Skip to main content

subc_daemon/
supervise.rs

1use std::{
2    collections::{HashMap, HashSet, VecDeque},
3    error::Error,
4    fmt, io,
5    path::PathBuf,
6    process::{ExitStatus, Stdio},
7    sync::{Arc, Mutex, OnceLock},
8    time::{Duration, SystemTime, UNIX_EPOCH},
9};
10
11use cortexkit_log::Retention;
12use serde_json::Value;
13use subc_control::{
14    ClientControlPush, LiveSpawn, ModuleProtocol, RouteCloseReason, SpawnCursor, SpawnEvent,
15    SpawnEventKind, SpawnSnapshot, SupervisorHealthStatus, TerminalDisposition, TerminalExitKind,
16};
17use subc_protocol::{
18    manifest::{SelfSignalKind, SignalAnchor},
19    session::{
20        HealthReport, HealthStatus, ModuleControlCommand, ModuleControlRequest,
21        MODULE_CONTROL_OP_HEALTH_CHECK,
22    },
23    Flags, FrameType, Priority, SUBC_LAUNCH_NONCE_ENV, SUBC_MODULE_ID_ENV,
24};
25use tokio::{
26    process::{Child, Command},
27    sync::{mpsc, oneshot, watch, Mutex as AsyncMutex},
28    task::JoinHandle,
29    time::{sleep, sleep_until, timeout, timeout_at, Instant},
30};
31use tracing::{debug, error, info, warn};
32
33use crate::{
34    child_roster::ChildRoster,
35    daemon_config::{
36        CAPTURE_KEEP_ENV, CAPTURE_MAX_AGE_DAYS_ENV, CAPTURE_MAX_FILE_MB_ENV, CK_LOG_ENV,
37    },
38    forwarding::{
39        CloseReason, ForwardingError, ForwardingTable, GoodbyeTarget, ModuleControlRpcOutcome,
40        ModuleDrainTarget, PendingModuleControlRpc,
41    },
42    provenance::{ExecutableIdentityProbe, SpawnedFileIdentity},
43    registry::{ConnectionId, RegistrationEndReason, RegistryError},
44    stderr_tail::{
45        pump_stderr_to, pump_stdout_to, ChildOutputSink, StderrRing, StderrTailConfig,
46        StderrTailSnapshot,
47    },
48    terminal_ring::{TerminalHistorySnapshot, TerminalRecord, TerminalRing, TerminalRingConfig},
49    Frame, FrameSink, Registry,
50};
51
52#[cfg(not(windows))]
53use crate::provenance::spawned_file_identity;
54
55#[path = "supervise_swap.rs"]
56mod swap;
57
58/// Command-line flag used by supervised modules to find subc.
59///
60/// subc launches module-mode children as `<module> --subc <connection-file-path>`.
61/// The path points at the TCP+key connection file; it is not an ambient signal and
62/// is never inherited by standalone children.
63pub const SUBC_ARG: &str = "--subc";
64
65const DEFAULT_MAX_RESTARTS: u32 = 3;
66const DEFAULT_BACKOFF: Duration = Duration::from_millis(100);
67const DEFAULT_MAX_BACKOFF: Duration = Duration::from_secs(30);
68/// The span `DEFAULT_MAX_RESTARTS` is counted over. Ten minutes is long enough
69/// to contain a real crash loop (which respawns in seconds) and short enough
70/// that unrelated crashes hours apart never accumulate into a permanent stop.
71const DEFAULT_RESTART_WINDOW: Duration = Duration::from_secs(600);
72/// How long a drain waits for already-dispatched requests to finalize before
73/// the child is torn down. Sized for TOOL-SCALE work (bash, inspect, builds),
74/// not RPC-scale: the original 2s value silently cut nearly every real tool
75/// call at the fence, making the wait-for-finalize design decorative for the
76/// workloads it existed for. Quiescence short-circuits, so an idle module
77/// restarts immediately regardless of this value; the budget is spent only
78/// when a genuine in-flight request is worth finishing. Per-module override:
79/// `drain_timeout_ms` in subc.jsonc; per-restart override: the operator's
80/// `supervisor.restart{drain_timeout_ms}` (0 = cut now, for wedge bounces
81/// where a stuck request will never settle).
82pub const DEFAULT_DRAIN_TIMEOUT: Duration = Duration::from_secs(30);
83const REGISTRY_RELEASE_TIMEOUT: Duration = Duration::from_secs(1);
84const REGISTRY_RELEASE_POLL: Duration = Duration::from_millis(10);
85/// How long a restart waits for the exited process's output readers.
86///
87/// The restart does not depend on either output reader finishing. A stderr
88/// reader still running at this bound is left running, and its later output goes
89/// into the exited process's own section of the stderr ring (see
90/// `StderrRing::push_line_from`), ending naturally at EOF on its pipe. So the
91/// bound no longer decides whether a crash's last lines are kept: under load
92/// the reader may simply not have been scheduled yet, and cutting it there
93/// discarded exactly the lines that explained the crash.
94///
95/// What the bound still decides is when the tail starts reporting
96/// `Incomplete`: a pipe open past it usually means a descendant of the exited
97/// process still holds it, and the tail cannot claim to be whole until that
98/// pipe closes. That is also the only case in which the wait costs the restart
99/// anything, because a pipe with no other holder reaches EOF when the process
100/// exits. Under load a slow reader can show `Incomplete` briefly; it returns to
101/// `Captured` at EOF with nothing lost.
102///
103/// The stdout reader carries no ring, only the capture file. It also continues
104/// to EOF after this bound: child exit closes the writer but does not mean the
105/// reader has consumed all buffered bytes. Late output from either pipe can
106/// therefore reach the shared capture file after the successor starts.
107const STDERR_PUMP_DRAIN_TIMEOUT: Duration = Duration::from_millis(250);
108/// Maximum number of supervised process spawn/exit facts retained per daemon incarnation.
109pub const SPAWN_EVENT_RING_CAPACITY: usize = 4096;
110const SPAWN_SUBSCRIBER_BUFFER: usize = SPAWN_EVENT_RING_CAPACITY + 1;
111/// Terminal Error code for a `supervisor.spawn_subscribe` stream the daemon
112/// dropped because the subscriber stopped draining its frames. The detail's
113/// `first_undelivered_cursor` names the first event it did not receive; the
114/// client resubscribes from the last cursor it did receive.
115pub(crate) const SPAWN_SUBSCRIBER_LAGGED_CODE: &str = "spawn_subscriber_lagged";
116
117#[cfg(target_os = "macos")]
118struct PrivacyExec {
119    reader: tokio::io::unix::AsyncFd<std::io::PipeReader>,
120    deadline: tokio::time::Instant,
121    expected: Option<subc_os::FileIdentity>,
122    trampoline: Option<subc_os::FileIdentity>,
123    script: bool,
124    module_id: String,
125}
126
127#[cfg(all(test, target_os = "macos"))]
128pub(crate) fn test_privacy_trampoline() -> PathBuf {
129    // Cargo's unit-test executable lives in <profile>/deps; its fixture bin
130    // lives beside that directory. This honors custom CARGO_TARGET_DIR too.
131    let path = std::env::current_exe()
132        .unwrap()
133        .parent()
134        .unwrap()
135        .parent()
136        .unwrap()
137        .join("privacy-trampoline-fixture");
138    // Without the fixture every macOS spawn is refused, and the tests that
139    // spawn fail later as a module in state Failed, which names the wrong
140    // cause. `cargo test -p subc-daemon --lib` alone does not build it.
141    assert!(
142        path.exists(),
143        "privacy-trampoline-fixture not built at {}: run `cargo build -p subc-daemon \
144         --bins --features test-support` or `cargo test -p subc-daemon` first",
145        path.display()
146    );
147    path
148}
149
150#[cfg(target_os = "macos")]
151fn probe_privacy_trampoline(path: &std::path::Path) -> Result<(), String> {
152    use std::io::Read;
153    let mut probe = std::process::Command::new(path)
154        .args(["__disclaim-exec", "--probe"])
155        .stdin(Stdio::null())
156        .stdout(Stdio::piped())
157        .stderr(Stdio::piped())
158        .spawn()
159        .map_err(|error| {
160            format!(
161                "privacy trampoline probe failed for {}: {error}",
162                path.display()
163            )
164        })?;
165    let deadline = std::time::Instant::now() + Duration::from_secs(5);
166    let status = loop {
167        match probe.try_wait() {
168            Ok(Some(status)) => break status,
169            Ok(None) if std::time::Instant::now() < deadline => {
170                std::thread::sleep(Duration::from_millis(5))
171            }
172            result => {
173                let _ = probe.kill();
174                let _ = probe.wait();
175                return Err(format!(
176                    "privacy trampoline probe failed or timed out for {}: {result:?}",
177                    path.display()
178                ));
179            }
180        }
181    };
182    let mut answer = String::new();
183    if let Some(stdout) = probe.stdout.take() {
184        let _ = stdout.take(256).read_to_string(&mut answer);
185    }
186    if status.success() && answer.trim() == subc_os::privacy_identity::TRAMPOLINE_PROBE {
187        return Ok(());
188    }
189    let mut diagnostic = String::new();
190    if let Some(stderr) = probe.stderr.take() {
191        let _ = stderr.take(1024).read_to_string(&mut diagnostic);
192    }
193    let cause = diagnostic
194        .trim()
195        .strip_prefix("ck-subc: own privacy identity refused: ")
196        .unwrap_or("binary does not implement the privacy trampoline protocol");
197    Err(format!(
198        "{cause}: probe of {} exited {status}",
199        path.display()
200    ))
201}
202
203#[cfg(target_os = "macos")]
204fn privacy_command(
205    spec: &ModuleSpec,
206    roster: &ChildRoster,
207) -> Result<
208    (
209        Command,
210        Option<PrivacyExec>,
211        subc_os::privacy_identity::ExecAcknowledgement,
212    ),
213    SuperviseError,
214> {
215    let failure = |cause: String| {
216        warn!(module_id = %spec.module_id, %cause, "privacy identity trampoline refused module spawn");
217        SuperviseError::Spawn {
218            program: spec.program.clone(),
219            source: io::Error::other(cause),
220            cgroup_path: None,
221        }
222    };
223    let trampoline = roster.privacy_trampoline().map_err(failure)?;
224    // Resolve PATH with the same environment the Command will receive. For
225    // scripts retain the existing orphan-identity rule: the kernel chooses
226    // the interpreter, and its observed image is the one recorded. Do not
227    // duplicate the kernel's shebang/PATH interpreter resolution in Rust.
228    let program = if spec.program.components().count() == 1 && !spec.program.is_absolute() {
229        let path = spec
230            .env
231            .iter()
232            .find(|(key, _)| key == "PATH")
233            .map(|(_, value)| std::ffi::OsString::from(value))
234            .or_else(|| std::env::var_os("PATH"))
235            .unwrap_or_else(|| "/usr/bin:/bin".into());
236        std::env::split_paths(&path)
237            .map(|dir| dir.join(&spec.program))
238            .find(|path| path.is_file())
239            .unwrap_or_else(|| spec.program.clone())
240    } else {
241        spec.program.clone()
242    };
243    let expected = subc_os::file_identity(&program);
244    let trampoline_image = subc_os::file_identity(&trampoline);
245    let script = {
246        use std::io::Read;
247        let mut prefix = [0u8; 2];
248        std::fs::File::open(&program)
249            .is_ok_and(|mut file| file.read_exact(&mut prefix).is_ok() && &prefix == b"#!")
250    };
251    if expected.is_none() || expected == subc_os::file_identity(&trampoline) {
252        return Err(failure(
253            "privacy identity module executable is missing or is the trampoline itself".to_string(),
254        ));
255    }
256    let (reader, ack) = subc_os::privacy_identity::ExecAcknowledgement::pipe()
257        .map_err(|error| failure(error.to_string()))?;
258    let reader =
259        tokio::io::unix::AsyncFd::new(reader).map_err(|error| failure(error.to_string()))?;
260    let mut command = Command::new(&trampoline);
261    command
262        .arg("__disclaim-exec")
263        .arg(ack.fd().to_string())
264        .arg(&program);
265    ack.install(command.as_std_mut());
266    Ok((
267        command,
268        Some(PrivacyExec {
269            reader,
270            deadline: tokio::time::Instant::now() + Duration::from_secs(5),
271            expected,
272            trampoline: trampoline_image,
273            script,
274            module_id: spec.module_id.clone(),
275        }),
276        ack,
277    ))
278}
279
280struct SupervisedChild {
281    child: Child,
282    #[cfg(windows)]
283    nonce_delivery: Option<subc_os::LaunchNoncePipeDelivery>,
284    #[cfg(target_os = "macos")]
285    privacy_exec: Option<PrivacyExec>,
286    /// Set once this launch's exec acknowledgement confirms the module image.
287    /// On macOS the pid first runs the `ck-subc` launch trampoline (see
288    /// `subc_os::privacy_identity`), which then replaces itself with the
289    /// module. The supervisor owns and can kill that pid from spawn, but
290    /// status readers report it only after this latch is set, so nothing
291    /// reports the trampoline's image as the module's.
292    #[cfg(target_os = "macos")]
293    report_ready: Arc<OnceLock<()>>,
294    /// Refusal before the module image was accepted, retained for terminal records.
295    spawn_failure: Option<String>,
296    /// The protocol this process was launched with. A reload can store a new
297    /// launch spec with a different protocol, but that takes effect only at the
298    /// next spawn, so this process keeps being handled by the protocol it
299    /// actually speaks.
300    protocol: ModuleProtocol,
301    /// This process's cgroup name: a bounded module/slot label followed by a
302    /// spawn suffix unique to this process (when cgroup placement is on). A
303    /// retired process in a slot may still be draining when a later one is
304    /// spawned into that slot, so the suffix keeps the later process out of
305    /// the retired one's cgroup, which is the domain a kill applies to.
306    #[cfg(target_os = "linux")]
307    module_id: String,
308    #[cfg(target_os = "linux")]
309    cgroup_placement: Option<subc_cgroup::Placement>,
310    /// The job that contains this child and every process it spawns (issue #109).
311    ///
312    /// Dropping this handle is what reaps a surviving tree when no supervisor
313    /// code runs — a daemon crash — because the job carries
314    /// `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`.
315    ///
316    /// That limit is not crash-only, and the difference is worth knowing: a
317    /// Windows daemon *stop* is `taskkill` or the scheduler's `/End` — the
318    /// SIGTERM handler is `#[cfg(unix)]` — so the daemon dies with no stop
319    /// notice and the kernel closes the job handle, `TerminateProcess`ing every
320    /// module at once. Before this change they survived that, saw EOF on the
321    /// control socket, and ran their own teardown; Unix keeps that path
322    /// deliberately, so a module can seal a WAL or close a capture rather than
323    /// be killed mid-write. So this trades graceful teardown on every Windows
324    /// daemon stop for containment on a crash, which is the right way round
325    /// today: orphaned GPU workers are a reported, recurring problem, and the
326    /// modules that write most heavily do not run on Windows.
327    ///
328    /// The fix is a real Windows stop path — the daemon draining before it
329    /// exits, the twin of the Unix SIGTERM handler. Once it exists, this limit
330    /// reaches only what the drain left behind, which is what it should reach.
331    #[cfg(windows)]
332    job: Option<subc_jobobject::JobObject>,
333    stdout_pump: Option<JoinHandle<()>>,
334    stderr_pump: Option<StderrPump>,
335    stderr_ring: Arc<Mutex<StderrRing>>,
336    spawned_at_ms: u64,
337    spawned_from: PathBuf,
338    spawned_file_identity: Option<SpawnedFileIdentity>,
339    process_start_time: Option<u64>,
340    process_identity: Option<ProcessIdentity>,
341    pid: u32,
342    /// This process's entry in the daemon's child roster, released when the
343    /// process is reaped or this handle is dropped.
344    roster_guard: Option<crate::child_roster::RosterGuard>,
345}
346
347impl SupervisedChild {
348    fn id(&self) -> Option<u32> {
349        Some(self.pid)
350    }
351
352    fn process_identity(&self) -> Option<ProcessIdentity> {
353        self.process_identity
354    }
355
356    async fn wait(&mut self) -> io::Result<ExitStatus> {
357        #[cfg(target_os = "macos")]
358        self.confirm_privacy_exec().await;
359        // The roster entry is NOT released here. A daemon shutdown waits for the
360        // roster to empty and then exits the process, so releasing at the reap
361        // let it exit before the exit handler wrote this child's terminal record
362        // (the stderr drain and snapshot update sit in between), and the
363        // shutdown's own `daemon_shutdown` record was intermittently lost. The
364        // caller releases it after recording the exit (`release_roster`), and
365        // dropping the handle releases it too.
366        let result = self.child.wait().await;
367        #[cfg(target_os = "linux")]
368        if result.is_ok() {
369            if let Some(placement) = self.cgroup_placement.as_ref() {
370                cleanup_reaped_cgroup(placement, &self.module_id).await;
371                // Keep ownership while awaiting kernel population changes: a
372                // drain timeout may cancel this wait and then escalate/reap.
373                self.cgroup_placement = None;
374            }
375        }
376        result
377    }
378
379    #[cfg(target_os = "macos")]
380    async fn confirm_privacy_exec(&mut self) {
381        let Some(pending) = &mut self.privacy_exec else {
382            return;
383        };
384        let result = tokio::time::timeout_at(pending.deadline, async {
385            let mut record = Vec::new();
386            loop {
387                let mut ready = pending.reader.readable().await?;
388                let read = ready.try_io(|reader| {
389                    use std::io::Read;
390                    let mut reader = reader.get_ref();
391                    let mut buffer = [0u8; 256];
392                    reader.read(&mut buffer).map(|count| (count, buffer))
393                });
394                match read {
395                    Ok(Ok((0, _))) => return Ok::<_, io::Error>(record),
396                    Ok(Ok((count, buffer))) => {
397                        if record.len() + count > 1024 {
398                            return Err(io::Error::other(
399                                "privacy exec refusal record is too long",
400                            ));
401                        }
402                        record.extend_from_slice(&buffer[..count]);
403                    }
404                    Ok(Err(error)) => return Err(error),
405                    Err(_) => continue,
406                }
407            }
408        })
409        .await;
410        // Keep the reader in self across await: select cancellation must not
411        // discard the handshake or reset its original five-second deadline.
412        let pending = self.privacy_exec.as_ref().expect("pending exec");
413        let cause = match result {
414            Err(_) => Some("privacy identity trampoline did not exec within 5s".to_string()),
415            Ok(Err(error)) => Some(format!(
416                "privacy identity exec acknowledgement failed: {error}"
417            )),
418            Ok(Ok(record)) if !record.is_empty() => Some(
419                std::str::from_utf8(&record)
420                    .ok()
421                    .and_then(|record| {
422                        record
423                            .trim()
424                            .strip_prefix(subc_os::privacy_identity::EXEC_REFUSAL_TAG)
425                    })
426                    .filter(|cause| !cause.is_empty())
427                    .unwrap_or("invalid privacy exec refusal record")
428                    .to_string(),
429            ),
430            Ok(Ok(_)) => match self.child.try_wait() {
431                // Empty EOF is the exec acknowledgement. A real module may exit
432                // immediately, including with a reserved trampoline status; no
433                // image is admitted, and its ordinary exit contract stays intact.
434                Ok(Some(_status)) => None,
435                Err(error) => Some(format!("privacy identity trampoline wait failed: {error}")),
436                Ok(None) => {
437                    let image = observe_spawned_image(self.pid);
438                    if let Some(image) = image.filter(|image| {
439                        image.executable.is_some()
440                            && image.executable != pending.trampoline
441                            && (image.executable == pending.expected || pending.script)
442                    }) {
443                        if let Some(guard) = &self.roster_guard {
444                            guard.confirm_executable(image);
445                        }
446                        let _ = self.report_ready.set(());
447                        info!(module_id = %pending.module_id, pid = self.pid,
448                            "module spawned with own privacy identity (responsibility disclaimed)");
449                        None
450                    } else if image.is_none()
451                        || image.is_some_and(|image| image.executable.is_none())
452                    {
453                        // A process can exit between try_wait and the kernel
454                        // image read. Empty EOF already acknowledged exec, so
455                        // preserve that module's ordinary exit rather than
456                        // mislabel a disappearing image as trampoline refusal.
457                        // Keep pending in self across await so cancellation does
458                        // not discard validation or reset its original deadline.
459                        match tokio::time::timeout_at(pending.deadline, self.child.wait()).await {
460                            Ok(Ok(_status)) => None,
461                            Ok(Err(error)) => Some(format!("privacy identity trampoline wait failed: {error}")),
462                            Err(_) => Some("privacy identity module executable remained unreadable until the 5s exec deadline".to_string()),
463                        }
464                    } else {
465                        Some("privacy identity trampoline executable mismatch: expected the module program, not ck-subc".to_string())
466                    }
467                }
468            },
469        };
470        let pending = self.privacy_exec.take().expect("pending exec");
471        if let Some(cause) = cause {
472            warn!(module_id = %pending.module_id, pid = self.pid, %cause, "privacy identity trampoline refused module spawn");
473            self.spawn_failure = Some(cause);
474            // No image is admitted on failure. Reach the entire fresh process
475            // group, including a module which spawned a helper before refusal.
476            if let Some(pid) = rustix::process::Pid::from_raw(self.pid as i32) {
477                let _ = rustix::process::kill_process_group(pid, rustix::process::Signal::KILL);
478            }
479            let _ = self.child.start_kill();
480        }
481    }
482
483    /// Releases this child's daemon-shutdown roster entry once its exit has
484    /// been recorded. The pid is already reaped and free for reuse, so the
485    /// entry must not outlive the record any longer than that.
486    fn release_roster(&mut self) {
487        self.roster_guard = None;
488    }
489
490    /// Kill the child and, where containment is available, its process tree.
491    ///
492    /// `Child::kill` is `TerminateProcess` scoped to one pid, so a module with a
493    /// helper process leaked the helper — the Synapse embedding module's CUDA
494    /// worker holds the GPU allocation, so the leak cost VRAM until the next
495    /// restart of something else. Terminating the job reaches grandchildren that
496    /// a tree walk cannot, including one whose parent has already exited and
497    /// been reparented away.
498    ///
499    /// Best-effort like `request_graceful_stop`: a job failure is logged and the
500    /// direct-child kill still decides the outcome, so containment can never
501    /// change whether a module is reported as stopped.
502    fn start_kill(&mut self) -> io::Result<()> {
503        #[cfg(windows)]
504        if let Some(job) = &self.job {
505            if let Err(error) = job.terminate() {
506                debug!(
507                    error = %error,
508                    "job termination failed; the direct-child kill still owns the outcome"
509                );
510            }
511        }
512        #[cfg(target_os = "linux")]
513        kill_module_cgroup(self.cgroup_placement.as_ref(), &self.module_id);
514        self.child.start_kill()
515    }
516
517    async fn drain_stderr(&mut self, module_id: &str) {
518        if let Some(pump) = self.stdout_pump.take() {
519            settle_stdout_pump(module_id, pump, STDERR_PUMP_DRAIN_TIMEOUT).await;
520        }
521
522        let Some(pump) = self.stderr_pump.take() else {
523            return;
524        };
525        settle_stderr_pump(
526            module_id,
527            &self.stderr_ring,
528            pump,
529            STDERR_PUMP_DRAIN_TIMEOUT,
530        )
531        .await;
532    }
533}
534
535/// Bound the restart's wait, not the lifetime of the exited child's stdout
536/// reader. Slow pipe reads or capture writes must not turn the bound into lost
537/// output; dropping the handle lets the reader finish at EOF, like stderr.
538async fn settle_stdout_pump(module_id: &str, mut pump: JoinHandle<()>, bound: Duration) {
539    match timeout(bound, &mut pump).await {
540        Ok(Ok(())) => {}
541        Ok(Err(error)) => {
542            warn!(module_id, error = %error, "stdout pump ended unexpectedly");
543        }
544        Err(_) => {
545            drop(pump);
546            warn!(
547                module_id,
548                waited = ?bound,
549                "stdout pipe of the exited process has not drained; its reader keeps running without delaying the restart"
550            );
551        }
552    }
553}
554
555/// The reader task for one process's stderr, with the ring generation its
556/// lines are attributed to.
557struct StderrPump {
558    task: JoinHandle<()>,
559    generation: u64,
560}
561
562/// Retire an exited process's stderr reader and wait up to `bound` for it to
563/// reach EOF. A reader still running at the bound is detached, not stopped: it
564/// keeps filling the exited process's section of the ring until its pipe
565/// closes, and the tail reads `Incomplete` until then. See
566/// [`STDERR_PUMP_DRAIN_TIMEOUT`] for why.
567async fn settle_stderr_pump(
568    module_id: &str,
569    ring: &Arc<Mutex<StderrRing>>,
570    pump: StderrPump,
571    bound: Duration,
572) {
573    let lock = || ring.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
574    let StderrPump {
575        mut task,
576        generation,
577    } = pump;
578    lock().retire_pump(generation);
579    match timeout(bound, &mut task).await {
580        Ok(Ok(())) => {}
581        Ok(Err(err)) => {
582            let mut ring = lock();
583            ring.mark_incomplete_from(generation, format!("stderr pump ended unexpectedly: {err}"));
584            ring.finish_pump(generation);
585            warn!(module_id, error = %err, "stderr pump ended before clean EOF");
586        }
587        Err(_) => {
588            // Dropping the handle detaches the task; it ends at EOF on its pipe.
589            drop(task);
590            lock().mark_pump_late(
591                generation,
592                format!(
593                    "stderr of the exited process had not reached EOF {bound:?} after it was \
594                     retired (a descendant may still hold the pipe open); lines it still \
595                     writes are kept in that process's section"
596                ),
597            );
598            warn!(
599                module_id,
600                waited = ?bound,
601                "stderr pipe of the exited process is still open; its reader keeps running without delaying the restart"
602            );
603        }
604    }
605}
606
607fn registration_release_events() -> &'static watch::Sender<u64> {
608    static EVENTS: OnceLock<watch::Sender<u64>> = OnceLock::new();
609    EVENTS.get_or_init(|| {
610        let (sender, _receiver) = watch::channel(0);
611        sender
612    })
613}
614
615pub(crate) fn notify_registration_release() {
616    let events = registration_release_events();
617    let next_generation = (*events.borrow()).wrapping_add(1);
618    events.send_replace(next_generation);
619}
620
621/// How to launch one singleton module process.
622#[derive(Debug, Clone, PartialEq, Eq)]
623pub struct ModuleSpec {
624    pub module_id: String,
625    pub program: PathBuf,
626    pub args: Vec<String>,
627    pub env: Vec<(String, String)>,
628    /// When true this is a reserved module: each spawn gets a fresh one-time launch
629    /// nonce that the child must echo in its HELLO, so only the daemon-spawned
630    /// process can register this module_id (a security-boundary module like the
631    /// credential vault must not be impersonable while it is down/restarting).
632    pub reserved: bool,
633    /// Module-id prefixes this supervised module owns for reserved HELLO checks.
634    /// Prefixes come from daemon config and must end in `:` before they reach the
635    /// supervisor; the owner module's current spawn nonce authorizes claims under
636    /// each prefix.
637    pub reserved_prefixes: Vec<String>,
638    /// The wire protocol this module speaks, as DECLARED in daemon config.
639    ///
640    /// [`ModuleProtocol::None`] changes five things and nothing else: wire health
641    /// probing is suppressed (an optional HTTP probe can replace it), teardown sends SIGTERM before waiting,
642    /// `route.open` is refused, the spawn passes NO `--subc <path>` argument
643    /// and NO launch nonce, and a clean exit the daemon did not request is
644    /// restarted as a crash rather than recorded as a stop (see `on_child_exit`:
645    /// a stock program exits 0 on a stray SIGTERM, and a stop would leave it
646    /// down for good). `SUBC_MODULE_ID` still goes into the environment,
647    /// because a process ignores an environment variable it does not read.
648    ///
649    /// The argument is the part that cannot be "harmless to a process that
650    /// ignores it": a stock binary exits on an unknown flag before it listens
651    /// (`nats-server`: "flag provided but not defined: -subc"), which is how the
652    /// first conformance run against this mode found it. The nonce is withheld
653    /// because a process that will never present it gains nothing from holding
654    /// it, and a secret in the environment of a process that does not need it is
655    /// a leak surface for no benefit.
656    pub protocol: ModuleProtocol,
657    /// Whether two processes of this module may run at once, which is what a
658    /// blue/green swap does for the length of its overlap. Declared in daemon
659    /// config because the daemon must be able to answer it while the module is
660    /// down, and so a module cannot talk itself into it after registering.
661    pub overlap: ModuleOverlap,
662}
663
664/// Whether a module tolerates a second process of itself running alongside.
665///
666/// Most modules are single-writer on their store (a WAL, a capture log, a
667/// resident index behind a writer barrier), and two processes on one store
668/// corrupt it. So a swap, which overlaps the old and new process by design,
669/// is refused unless the module's config opts in with `overlap: "safe"`.
670#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
671pub enum ModuleOverlap {
672    /// Never run two processes of this module at once. The default.
673    #[default]
674    Exclusive,
675    /// The module has said a second process of itself is harmless for the
676    /// length of a swap.
677    ///
678    /// Declare it only if a second instance can run for a few seconds without
679    /// touching ANY single-writer store: every database, WAL, index, projector
680    /// and scheduled job the module owns. A lease on part of that state is not
681    /// enough. broca's session lease guards WAL appends while its run index, its
682    /// store projector and its archive fold timer (which unlinks live WAL files)
683    /// stay single-writer, so broca is exclusive despite holding a lease. The
684    /// refusal only fires after this has been decided, so the decision is the
685    /// check.
686    Safe,
687}
688
689impl ModuleOverlap {
690    pub fn as_str(self) -> &'static str {
691        match self {
692            Self::Exclusive => "exclusive",
693            Self::Safe => "safe",
694        }
695    }
696}
697
698/// Environment variable telling a spawned module which case it was started
699/// for, before it sends HELLO. Only a swap candidate carries it, as
700/// [`SPAWN_ROLE_SWAP_CANDIDATE`]; every other spawn has it removed.
701///
702/// It chooses a warm-up budget, nothing else: a swap candidate can warm for
703/// longer because nobody waits on it, while a plain restart must flip ready
704/// quickly because callers see `module_warming` until it does. Absence means
705/// plain restart, the safe reading. The daemon trusts nothing about it; the
706/// candidate is proven by its launch nonce at HELLO.
707pub const SUBC_SPAWN_ROLE_ENV: &str = "SUBC_SPAWN_ROLE";
708/// The one value of [`SUBC_SPAWN_ROLE_ENV`] the daemon sets.
709pub const SPAWN_ROLE_SWAP_CANDIDATE: &str = "swap_candidate";
710/// How long a swap waits for its candidate to register and declare itself
711/// ready when the operator does not say. A module warming as a swap candidate
712/// may take up to 90 s (aft's ceiling, the largest in the fleet), so the
713/// daemon allows that plus time to start the process and send HELLO.
714pub const DEFAULT_SWAP_READY_TIMEOUT: Duration = Duration::from_secs(100);
715
716/// Bounded restart policy for crash exits.
717///
718/// `max_restarts` is the number of replacement processes allowed after the
719/// initial spawn WITHIN `window`. After that many crash restarts inside one
720/// window the module enters [`ModuleState::Failed`] and the supervisor stops
721/// the crash loop.
722///
723/// The budget is a RATE, not a lifetime total. It used to be a lifetime total,
724/// and that only survived because crashes were rare: a module that crashed
725/// three times across a week was disabled forever by crashes that had nothing
726/// to do with each other. That stopped being survivable once modules began
727/// exiting non-zero whenever the daemon's connection to them drops, because
728/// then every daemon-side connection drop spends a unit of the same budget and
729/// one flappy hour permanently stops a healthy module. Restarts older than
730/// `window` release their slot, so a module that crashed twice yesterday has a
731/// full budget today, while a genuine crash loop -- which is fast by
732/// definition -- still reaches the cap and stops.
733#[derive(Debug, Clone, Copy, PartialEq, Eq)]
734pub struct RestartPolicy {
735    pub max_restarts: u32,
736    /// Base delay before a crash replacement. The actual delay escalates with
737    /// the number of recent crash replacements and is capped by `max_backoff`.
738    pub backoff: Duration,
739    /// Maximum delay before a crash replacement.
740    pub max_backoff: Duration,
741    /// The span `max_restarts` is counted over. `Duration::ZERO` makes the
742    /// budget effectively infinite (nothing is ever in-window), which is why
743    /// daemon config refuses `window_secs: 0` rather than quietly accepting it.
744    pub window: Duration,
745}
746
747impl RestartPolicy {
748    /// A policy with the default crash window. Callers that care about the
749    /// window say so with [`Self::with_window`]; the ones that do not are
750    /// asking for the standard rate limit, not for no limit.
751    pub fn new(max_restarts: u32, backoff: Duration) -> Self {
752        Self {
753            max_restarts,
754            backoff,
755            max_backoff: DEFAULT_MAX_BACKOFF,
756            window: DEFAULT_RESTART_WINDOW,
757        }
758    }
759
760    pub fn with_max_backoff(mut self, max_backoff: Duration) -> Self {
761        self.max_backoff = max_backoff;
762        self
763    }
764
765    pub fn with_window(mut self, window: Duration) -> Self {
766        self.window = window;
767        self
768    }
769
770    /// Calculate the capped exponential delay for the next crash replacement.
771    /// `restart_in_window` is zero for the first replacement after an operator
772    /// action (restart, reload, re-enable) cleared the crash ring, or after all
773    /// older crash replacements have aged out of the window.
774    fn delay_for_restart(&self, restart_in_window: u32) -> Duration {
775        if self.backoff.is_zero() || self.max_backoff.is_zero() {
776            return Duration::ZERO;
777        }
778
779        let mut delay = self.backoff;
780        for _ in 0..restart_in_window {
781            if delay >= self.max_backoff {
782                return self.max_backoff;
783            }
784            delay = delay
785                .checked_mul(10)
786                .unwrap_or(self.max_backoff)
787                .min(self.max_backoff);
788        }
789        delay.min(self.max_backoff)
790    }
791
792    /// The one sentence that explains a budget-exhausted stop, used for both the
793    /// log line and the terminal record so the two cannot drift. It names the
794    /// window because `max_restarts=3` alone reads as a lifetime cap, which is
795    /// exactly what this budget is not.
796    fn budget_exhausted_detail(&self) -> String {
797        format!(
798            "crash budget exhausted: max_restarts={} within window_secs={}",
799            self.max_restarts,
800            self.window.as_secs()
801        )
802    }
803}
804
805impl Default for RestartPolicy {
806    fn default() -> Self {
807        Self {
808            max_restarts: DEFAULT_MAX_RESTARTS,
809            backoff: DEFAULT_BACKOFF,
810            max_backoff: DEFAULT_MAX_BACKOFF,
811            window: DEFAULT_RESTART_WINDOW,
812        }
813    }
814}
815
816#[derive(Debug, Clone, Copy, PartialEq, Eq)]
817struct CrashRestartSchedule {
818    restart_in_window: u32,
819    delay: Duration,
820}
821
822/// Whether the daemon itself will bring this module back after the exit being
823/// handled: it is enabled AND its in-window crash restarts are below the cap.
824///
825/// Takes `&mut` because reading the budget prunes it. Instants that fell out of
826/// the window are dropped here rather than by a timer, so the count is right
827/// the moment somebody asks and no bookkeeping runs for idle modules.
828fn daemon_will_restart(
829    state: &mut SupervisorSnapshot,
830    policy: &RestartPolicy,
831    now: Instant,
832) -> bool {
833    state.enabled && state.crash_restarts_in_window(policy.window, now) < policy.max_restarts
834}
835
836const DEFAULT_HEALTH_CADENCE: Duration = Duration::from_secs(30);
837const DEFAULT_HEALTH_DEADLINE: Duration = Duration::from_secs(5);
838const DEFAULT_HEALTH_FAILURE_THRESHOLD: u32 = 3;
839const MAX_HEALTH_METRICS_BYTES: usize = 16 * 1024;
840
841#[derive(Debug, Clone, Copy, PartialEq, Eq)]
842pub enum HealthAction {
843    Report,
844    Restart,
845    Alert,
846}
847
848impl fmt::Display for HealthAction {
849    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
850        f.write_str(match self {
851            Self::Report => "report",
852            Self::Restart => "restart",
853            Self::Alert => "alert",
854        })
855    }
856}
857
858#[derive(Debug, Clone, PartialEq, Eq)]
859pub struct HealthConfig {
860    /// Optional loopback HTTP endpoint for a managed non-wire process.
861    /// Changing it applies live on rescan; the process protocol changes only
862    /// at its next spawn.
863    pub http: Option<String>,
864    pub cadence: Duration,
865    pub deadline: Duration,
866    pub failure_threshold: u32,
867    pub on_degraded: HealthAction,
868    pub on_failing: HealthAction,
869    pub critical: bool,
870}
871
872impl Default for HealthConfig {
873    fn default() -> Self {
874        Self {
875            http: None,
876            cadence: DEFAULT_HEALTH_CADENCE,
877            deadline: DEFAULT_HEALTH_DEADLINE,
878            failure_threshold: DEFAULT_HEALTH_FAILURE_THRESHOLD,
879            on_degraded: HealthAction::Report,
880            on_failing: HealthAction::Report,
881            critical: false,
882        }
883    }
884}
885
886/// The supervisor's view of one module's health, relayed to clients over
887/// channel-0 and rendered by `ck health`.
888///
889/// THIS TYPE IS WHERE THE ABSENCE MEANINGS ARE CREATED, which is why they are
890/// stated here rather than only at the wire type a consumer reads. A reader can
891/// look up what `None` means; only a writer can silently change it, and the
892/// writer has no reason to go looking at a downstream contract before editing.
893///
894/// `last_probe_ms: None` MEANS NEVER PROBED, not probed-long-ago. It is cleared
895/// back to `None` on re-registration precisely so a respawned module does not
896/// carry its predecessor's timestamp — so an old value and an absent one call for
897/// opposite readings, and anything that defaulted this to a number would make a
898/// never-probed module indistinguishable from one probed at the epoch.
899///
900/// `detail` and `metrics` are `None` when the module published none on this
901/// probe, which does not mean it reported nothing wrong — it is also the shape
902/// when the probe never reached it. `last_probe_ms` is what separates those.
903#[derive(Debug, Clone, PartialEq)]
904pub struct ModuleHealthStatus {
905    pub status: SupervisorHealthStatus,
906    pub last_probe_ms: Option<u64>,
907    pub detail: Option<String>,
908    pub metrics: Option<Value>,
909    pub consecutive_failures: u32,
910    /// Number of replies received after a recurring health probe's deadline.
911    /// Unlike a timeout, every increment proves the module was alive.
912    pub late_answer_count: u64,
913    /// End-to-end latency of the newest late reply, measured from probe start.
914    pub last_late_answer_latency_ms: Option<u64>,
915    pub last_action: Option<String>,
916    /// Set together with `last_action`; the pair moves as one, and both being
917    /// absent means no escalation has ever been taken rather than that the last
918    /// one succeeded.
919    pub last_action_ms: Option<u64>,
920}
921
922impl Default for ModuleHealthStatus {
923    fn default() -> Self {
924        Self {
925            status: SupervisorHealthStatus::Unknown,
926            last_probe_ms: None,
927            detail: None,
928            metrics: None,
929            consecutive_failures: 0,
930            late_answer_count: 0,
931            last_late_answer_latency_ms: None,
932            last_action: None,
933            last_action_ms: None,
934        }
935    }
936}
937
938/// Typed lifecycle state for a supervised module.
939#[derive(Debug, Clone, Copy, PartialEq, Eq)]
940pub enum ModuleState {
941    Starting,
942    Running,
943    Unresponsive,
944    Restarting,
945    Draining,
946    Stopped,
947    Failed,
948    Disabled,
949}
950
951impl fmt::Display for ModuleState {
952    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
953        f.write_str(match self {
954            Self::Starting => "starting",
955            Self::Running => "running",
956            Self::Unresponsive => "unresponsive",
957            Self::Restarting => "restarting",
958            Self::Draining => "draining",
959            Self::Stopped => "stopped",
960            Self::Failed => "failed",
961            Self::Disabled => "disabled",
962        })
963    }
964}
965
966/// Supervisor classification of a child-process exit.
967#[derive(Debug, Clone, Copy, PartialEq, Eq)]
968pub enum ExitKind {
969    Clean,
970    Crash,
971    DeliberateSeverance,
972}
973
974impl From<ExitKind> for TerminalExitKind {
975    fn from(kind: ExitKind) -> Self {
976        match kind {
977            ExitKind::Clean => Self::Clean,
978            ExitKind::Crash => Self::Crash,
979            ExitKind::DeliberateSeverance => Self::DeliberateSeverance,
980        }
981    }
982}
983
984/// Exact process identity retained when a supervised module registers its
985/// connection. PID reuse makes a PID alone insufficient evidence of ownership.
986#[derive(Debug, Clone, Copy, PartialEq, Eq)]
987pub(crate) struct ProcessIdentity {
988    pub(crate) pid: u32,
989    pub(crate) start_time: u64,
990}
991
992/// Last observed child exit, if any.
993#[derive(Debug, Clone, PartialEq, Eq)]
994pub struct ExitReport {
995    pub kind: ExitKind,
996    pub code: Option<i32>,
997    pub signal: Option<i32>,
998    pub at_ms: u64,
999}
1000
1001/// Point-in-time module status answerable by subc without forwarding to the
1002/// module process.
1003#[derive(Debug, Clone, PartialEq)]
1004pub struct ModuleStatus {
1005    pub module_id: String,
1006    /// The daemon's Windows per-spawn record: `pipe` after the process it
1007    /// spawned read all nonce bytes, otherwise `env` for the offered copy.
1008    /// List/status reports use this record, not the source the module sends
1009    /// in its registration message's manifest.provenance.launch_nonce_source.
1010    pub launch_nonce_source: Option<String>,
1011    pub state: ModuleState,
1012    pub enabled: bool,
1013    pub process_alive: bool,
1014    pub registration_active: bool,
1015    /// The module's declared wire protocol, carried beside `live` because it is
1016    /// what makes `live` readable: the two fields answer one question together.
1017    /// While a process is alive this is its launch declaration, not a later
1018    /// pending-reload edit. When down it is the configured next launch protocol.
1019    pub protocol: ModuleProtocol,
1020    /// Whether the module is serving, under the strongest definition the daemon
1021    /// can assert for its protocol.
1022    ///
1023    /// A subc module must also be REGISTERED: its process being alive says
1024    /// nothing about whether it can take a request. A `protocol: "none"` module
1025    /// never registers, so that term is dropped and this falls back to "enabled,
1026    /// running, and the process the daemon launched is alive" -- which is all
1027    /// the daemon observes about a process that speaks no subc wire. It stays a
1028    /// `bool` on the wire for compatibility; renderers pair it with `protocol`
1029    /// rather than printing it bare.
1030    pub live: bool,
1031    /// Crash restarts spent INSIDE `restart_window` as of this read. Older
1032    /// restarts have already released their slot, so this count can go down
1033    /// without anybody touching the module.
1034    pub restart_count: u32,
1035    /// Replacement processes spawned over this module's entire supervisor lifetime;
1036    /// unlike `restart_count`, this value is never reset by an operator action
1037    /// and never falls out of a window.
1038    pub lifetime_restarts: u32,
1039    pub spawn_generation: u64,
1040    /// The budget `restart_count` is spent against. Carried alongside the count
1041    /// because the count alone does not say how close the module is to being
1042    /// disabled, and reporting one without the other is what makes an
1043    /// about-to-be-retired module look ordinary.
1044    pub max_restarts: u32,
1045    /// The span `restart_count` is counted over. Carried with the pair above for
1046    /// the same reason they are carried together: "2 of 3" means one thing for a
1047    /// ten-minute window and something else entirely for a lifetime.
1048    pub restart_window: Duration,
1049    /// Effective drain and restart timing policy used by this running module.
1050    /// These values are carried together with the restart budget so status
1051    /// readers can compare configured intent with what the supervisor applied.
1052    pub drain_timeout: Duration,
1053    pub restart_backoff: Duration,
1054    pub restart_max_backoff: Duration,
1055    /// The module's process. On macOS this stays absent while the `ck-subc`
1056    /// launch trampoline is still running in that pid, and appears once the
1057    /// exec acknowledgement confirms the module image has replaced it. Launch
1058    /// time and the supervisor's own hold on the process are unaffected.
1059    pub pid: Option<u32>,
1060    pub spawned_at_ms: Option<u64>,
1061    pub spawned_from: Option<PathBuf>,
1062    pub process_start_time: Option<u64>,
1063    pub last_exit: Option<ExitReport>,
1064    pub health: ModuleHealthStatus,
1065}
1066
1067#[cfg(test)]
1068#[derive(Debug, Clone, Copy, PartialEq)]
1069struct ActorSelectCheckpoint {
1070    generation: u64,
1071    turn: u64,
1072    registered_connection: Option<ConnectionId>,
1073    next_probe_at: Option<Instant>,
1074    wake_after: Option<Duration>,
1075}
1076
1077#[derive(Debug, Clone, PartialEq)]
1078struct SupervisorSnapshot {
1079    #[cfg(windows)]
1080    pipe_consumed: Option<Arc<OnceLock<()>>>,
1081    /// Counts running-child loop turns, not executor polls of a parked wait.
1082    #[cfg(test)]
1083    actor_turns: u64,
1084    /// Running select after exec confirmation and with no unconsumed registry
1085    /// event. Recording a spawn or starting a loop turn is not this barrier.
1086    #[cfg(test)]
1087    actor_select: Option<ActorSelectCheckpoint>,
1088    state: ModuleState,
1089    enabled: bool,
1090    process_alive: bool,
1091    spawned_protocol: Option<ModuleProtocol>,
1092    spawn_failure: Option<String>,
1093    /// When each crash restart was spent, oldest first. This IS the crash
1094    /// budget: its in-window length is the count an operator sees and the count
1095    /// the restart decision is made against, so there is no second counter that
1096    /// can disagree with it. Bounded by `max_restarts`, and cleared by the same
1097    /// operator actions that used to zero the old lifetime counter.
1098    crash_restarts: VecDeque<Instant>,
1099    lifetime_restarts: u32,
1100    /// Successful child spawns in this daemon incarnation.
1101    ///
1102    /// `lifetime_restarts` was considered and rejected: it starts at zero
1103    /// (line 640), successful initial/operator spawns in `set_running` do not
1104    /// increment it (lines 5264-5274), and crash/deliberate retry bookkeeping
1105    /// increments before a successful replacement exists (lines 604, 3846,
1106    /// and 3921), so a failed spawn can consume it. This counter moves only
1107    /// when a live PID is accepted below.
1108    spawn_generation: u64,
1109    pid: Option<u32>,
1110    #[cfg(target_os = "macos")]
1111    report_ready: Option<Arc<OnceLock<()>>>,
1112    /// Last reaped child, retained after current process facts are cleared.
1113    reaped_pid: Option<u32>,
1114    /// Whether the command-serving supervision loop has a scheduled respawn.
1115    respawn_pending: bool,
1116    /// A second restart is waiting for the replacement already scheduled.
1117    coalesced_restart_pending: bool,
1118    spawned_at_ms: Option<u64>,
1119    spawned_from: Option<PathBuf>,
1120    spawned_file_identity: Option<SpawnedFileIdentity>,
1121    process_start_time: Option<u64>,
1122    deliberate_severance: Option<ProcessIdentity>,
1123    last_exit: Option<ExitReport>,
1124    /// Diagnostic attached to the next drain's terminal record, if any.
1125    drain_disposition_detail: Option<String>,
1126    health: ModuleHealthStatus,
1127    /// Whether the current process was started as a swap candidate and so
1128    /// lives in the module's alternate cgroup. The next swap's candidate takes
1129    /// the other one, so the two processes of a swap never share a cgroup. A
1130    /// plain spawn always uses the primary cgroup.
1131    in_alternate_slot: bool,
1132    /// Whether the current `Draining` state ends in a replacement process
1133    /// (restart, reload, health restart) rather than a stop. Only meaningful
1134    /// while `state` is `Draining`; every entry into that state rewrites it.
1135    /// It is what lets route.open answer the retryable `module_reloading` to a
1136    /// consumer that reaches a still-registered process mid-restart, instead of
1137    /// the `supervisor_not_live` a stop or disable deserves.
1138    draining_to_replace: bool,
1139    /// Whether a configuration update has been applied since the current
1140    /// process was spawned, so that process runs an older spec than the one
1141    /// the supervisor now holds. A queued restart is only coalesced into a
1142    /// fresher process when this is false: a restart requested to pick up a
1143    /// new configuration must not be satisfied by a process that predates it.
1144    configuration_updated_since_spawn: bool,
1145}
1146
1147impl SupervisorSnapshot {
1148    /// The pid that status, provenance and resource readings may report. While
1149    /// the launch trampoline still runs in the pid, reading its executable or
1150    /// resource use would describe `ck-subc`, not the module, so none is
1151    /// reported until the exec acknowledgement confirms the module image.
1152    fn reported_pid(&self) -> Option<u32> {
1153        #[cfg(target_os = "macos")]
1154        if self
1155            .report_ready
1156            .as_ref()
1157            .is_some_and(|ready| ready.get().is_none())
1158        {
1159            return None;
1160        }
1161        self.pid
1162    }
1163
1164    fn starting() -> Self {
1165        Self::new(ModuleState::Starting, true)
1166    }
1167
1168    fn disabled() -> Self {
1169        Self::new(ModuleState::Disabled, false)
1170    }
1171
1172    fn failed() -> Self {
1173        Self::new(ModuleState::Failed, true)
1174    }
1175
1176    /// Crash restarts still inside `window`, having dropped the ones that are
1177    /// not. Pruning on read is what makes the budget a rate: an instant older
1178    /// than the window stops holding a slot the moment anybody counts.
1179    fn crash_restarts_in_window(&mut self, window: Duration, now: Instant) -> u32 {
1180        while let Some(oldest) = self.crash_restarts.front() {
1181            if now.duration_since(*oldest) > window {
1182                self.crash_restarts.pop_front();
1183            } else {
1184                break;
1185            }
1186        }
1187        u32::try_from(self.crash_restarts.len()).unwrap_or(u32::MAX)
1188    }
1189
1190    /// Spend one unit of the crash budget and record the restart in the ledger.
1191    ///
1192    /// The ring is bounded by the cap because more than `max_restarts` in-window
1193    /// instants can never be reached (the caller refuses the restart first), so
1194    /// anything beyond that is an unbounded queue waiting to happen.
1195    fn record_crash_restart(&mut self, policy: &RestartPolicy, now: Instant) {
1196        self.crash_restarts.push_back(now);
1197        while self.crash_restarts.len() > policy.max_restarts as usize {
1198            self.crash_restarts.pop_front();
1199        }
1200        self.lifetime_restarts += 1;
1201    }
1202
1203    /// Reserve one crash-restart slot and calculate the delay before respawning.
1204    /// The count is captured before recording this restart, so the first retry
1205    /// uses the base delay and each later in-window retry escalates once.
1206    fn next_crash_restart(
1207        &mut self,
1208        policy: &RestartPolicy,
1209        now: Instant,
1210    ) -> Option<CrashRestartSchedule> {
1211        let restart_in_window = self.crash_restarts_in_window(policy.window, now);
1212        if restart_in_window >= policy.max_restarts {
1213            return None;
1214        }
1215        self.record_crash_restart(policy, now);
1216        Some(CrashRestartSchedule {
1217            restart_in_window,
1218            delay: policy.delay_for_restart(restart_in_window),
1219        })
1220    }
1221
1222    /// Give the module its full budget back, as an operator restart, reload, or
1223    /// re-enable does. `lifetime_restarts` deliberately does not move: it is the
1224    /// ledger of what actually happened, and an operator action does not unmake
1225    /// the crashes.
1226    fn clear_crash_restarts(&mut self) {
1227        self.crash_restarts.clear();
1228    }
1229
1230    fn new(state: ModuleState, enabled: bool) -> Self {
1231        Self {
1232            #[cfg(test)]
1233            actor_turns: 0,
1234            #[cfg(test)]
1235            actor_select: None,
1236            state,
1237            enabled,
1238            process_alive: false,
1239            #[cfg(windows)]
1240            pipe_consumed: None,
1241            spawned_protocol: None,
1242            spawn_failure: None,
1243            crash_restarts: VecDeque::new(),
1244            lifetime_restarts: 0,
1245            spawn_generation: 0,
1246            pid: None,
1247            #[cfg(target_os = "macos")]
1248            report_ready: None,
1249            reaped_pid: None,
1250            respawn_pending: false,
1251            coalesced_restart_pending: false,
1252            spawned_at_ms: None,
1253            spawned_from: None,
1254            spawned_file_identity: None,
1255            process_start_time: None,
1256            deliberate_severance: None,
1257            last_exit: None,
1258            drain_disposition_detail: None,
1259            health: ModuleHealthStatus::default(),
1260            in_alternate_slot: false,
1261            draining_to_replace: false,
1262            configuration_updated_since_spawn: false,
1263        }
1264    }
1265}
1266
1267type SharedSnapshot = Arc<Mutex<SupervisorSnapshot>>;
1268
1269type SpawnSubscriberKey = (ConnectionId, u64);
1270
1271#[derive(Debug)]
1272struct SpawnSubscriber {
1273    version: u8,
1274    frames: mpsc::Sender<Frame>,
1275    /// Tells this subscriber's forwarder that it was dropped for lagging, and
1276    /// from which event. The full frame channel cannot carry that news, so it
1277    /// travels beside it; see `SpawnEventFeed::subscribe`.
1278    lagged: Option<oneshot::Sender<SpawnCursor>>,
1279}
1280
1281#[derive(Debug)]
1282struct SpawnEventState {
1283    daemon_incarnation: String,
1284    seq: u64,
1285    capacity: usize,
1286    live: HashMap<String, LiveSpawn>,
1287    generations: HashMap<String, u64>,
1288    events: VecDeque<SpawnEvent>,
1289    subscribers: HashMap<SpawnSubscriberKey, SpawnSubscriber>,
1290}
1291
1292impl Default for SpawnEventState {
1293    fn default() -> Self {
1294        Self {
1295            daemon_incarnation: "unconfigured".to_string(),
1296            seq: 0,
1297            capacity: SPAWN_EVENT_RING_CAPACITY,
1298            live: HashMap::new(),
1299            generations: HashMap::new(),
1300            events: VecDeque::new(),
1301            subscribers: HashMap::new(),
1302        }
1303    }
1304}
1305
1306#[derive(Debug, Clone, Default)]
1307struct SpawnEventFeed(Arc<Mutex<SpawnEventState>>);
1308
1309#[derive(Debug, Clone, PartialEq, Eq)]
1310pub(crate) enum SpawnSubscribeRefusal {
1311    ForeignIncarnation { current: String },
1312    TooOld { oldest: SpawnCursor },
1313    Frame(String),
1314}
1315
1316impl SpawnEventFeed {
1317    fn configure_incarnation(&self, daemon_incarnation: String) {
1318        let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1319        state.daemon_incarnation = daemon_incarnation;
1320        state.seq = 0;
1321        state.live.clear();
1322        state.generations.clear();
1323        state.events.clear();
1324        state.subscribers.clear();
1325    }
1326
1327    fn cursor(state: &SpawnEventState) -> SpawnCursor {
1328        SpawnCursor {
1329            daemon_incarnation: state.daemon_incarnation.clone(),
1330            seq: state.seq,
1331        }
1332    }
1333
1334    fn snapshot(&self) -> SpawnSnapshot {
1335        let state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1336        let mut live = state.live.values().cloned().collect::<Vec<_>>();
1337        live.sort_by(|left, right| left.module_id.cmp(&right.module_id));
1338        SpawnSnapshot {
1339            cursor: Self::cursor(&state),
1340            ring_bound: state.capacity as u64,
1341            live,
1342        }
1343    }
1344
1345    fn emit_spawned(&self, module_id: &str, pid: u32, spawned_at_ms: u64) -> u64 {
1346        let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1347        let generation = state
1348            .generations
1349            .get(module_id)
1350            .copied()
1351            .unwrap_or(0)
1352            .checked_add(1)
1353            .expect("spawn generation exhausted");
1354        state.generations.insert(module_id.to_string(), generation);
1355        let live = LiveSpawn {
1356            module_id: module_id.to_string(),
1357            spawn_generation: generation,
1358            pid,
1359            spawned_at_ms,
1360        };
1361        state.live.insert(module_id.to_string(), live);
1362        Self::emit_locked(
1363            &mut state,
1364            SpawnEventKind::Spawned,
1365            module_id.to_string(),
1366            generation,
1367            pid,
1368            None,
1369            None,
1370        );
1371        generation
1372    }
1373
1374    fn emit_exited(&self, module_id: &str, exit_code: Option<i32>, exit_signal: Option<i32>) {
1375        let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1376        let Some(live) = state.live.remove(module_id) else {
1377            warn!(
1378                module_id,
1379                "terminal record had no live spawn event identity"
1380            );
1381            return;
1382        };
1383        Self::emit_locked(
1384            &mut state,
1385            SpawnEventKind::Exited,
1386            module_id.to_string(),
1387            live.spawn_generation,
1388            live.pid,
1389            exit_code,
1390            exit_signal,
1391        );
1392    }
1393
1394    /// Report the exit of a process that a swap has already replaced.
1395    ///
1396    /// `emit_exited` removes the module's live entry, which after a swap's
1397    /// cutover describes the promoted candidate, not the old process now
1398    /// exiting. This emits the old generation's exit and leaves the live entry
1399    /// alone unless it still names that generation.
1400    fn emit_superseded_exited(
1401        &self,
1402        module_id: &str,
1403        spawn_generation: u64,
1404        pid: u32,
1405        exit_code: Option<i32>,
1406        exit_signal: Option<i32>,
1407    ) {
1408        let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1409        if state
1410            .live
1411            .get(module_id)
1412            .is_some_and(|live| live.spawn_generation == spawn_generation)
1413        {
1414            state.live.remove(module_id);
1415        }
1416        Self::emit_locked(
1417            &mut state,
1418            SpawnEventKind::Exited,
1419            module_id.to_string(),
1420            spawn_generation,
1421            pid,
1422            exit_code,
1423            exit_signal,
1424        );
1425    }
1426
1427    #[allow(clippy::too_many_arguments)]
1428    fn emit_locked(
1429        state: &mut SpawnEventState,
1430        kind: SpawnEventKind,
1431        module_id: String,
1432        spawn_generation: u64,
1433        pid: u32,
1434        exit_code: Option<i32>,
1435        exit_signal: Option<i32>,
1436    ) {
1437        state.seq = state
1438            .seq
1439            .checked_add(1)
1440            .expect("spawn event sequence exhausted");
1441        let event = SpawnEvent {
1442            cursor: Self::cursor(state),
1443            kind,
1444            module_id,
1445            spawn_generation,
1446            pid,
1447            exit_code,
1448            exit_signal,
1449        };
1450        state.events.push_back(event.clone());
1451        while state.events.len() > state.capacity {
1452            state.events.pop_front();
1453        }
1454        let body = match serde_json::to_vec(&event) {
1455            Ok(body) => body,
1456            Err(error) => {
1457                error!(%error, "failed to serialize supervisor spawn event");
1458                return;
1459            }
1460        };
1461        state.subscribers.retain(|(connection_id, corr), subscriber| {
1462            let frame = Frame::build_with_version(
1463                subscriber.version,
1464                FrameType::StreamData,
1465                control_flags(),
1466                0,
1467                0,
1468                *corr,
1469                body.clone(),
1470            );
1471            match frame {
1472                Ok(frame) => {
1473                    if subscriber.frames.try_send(frame).is_ok() {
1474                        true
1475                    } else {
1476                        warn!(connection_id = connection_id.get(), corr, "dropping lagged supervisor spawn subscriber");
1477                        if let Some(lagged) = subscriber.lagged.take() {
1478                            let _ = lagged.send(event.cursor.clone());
1479                        }
1480                        false
1481                    }
1482                }
1483                Err(error) => {
1484                    warn!(connection_id = connection_id.get(), corr, %error, "dropping supervisor spawn subscriber after frame build failure");
1485                    false
1486                }
1487            }
1488        });
1489    }
1490
1491    fn subscribe(
1492        &self,
1493        connection_id: ConnectionId,
1494        corr: u64,
1495        version: u8,
1496        since: Option<SpawnCursor>,
1497        sink: FrameSink,
1498    ) -> Result<(), SpawnSubscribeRefusal> {
1499        let (frames, mut receiver) = mpsc::channel(SPAWN_SUBSCRIBER_BUFFER);
1500        let (lagged, mut lagged_rx) = oneshot::channel::<SpawnCursor>();
1501        {
1502            let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1503            let replay = if let Some(since) = since {
1504                if since.daemon_incarnation != state.daemon_incarnation {
1505                    return Err(SpawnSubscribeRefusal::ForeignIncarnation {
1506                        current: state.daemon_incarnation.clone(),
1507                    });
1508                }
1509                if let Some(oldest) = state.events.front().map(|event| event.cursor.clone()) {
1510                    if since.seq < oldest.seq.saturating_sub(1) {
1511                        return Err(SpawnSubscribeRefusal::TooOld { oldest });
1512                    }
1513                }
1514                state
1515                    .events
1516                    .iter()
1517                    .filter(|event| event.cursor.seq > since.seq)
1518                    .cloned()
1519                    .collect::<Vec<_>>()
1520            } else {
1521                Vec::new()
1522            };
1523            for event in replay {
1524                let body = serde_json::to_vec(&event)
1525                    .map_err(|error| SpawnSubscribeRefusal::Frame(error.to_string()))?;
1526                let frame = Frame::build_with_version(
1527                    version,
1528                    FrameType::StreamData,
1529                    control_flags(),
1530                    0,
1531                    0,
1532                    corr,
1533                    body,
1534                )
1535                .map_err(|error| SpawnSubscribeRefusal::Frame(error.to_string()))?;
1536                frames
1537                    .try_send(frame)
1538                    .map_err(|error| SpawnSubscribeRefusal::Frame(error.to_string()))?;
1539            }
1540            state.subscribers.insert(
1541                (connection_id, corr),
1542                SpawnSubscriber {
1543                    version,
1544                    frames,
1545                    lagged: Some(lagged),
1546                },
1547            );
1548        }
1549        // The lagged terminal is sent here, by the forwarder, rather than by
1550        // the emitter: at the moment of the drop the subscriber's own channel
1551        // is full, and writing to the connection sink directly from the emitter
1552        // would put the Error AHEAD of the events still queued in that channel
1553        // (and the emitter holds the feed lock, so it cannot await the sink).
1554        // Dropping the subscriber drops the only sender, so `recv` drains every
1555        // queued event and then returns `None`; only then is the Error sent, so
1556        // the client sees each event it can keep, then the reason it was cut.
1557        // Cancel and connection removal drop the oneshot unsent, so they end
1558        // the stream with no Error.
1559        tokio::spawn(async move {
1560            while let Some(frame) = receiver.recv().await {
1561                if sink.send(frame).await.is_err() {
1562                    return;
1563                }
1564            }
1565            let Ok(first_undelivered) = lagged_rx.try_recv() else {
1566                return;
1567            };
1568            match spawn_subscriber_lagged_frame(version, corr, first_undelivered) {
1569                Ok(frame) => {
1570                    let _ = sink.send(frame).await;
1571                }
1572                Err(error) => {
1573                    error!(%error, corr, "failed to build lagged spawn subscriber terminal frame");
1574                }
1575            }
1576        });
1577        Ok(())
1578    }
1579
1580    fn cancel(&self, connection_id: ConnectionId, corr: u64) -> bool {
1581        let Some(subscriber) = self
1582            .0
1583            .lock()
1584            .unwrap_or_else(|p| p.into_inner())
1585            .subscribers
1586            .remove(&(connection_id, corr))
1587        else {
1588            return false;
1589        };
1590        if let Ok(frame) = Frame::build_with_version(
1591            subscriber.version,
1592            FrameType::StreamEnd,
1593            control_flags(),
1594            0,
1595            0,
1596            corr,
1597            Vec::new(),
1598        ) {
1599            tokio::spawn(async move {
1600                let _ = subscriber.frames.send(frame).await;
1601            });
1602        }
1603        true
1604    }
1605
1606    fn remove_connection(&self, connection_id: ConnectionId) {
1607        self.0
1608            .lock()
1609            .unwrap_or_else(|p| p.into_inner())
1610            .subscribers
1611            .retain(|(subscriber_connection, _), _| *subscriber_connection != connection_id);
1612    }
1613
1614    #[cfg(any(test, feature = "test-support"))]
1615    fn set_capacity(&self, capacity: usize) {
1616        self.0.lock().unwrap_or_else(|p| p.into_inner()).capacity = capacity;
1617    }
1618
1619    #[cfg(any(test, feature = "test-support"))]
1620    fn subscriber_count(&self) -> usize {
1621        self.0
1622            .lock()
1623            .unwrap_or_else(|p| p.into_inner())
1624            .subscribers
1625            .len()
1626    }
1627}
1628
1629/// Narrow process-liveness signal published by supervisors and consumed by passive liveness polls.
1630/// The terminal Error a lagged spawn subscriber receives after its queued events.
1631fn spawn_subscriber_lagged_frame(
1632    version: u8,
1633    corr: u64,
1634    first_undelivered: SpawnCursor,
1635) -> Result<Frame, String> {
1636    let body = serde_json::to_vec(&subc_protocol::ErrorBody {
1637        code: SPAWN_SUBSCRIBER_LAGGED_CODE.to_string(),
1638        message: "spawn subscriber fell behind and was dropped; resubscribe from the last cursor received"
1639            .to_string(),
1640        detail: Some(serde_json::json!({
1641            "first_undelivered_cursor": first_undelivered
1642        })),
1643    })
1644    .map_err(|error| error.to_string())?;
1645    Frame::build_with_version(version, FrameType::Error, control_flags(), 0, 0, corr, body)
1646        .map_err(|error| error.to_string())
1647}
1648
1649pub trait ModuleProcessLiveness: Send + Sync {
1650    fn process_live(&self, module_id: &str) -> Option<bool>;
1651
1652    /// Whether the supervisor is replacing this module's process right now: an
1653    /// operator restart or reload, a health restart, or a crash respawn whose
1654    /// backoff is running. A module in that state is not live, but a consumer
1655    /// refused now should retry shortly rather than treat the target as gone.
1656    /// Stopped, failed, and disabled modules are not replacing.
1657    fn process_replacing(&self, _module_id: &str) -> bool {
1658        false
1659    }
1660}
1661
1662/// Shared process-liveness registry keyed by supervised `module_id`.
1663#[derive(Debug, Clone, Default)]
1664pub struct SupervisorProcessLiveness {
1665    snapshots: Arc<Mutex<HashMap<String, SharedSnapshot>>>,
1666}
1667
1668impl SupervisorProcessLiveness {
1669    pub fn new() -> Self {
1670        Self::default()
1671    }
1672
1673    fn track(&self, module_id: String, snapshot: SharedSnapshot) {
1674        let mut snapshots = self
1675            .snapshots
1676            .lock()
1677            .unwrap_or_else(|poisoned| poisoned.into_inner());
1678        snapshots.insert(module_id, snapshot);
1679    }
1680
1681    fn untrack_if_current(&self, module_id: &str, snapshot: &SharedSnapshot) {
1682        let mut snapshots = self
1683            .snapshots
1684            .lock()
1685            .unwrap_or_else(|poisoned| poisoned.into_inner());
1686        let is_current = snapshots
1687            .get(module_id)
1688            .map(|tracked| Arc::ptr_eq(tracked, snapshot))
1689            .unwrap_or(false);
1690        if is_current {
1691            snapshots.remove(module_id);
1692        }
1693    }
1694}
1695
1696impl ModuleProcessLiveness for SupervisorProcessLiveness {
1697    fn process_live(&self, module_id: &str) -> Option<bool> {
1698        let snapshot = {
1699            let snapshots = self
1700                .snapshots
1701                .lock()
1702                .unwrap_or_else(|poisoned| poisoned.into_inner());
1703            snapshots.get(module_id).cloned()
1704        }?;
1705        let snapshot = snapshot
1706            .lock()
1707            .unwrap_or_else(|poisoned| poisoned.into_inner());
1708        Some(snapshot.state == ModuleState::Running && snapshot.process_alive)
1709    }
1710
1711    fn process_replacing(&self, module_id: &str) -> bool {
1712        let Some(snapshot) = self
1713            .snapshots
1714            .lock()
1715            .unwrap_or_else(|poisoned| poisoned.into_inner())
1716            .get(module_id)
1717            .cloned()
1718        else {
1719            return false;
1720        };
1721        let snapshot = snapshot
1722            .lock()
1723            .unwrap_or_else(|poisoned| poisoned.into_inner());
1724        snapshot.enabled
1725            && match snapshot.state {
1726                ModuleState::Restarting => true,
1727                ModuleState::Draining => snapshot.draining_to_replace,
1728                ModuleState::Starting
1729                | ModuleState::Running
1730                | ModuleState::Unresponsive
1731                | ModuleState::Stopped
1732                | ModuleState::Failed
1733                | ModuleState::Disabled => false,
1734            }
1735    }
1736}
1737
1738#[cfg(test)]
1739#[derive(Debug, Default)]
1740struct ReloadExitRecordGate {
1741    reached: tokio::sync::Notify,
1742    resume: tokio::sync::Notify,
1743}
1744
1745#[derive(Debug, Clone, Copy)]
1746enum RespawnKind {
1747    Spawn,
1748    Reload,
1749}
1750
1751#[derive(Debug, Clone, Copy)]
1752struct PendingRespawn {
1753    deadline: Instant,
1754    kind: RespawnKind,
1755}
1756
1757type ReloadReply = oneshot::Sender<Result<(), SuperviseError>>;
1758
1759#[derive(Debug, Clone)]
1760struct SupervisorRuntimeConfig {
1761    /// Restart operations hand their backoff to the loop, which keeps serving commands.
1762    scheduled_respawn: Arc<Mutex<Option<PendingRespawn>>>,
1763    /// A reload acknowledges completion only after its replacement registers.
1764    deferred_reload_reply: Arc<Mutex<Option<ReloadReply>>>,
1765    restart_policy: RestartPolicy,
1766    /// This module's RESOLVED drain budget: per-module config when present,
1767    /// else `default_drain_timeout`.
1768    drain_timeout: Duration,
1769    /// Shared with the status handle so the attested value changes atomically
1770    /// when a rescan updates the running drain policy.
1771    effective_drain_timeout: Arc<Mutex<Duration>>,
1772    /// The supervisor-wide fallback, kept so a configuration update that
1773    /// REMOVES the per-module override can re-resolve to it.
1774    default_drain_timeout: Duration,
1775    health: HealthConfig,
1776    connection_file_path: Option<PathBuf>,
1777    capture_logs_dir: Option<PathBuf>,
1778    forwarding: Option<Arc<ForwardingTable>>,
1779    /// The shared handle, so every spawn path (initial, restart, reload) records the
1780    /// reserved-module launch nonce the HELLO verifier checks against.
1781    supervisor_handle: Option<SupervisorHandle>,
1782    /// This module's stderr tail, shared with the [`SupervisedModule`] that answers
1783    /// status queries.
1784    ///
1785    /// One ring per module, held across every respawn. The lines explaining an exit
1786    /// are written BEFORE that exit, so a ring recreated per process would be empty
1787    /// exactly when it is asked for.
1788    stderr_ring: Arc<Mutex<StderrRing>>,
1789    terminal_ring: Arc<Mutex<TerminalRing>>,
1790    spawn_events: SpawnEventFeed,
1791    child_roster: ChildRoster,
1792    #[cfg(target_os = "linux")]
1793    cgroup_placement: Option<subc_cgroup::Placement>,
1794    #[cfg(test)]
1795    test_seed_stale_facts_before_enable_spawn: bool,
1796    #[cfg(test)]
1797    test_reload_exit_record_gate: Option<Arc<ReloadExitRecordGate>>,
1798}
1799
1800#[derive(Debug, Clone, PartialEq, Eq)]
1801struct SupervisedConfiguration {
1802    spec: ModuleSpec,
1803    health: HealthConfig,
1804}
1805
1806/// Shared daemon lookup table for supervised module handles.
1807///
1808/// Shared by clone between the [`Supervisor`] (which spawns processes) and the
1809/// channel-0 control handler (which verifies HELLOs and consumer route opens), so
1810/// launch nonces recorded at spawn are checked by the same daemon instance.
1811#[derive(Debug, Clone, Default)]
1812pub struct SupervisorHandle {
1813    modules: Arc<Mutex<HashMap<String, SupervisedModule>>>,
1814    /// Module ids the supervisor has taken on. An id is added BEFORE the
1815    /// module's first process is spawned and removed only when the module
1816    /// leaves the roster (see [`Self::retire`]), so it is always a superset of
1817    /// the keys of `modules`.
1818    ///
1819    /// `modules` cannot answer "is this module configured?" on its own: a
1820    /// [`SupervisedModule`] only exists once its process has been spawned, and
1821    /// a fast child can connect, register, sync its scopes and ask about them
1822    /// before the supervisor has inserted it. Answering "not configured" in that
1823    /// gap makes scope admission refuse with the terminal "will never sync"
1824    /// instead of the retryable "has not synced yet".
1825    configured_ids: Arc<Mutex<HashSet<String>>>,
1826    spawn_events: SpawnEventFeed,
1827    /// The current expected launch nonce for each reserved module_id. Set when the
1828    /// supervisor spawns the reserved module; checked when a HELLO claims that id. A
1829    /// non-reserved module never has an entry here and is never nonce-checked.
1830    /// Reserved module ids and the nonce that authorizes their next HELLO.
1831    /// `None` means RESERVED WITH NO LEGITIMATE HOLDER — a reserved module that
1832    /// has never been spawned (e.g. configured `enabled: false`) — and refuses
1833    /// every HELLO. Before this was expressible, a reserved-but-never-spawned id
1834    /// had NO entry and admitted anyone: the reservation protected the nonce
1835    /// holder, not the NAME (found live by CKCRED's canary probe registering
1836    /// against a reserved scratch id).
1837    reserved_nonces: Arc<Mutex<HashMap<String, Option<String>>>>,
1838    /// Module ids removed by an executed rescan and the unix-millisecond removal time.
1839    ///
1840    /// This is deliberately in-memory only: subc is state-free across daemon
1841    /// restarts, and the tombstone only explains the hours-after-removal window
1842    /// while this executing daemon is still alive. Do not persist it in a store.
1843    removal_tombstones: Arc<Mutex<HashMap<String, u64>>>,
1844    /// The current launch nonce for every supervised spawn. This is separate from
1845    /// reserved_nonces because consumer route.open attestation applies to all spawned
1846    /// modules, while HELLO id-squatting protection remains opt-in via `reserved`.
1847    spawn_nonces: Arc<Mutex<HashMap<String, String>>>,
1848    /// Reserved namespace prefixes mapped to the supervised owner module whose
1849    /// current spawn nonce authorizes HELLO claims below the prefix.
1850    ///
1851    /// Per §2.6 this is not a same-user security barrier: a same-user process can
1852    /// read the key file and launch nonce env. Like exact reserved ids, it prevents
1853    /// accidental collisions and lower-trust processes from squatting protected
1854    /// namespaces.
1855    reserved_prefix_owners: Arc<Mutex<HashMap<String, String>>>,
1856    /// Blue/green swaps in progress, by module id. An entry exists from just
1857    /// before the candidate process is spawned until the swap has failed, or
1858    /// has cut over and the old process is gone. While it exists, HELLO for the
1859    /// id is gated on the swap token (see [`Self::swap_hello_admission`]) and
1860    /// consumer attestation accepts both processes' nonces.
1861    swaps: Arc<Mutex<HashMap<String, OpenSwap>>>,
1862    /// Told when a swap promotes its candidate; see [`SwapPromotionObserver`].
1863    promotion_observer: PromotionObserverSlot,
1864    /// Serializes module-set reconciliation with operator lifecycle commands. Without
1865    /// this daemon-wide ordering, a rescan could retire or update a module while a
1866    /// concurrent reload still held its old handle and launch specification.
1867    operation_lock: Arc<AsyncMutex<()>>,
1868}
1869
1870/// Told when a swap has promoted its candidate to be the module's active
1871/// registration.
1872///
1873/// An ordinary HELLO runs the control plane's registration side effects (the
1874/// capability cache, the deny census, the requirement recompute) as it
1875/// registers. A swap candidate's HELLO does not, because it is not routable;
1876/// promotion is when those must run instead, and promotion happens in the
1877/// supervisor, which has no other way into the control handler.
1878pub(crate) trait SwapPromotionObserver: Send + Sync {
1879    fn swap_promoted(&self, registration: &crate::registry::ModuleRegistration);
1880}
1881
1882/// The installed [`SwapPromotionObserver`], held weakly: the observer (the
1883/// control handler) owns this handle, so a strong reference back would be a
1884/// cycle that keeps both alive.
1885#[derive(Clone, Default)]
1886struct PromotionObserverSlot(Arc<Mutex<Option<std::sync::Weak<dyn SwapPromotionObserver>>>>);
1887
1888impl fmt::Debug for PromotionObserverSlot {
1889    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1890        f.write_str("PromotionObserverSlot")
1891    }
1892}
1893
1894/// The nonces of one open swap.
1895#[derive(Debug, Clone)]
1896struct OpenSwap {
1897    /// The launch nonce minted for the candidate process. It is the swap
1898    /// token: the only thing that admits a HELLO into the candidate slot.
1899    candidate_nonce: String,
1900    /// The incumbent's launch nonce, captured when the swap opened. It is kept
1901    /// here because cutover moves the module's recorded spawn nonce to the
1902    /// candidate while the incumbent is still draining and its consumers are
1903    /// still attesting with this one.
1904    incumbent_nonce: Option<String>,
1905    /// Set once a HELLO has been admitted with the swap token, so the token
1906    /// admits one registration and cannot be replayed after cutover empties
1907    /// the candidate slot.
1908    candidate_admitted: bool,
1909}
1910
1911/// What the swap gate says about a HELLO. See
1912/// [`SupervisorHandle::swap_hello_admission`].
1913#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1914pub(crate) enum SwapHelloAdmission {
1915    /// No swap is open for the id (or the HELLO carries the incumbent's own
1916    /// nonce); the ordinary gates decide.
1917    NotSwapping,
1918    /// The HELLO carries the swap token: register it into the candidate slot.
1919    Candidate,
1920    /// A swap is open and the HELLO carries a nonce the supervisor did not
1921    /// mint for this id, no nonce, or a token already used.
1922    Refused,
1923}
1924
1925#[derive(Debug, Clone, PartialEq, Eq)]
1926pub(crate) enum ReservedHelloRejection {
1927    Exact {
1928        module_id: String,
1929    },
1930    Prefix {
1931        prefix: String,
1932        owner_module_id: String,
1933    },
1934}
1935
1936impl SupervisorHandle {
1937    pub fn new() -> Self {
1938        Self::default()
1939    }
1940
1941    pub(crate) fn spawn_snapshot(&self) -> SpawnSnapshot {
1942        self.spawn_events.snapshot()
1943    }
1944
1945    pub(crate) fn subscribe_spawns(
1946        &self,
1947        connection_id: ConnectionId,
1948        corr: u64,
1949        version: u8,
1950        since: Option<SpawnCursor>,
1951        sink: FrameSink,
1952    ) -> Result<(), SpawnSubscribeRefusal> {
1953        self.spawn_events
1954            .subscribe(connection_id, corr, version, since, sink)
1955    }
1956
1957    pub(crate) fn cancel_spawn_subscription(&self, connection_id: ConnectionId, corr: u64) -> bool {
1958        self.spawn_events.cancel(connection_id, corr)
1959    }
1960
1961    pub(crate) fn remove_spawn_subscribers(&self, connection_id: ConnectionId) {
1962        self.spawn_events.remove_connection(connection_id);
1963    }
1964
1965    #[cfg(any(test, feature = "test-support"))]
1966    pub fn set_spawn_event_capacity_for_test(&self, capacity: usize) {
1967        assert!(capacity > 0, "spawn event capacity must be non-zero");
1968        self.spawn_events.set_capacity(capacity);
1969    }
1970
1971    #[cfg(any(test, feature = "test-support"))]
1972    pub fn spawn_subscriber_count_for_test(&self) -> usize {
1973        self.spawn_events.subscriber_count()
1974    }
1975
1976    /// Record the launch nonce from a supervised spawn, replacing any prior nonce so
1977    /// a respawn invalidates stale consumer identities.
1978    pub fn set_spawn_nonce(&self, module_id: &str, nonce: String) {
1979        self.spawn_nonces
1980            .lock()
1981            .unwrap_or_else(|poisoned| poisoned.into_inner())
1982            .insert(module_id.to_string(), nonce);
1983    }
1984
1985    /// Record the launch nonce expected from the next HELLO for a reserved module,
1986    /// replacing any prior nonce (a respawn invalidates the previous one).
1987    pub fn set_reserved_nonce(&self, module_id: &str, nonce: String) {
1988        self.reserved_nonces
1989            .lock()
1990            .unwrap_or_else(|poisoned| poisoned.into_inner())
1991            .insert(module_id.to_string(), Some(nonce));
1992    }
1993
1994    /// Record namespace prefixes owned by a supervised module.
1995    pub fn set_reserved_prefixes(&self, owner_module_id: &str, prefixes: &[String]) {
1996        let mut owners = self
1997            .reserved_prefix_owners
1998            .lock()
1999            .unwrap_or_else(|poisoned| poisoned.into_inner());
2000        owners.retain(|_, owner| owner != owner_module_id);
2001        for prefix in prefixes {
2002            owners.insert(prefix.clone(), owner_module_id.to_string());
2003        }
2004    }
2005
2006    /// The launch nonce most recently minted for a module's spawn, if any.
2007    #[cfg(test)]
2008    pub(crate) fn spawn_nonce(&self, module_id: &str) -> Option<String> {
2009        self.spawn_nonces
2010            .lock()
2011            .unwrap_or_else(|poisoned| poisoned.into_inner())
2012            .get(module_id)
2013            .cloned()
2014    }
2015
2016    fn apply_identity_configuration(&self, spec: &ModuleSpec) {
2017        self.set_reserved_prefixes(&spec.module_id, &spec.reserved_prefixes);
2018        let spawn_nonce = self
2019            .spawn_nonces
2020            .lock()
2021            .unwrap_or_else(|poisoned| poisoned.into_inner())
2022            .get(&spec.module_id)
2023            .cloned();
2024        let mut reserved_nonces = self
2025            .reserved_nonces
2026            .lock()
2027            .unwrap_or_else(|poisoned| poisoned.into_inner());
2028        if spec.reserved {
2029            // `None` (no spawn nonce minted) is INSERTED, not skipped: a
2030            // reserved name whose module has never spawned has no legitimate
2031            // holder, and the entry's absence is what used to leave the name
2032            // open to the first claimant.
2033            reserved_nonces.insert(spec.module_id.clone(), spawn_nonce);
2034        }
2035        drop(reserved_nonces);
2036        // A later unreserved declaration must not silently unreserve an id that
2037        // was retained after its reserved configuration was removed. The explicit
2038        // release ceremony is the only operation that retires that gate.
2039        self.removal_tombstones
2040            .lock()
2041            .unwrap_or_else(|poisoned| poisoned.into_inner())
2042            .remove(&spec.module_id);
2043    }
2044
2045    /// Whether a HELLO claiming `module_id` is authorized. An exact reserved id is
2046    /// authorized only by its expected nonce; otherwise a matching reserved prefix
2047    /// is authorized by the owner module's current spawn nonce. Non-reserved ids
2048    /// with no matching prefix are always authorized.
2049    pub fn reserved_hello_authorized(&self, module_id: &str, presented: Option<&str>) -> bool {
2050        self.reserved_hello_rejection(module_id, presented)
2051            .is_none()
2052    }
2053
2054    pub(crate) fn reserved_hello_rejection(
2055        &self,
2056        module_id: &str,
2057        presented: Option<&str>,
2058    ) -> Option<ReservedHelloRejection> {
2059        let nonces = self
2060            .reserved_nonces
2061            .lock()
2062            .unwrap_or_else(|poisoned| poisoned.into_inner());
2063        if let Some(expected) = nonces.get(module_id) {
2064            // `None` = reserved with no legitimate holder: refuse every
2065            // presentation, because no process can hold a nonce that was never
2066            // minted. Only a real minted nonce admits, in constant time.
2067            let authorized = match expected {
2068                Some(expected) => {
2069                    presented.is_some_and(|p| constant_time_eq(expected.as_bytes(), p.as_bytes()))
2070                }
2071                None => false,
2072            };
2073            if authorized {
2074                return None;
2075            }
2076            return Some(ReservedHelloRejection::Exact {
2077                module_id: module_id.to_string(),
2078            });
2079        }
2080        drop(nonces);
2081
2082        let matched_prefix = self
2083            .reserved_prefix_owners
2084            .lock()
2085            .unwrap_or_else(|poisoned| poisoned.into_inner())
2086            .iter()
2087            .filter(|(prefix, _)| module_id.starts_with(prefix.as_str()))
2088            .max_by_key(|(prefix, _)| prefix.len())
2089            .map(|(prefix, owner)| (prefix.clone(), owner.clone()));
2090        let (prefix, owner_module_id) = matched_prefix?;
2091
2092        let authorized = presented.is_some_and(|presented| {
2093            self.spawn_nonces
2094                .lock()
2095                .unwrap_or_else(|poisoned| poisoned.into_inner())
2096                .get(&owner_module_id)
2097                .is_some_and(|expected| constant_time_eq(expected.as_bytes(), presented.as_bytes()))
2098                // While the owner is being swapped, children started by
2099                // either of its two processes hold that process's nonce.
2100                || self.swap_nonce_matches(&owner_module_id, presented)
2101        });
2102        if authorized {
2103            None
2104        } else {
2105            Some(ReservedHelloRejection::Prefix {
2106                prefix,
2107                owner_module_id,
2108            })
2109        }
2110    }
2111
2112    /// Whether a consumer connection proved it came from a daemon-spawned module.
2113    ///
2114    /// Absence of an expected spawn nonce is a hard failure: consumer_identity is
2115    /// accepted only for module ids the supervisor has spawned.
2116    pub fn spawned_consumer_authorized(&self, module_id: &str, presented: &str) -> bool {
2117        if presented.is_empty() {
2118            return false;
2119        }
2120        let nonces = self
2121            .spawn_nonces
2122            .lock()
2123            .unwrap_or_else(|poisoned| poisoned.into_inner());
2124        let current = nonces
2125            .get(module_id)
2126            .is_some_and(|expected| constant_time_eq(expected.as_bytes(), presented.as_bytes()));
2127        drop(nonces);
2128        // During a swap two processes of the module are alive, and a consumer
2129        // started by either one presents that process's nonce. Accepting only
2130        // the recorded one would fail the incumbent's consumers for the whole
2131        // overlap once cutover moves the record to the candidate.
2132        current || self.swap_nonce_matches(module_id, presented)
2133    }
2134
2135    /// Whether `presented` is either nonce of an open swap for `module_id`.
2136    fn swap_nonce_matches(&self, module_id: &str, presented: &str) -> bool {
2137        let swaps = self
2138            .swaps
2139            .lock()
2140            .unwrap_or_else(|poisoned| poisoned.into_inner());
2141        swaps.get(module_id).is_some_and(|swap| {
2142            constant_time_eq(swap.candidate_nonce.as_bytes(), presented.as_bytes())
2143                || swap.incumbent_nonce.as_deref().is_some_and(|incumbent| {
2144                    constant_time_eq(incumbent.as_bytes(), presented.as_bytes())
2145                })
2146        })
2147    }
2148
2149    /// Open a swap for `module_id` with the candidate's freshly minted nonce.
2150    /// Called before the candidate process exists.
2151    pub(crate) fn open_swap(&self, module_id: &str, candidate_nonce: String) {
2152        let incumbent_nonce = self
2153            .spawn_nonces
2154            .lock()
2155            .unwrap_or_else(|poisoned| poisoned.into_inner())
2156            .get(module_id)
2157            .cloned();
2158        self.swaps
2159            .lock()
2160            .unwrap_or_else(|poisoned| poisoned.into_inner())
2161            .insert(
2162                module_id.to_string(),
2163                OpenSwap {
2164                    candidate_nonce,
2165                    incumbent_nonce,
2166                    candidate_admitted: false,
2167                },
2168            );
2169    }
2170
2171    /// Close the swap for `module_id`, releasing whichever nonce is no longer
2172    /// the module's recorded one.
2173    pub(crate) fn close_swap(&self, module_id: &str) {
2174        self.swaps
2175            .lock()
2176            .unwrap_or_else(|poisoned| poisoned.into_inner())
2177            .remove(module_id);
2178    }
2179
2180    /// Install the observer told about swap promotions, replacing any earlier
2181    /// one.
2182    pub(crate) fn set_swap_promotion_observer(
2183        &self,
2184        observer: std::sync::Weak<dyn SwapPromotionObserver>,
2185    ) {
2186        *self
2187            .promotion_observer
2188            .0
2189            .lock()
2190            .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(observer);
2191    }
2192
2193    /// Tell the installed observer, if it is still alive, that a swap promoted
2194    /// `registration`.
2195    fn notify_swap_promoted(&self, registration: &crate::registry::ModuleRegistration) {
2196        let observer = self
2197            .promotion_observer
2198            .0
2199            .lock()
2200            .unwrap_or_else(|poisoned| poisoned.into_inner())
2201            .as_ref()
2202            .and_then(std::sync::Weak::upgrade);
2203        if let Some(observer) = observer {
2204            observer.swap_promoted(registration);
2205        }
2206    }
2207
2208    /// Whether a swap is open for `module_id`.
2209    pub(crate) fn swap_open(&self, module_id: &str) -> bool {
2210        self.swaps
2211            .lock()
2212            .unwrap_or_else(|poisoned| poisoned.into_inner())
2213            .contains_key(module_id)
2214    }
2215
2216    /// Make the candidate's nonce the module's recorded spawn nonce, as a plain
2217    /// respawn would, once cutover has made the candidate the module's process.
2218    /// The swap stays open so the incumbent's nonce keeps attesting until the
2219    /// incumbent has drained and exited.
2220    fn promote_swap_nonce(&self, module_id: &str, reserved: bool) {
2221        let candidate_nonce = self
2222            .swaps
2223            .lock()
2224            .unwrap_or_else(|poisoned| poisoned.into_inner())
2225            .get(module_id)
2226            .map(|swap| swap.candidate_nonce.clone());
2227        let Some(nonce) = candidate_nonce else {
2228            return;
2229        };
2230        self.set_spawn_nonce(module_id, nonce.clone());
2231        if reserved {
2232            self.set_reserved_nonce(module_id, nonce);
2233        }
2234    }
2235
2236    /// The swap gate for a HELLO claiming `module_id`.
2237    ///
2238    /// This runs BEFORE the reserved-module gate. A reserved module's candidate
2239    /// presents the candidate nonce, which the reserved gate (holding the
2240    /// incumbent's nonce) would refuse as `reserved_module` before swap
2241    /// admission was ever reached. And it applies to unreserved ids too: for an
2242    /// unreserved id the only thing that ever stopped a second process claiming
2243    /// a live id was the `duplicate_module_id` refusal, which is exactly the
2244    /// refusal a swap lifts for its candidate.
2245    ///
2246    /// The incumbent's own nonce falls through to the ordinary gates, which
2247    /// treat it as they always have (a live incumbent is refused as a
2248    /// duplicate). Anything else while a swap is open is refused, including an
2249    /// absent nonce.
2250    pub(crate) fn swap_hello_admission(
2251        &self,
2252        module_id: &str,
2253        presented: Option<&str>,
2254    ) -> SwapHelloAdmission {
2255        let swaps = self
2256            .swaps
2257            .lock()
2258            .unwrap_or_else(|poisoned| poisoned.into_inner());
2259        let Some(swap) = swaps.get(module_id) else {
2260            return SwapHelloAdmission::NotSwapping;
2261        };
2262        let Some(presented) = presented else {
2263            return SwapHelloAdmission::Refused;
2264        };
2265        if constant_time_eq(swap.candidate_nonce.as_bytes(), presented.as_bytes()) {
2266            return if swap.candidate_admitted {
2267                SwapHelloAdmission::Refused
2268            } else {
2269                SwapHelloAdmission::Candidate
2270            };
2271        }
2272        if swap
2273            .incumbent_nonce
2274            .as_deref()
2275            .is_some_and(|incumbent| constant_time_eq(incumbent.as_bytes(), presented.as_bytes()))
2276        {
2277            return SwapHelloAdmission::NotSwapping;
2278        }
2279        SwapHelloAdmission::Refused
2280    }
2281
2282    /// Record that the swap token has registered a candidate, so it admits no
2283    /// second HELLO.
2284    pub(crate) fn mark_swap_candidate_admitted(&self, module_id: &str) {
2285        if let Some(swap) = self
2286            .swaps
2287            .lock()
2288            .unwrap_or_else(|poisoned| poisoned.into_inner())
2289            .get_mut(module_id)
2290        {
2291            swap.candidate_admitted = true;
2292        }
2293    }
2294
2295    /// Test/support lookup for the current launch nonce of a supervised spawn.
2296    pub fn spawn_launch_nonce_for(&self, module_id: &str) -> Option<String> {
2297        self.spawn_nonces
2298            .lock()
2299            .unwrap_or_else(|poisoned| poisoned.into_inner())
2300            .get(module_id)
2301            .cloned()
2302    }
2303
2304    /// Test/support lookup for the HELLO-gating nonce of a reserved module.
2305    pub fn reserved_launch_nonce_for(&self, module_id: &str) -> Option<String> {
2306        self.reserved_nonces
2307            .lock()
2308            .unwrap_or_else(|poisoned| poisoned.into_inner())
2309            .get(module_id)
2310            .cloned()
2311            .flatten()
2312    }
2313
2314    pub fn insert(&self, module: SupervisedModule) -> Option<SupervisedModule> {
2315        // Normally already marked before the process was spawned; marking here
2316        // too keeps `configured_ids` a superset of the roster for any caller
2317        // that inserts a module directly.
2318        self.mark_configured(module.module_id());
2319        let mut modules = self
2320            .modules
2321            .lock()
2322            .unwrap_or_else(|poisoned| poisoned.into_inner());
2323        modules.insert(module.module_id().to_string(), module)
2324    }
2325
2326    /// Record that the supervisor has taken on `module_id`. Called before the
2327    /// module's first process is spawned, so that by the time that process can
2328    /// register, [`Self::is_configured`] already answers true.
2329    fn mark_configured(&self, module_id: &str) {
2330        self.configured_ids
2331            .lock()
2332            .unwrap_or_else(|poisoned| poisoned.into_inner())
2333            .insert(module_id.to_string());
2334    }
2335
2336    /// Undo [`Self::mark_configured`] for a module whose first spawn failed
2337    /// before it was ever put on the roster. A module already on the roster
2338    /// keeps its mark: only [`Self::retire`] takes a rostered module off.
2339    fn unmark_configured_unless_rostered(&self, module_id: &str) {
2340        let modules = self
2341            .modules
2342            .lock()
2343            .unwrap_or_else(|poisoned| poisoned.into_inner());
2344        if !modules.contains_key(module_id) {
2345            self.configured_ids
2346                .lock()
2347                .unwrap_or_else(|poisoned| poisoned.into_inner())
2348                .remove(module_id);
2349        }
2350    }
2351
2352    /// Whether `module_id` is a module this daemon supervises: on the roster,
2353    /// or about to be (its process is being spawned right now).
2354    ///
2355    /// This, not `get(..).is_some()`, is what "the owner is configured" means
2356    /// for scopes: a supervised module's process can register and sync before
2357    /// [`Self::get`] can return it, and in that window it is still a module
2358    /// that will sync, not one that never will.
2359    pub(crate) fn is_configured(&self, module_id: &str) -> bool {
2360        self.configured_ids
2361            .lock()
2362            .unwrap_or_else(|poisoned| poisoned.into_inner())
2363            .contains(module_id)
2364    }
2365
2366    pub fn get(&self, module_id: &str) -> Option<SupervisedModule> {
2367        let modules = self
2368            .modules
2369            .lock()
2370            .unwrap_or_else(|poisoned| poisoned.into_inner());
2371        modules.get(module_id).cloned()
2372    }
2373
2374    pub(crate) fn record_late_health_answer(
2375        &self,
2376        module_id: &str,
2377        latency_ms: u64,
2378    ) -> Result<bool, SuperviseError> {
2379        let Some(module) = self.get(module_id) else {
2380            return Ok(false);
2381        };
2382        update_snapshot(&module.inner.snapshot, Some(module_id), |state| {
2383            state.health.late_answer_count = state.health.late_answer_count.saturating_add(1);
2384            state.health.last_late_answer_latency_ms = Some(latency_ms);
2385            // A late answer is an answer: the module served the probe, just past
2386            // the deadline. Leaving the miss streak in place while logging
2387            // "proves the module is alive" is how a CPU-starved module that
2388            // answers every probe a few seconds late still marches to the
2389            // threshold and gets killed — the exact kill class `NoAnswer` is
2390            // excluded from `is_proof_of_death` to prevent. Slow-but-answering
2391            // is degradation, and degradation reports; it does not restart.
2392            state.health.consecutive_failures = 0;
2393        })?;
2394        Ok(true)
2395    }
2396
2397    /// Arm the one-shot marker for the module process that this caller
2398    /// deliberately initiated severance against. Generic connection teardown
2399    /// must not call this:
2400    /// a surviving process would otherwise retain an exemption for a later
2401    /// genuine crash.
2402    pub fn record_deliberate_severance(&self, module_id: &str) -> Result<bool, SuperviseError> {
2403        let Some(module) = self.get(module_id) else {
2404            return Ok(false);
2405        };
2406        let snapshot = lock_snapshot(&module.inner.snapshot)?;
2407        let Some((pid, start_time)) = snapshot.pid.zip(snapshot.process_start_time) else {
2408            return Ok(false);
2409        };
2410        drop(snapshot);
2411        module.record_deliberate_severance(ProcessIdentity { pid, start_time })
2412    }
2413
2414    pub fn list(&self) -> Vec<SupervisedModule> {
2415        let modules = self
2416            .modules
2417            .lock()
2418            .unwrap_or_else(|poisoned| poisoned.into_inner());
2419        let mut modules = modules.values().cloned().collect::<Vec<_>>();
2420        modules.sort_by(|left, right| left.module_id().cmp(right.module_id()));
2421        modules
2422    }
2423
2424    pub(crate) fn retire(&self, module_id: &str) -> Option<SupervisedModule> {
2425        self.spawn_nonces
2426            .lock()
2427            .unwrap_or_else(|poisoned| poisoned.into_inner())
2428            .remove(module_id);
2429        self.close_swap(module_id);
2430        let mut reserved_nonces = self
2431            .reserved_nonces
2432            .lock()
2433            .unwrap_or_else(|poisoned| poisoned.into_inner());
2434        if reserved_nonces.contains_key(module_id) {
2435            // The old nonce must die with the removed process, but the exact-id
2436            // gate remains until an operator explicitly releases it.
2437            reserved_nonces.insert(module_id.to_string(), None);
2438        }
2439        drop(reserved_nonces);
2440        self.reserved_prefix_owners
2441            .lock()
2442            .unwrap_or_else(|poisoned| poisoned.into_inner())
2443            .retain(|_, owner| owner != module_id);
2444        let removed = self
2445            .modules
2446            .lock()
2447            .unwrap_or_else(|poisoned| poisoned.into_inner())
2448            .remove(module_id);
2449        self.configured_ids
2450            .lock()
2451            .unwrap_or_else(|poisoned| poisoned.into_inner())
2452            .remove(module_id);
2453        removed
2454    }
2455
2456    /// Remember a module removed by a non-preview rescan so route.open can
2457    /// distinguish that intentional removal from an unknown id.
2458    pub(crate) fn record_rescan_removal(&self, module_id: &str) {
2459        self.removal_tombstones
2460            .lock()
2461            .unwrap_or_else(|poisoned| poisoned.into_inner())
2462            .insert(module_id.to_string(), unix_ms_now());
2463    }
2464
2465    /// Return how long ago a rescan removed this module in milliseconds.
2466    pub(crate) fn removal_tombstone_age_ms(&self, module_id: &str) -> Option<u64> {
2467        self.removal_tombstones
2468            .lock()
2469            .unwrap_or_else(|poisoned| poisoned.into_inner())
2470            .get(module_id)
2471            .copied()
2472            .map(|removed_at_ms| unix_ms_now().saturating_sub(removed_at_ms))
2473    }
2474
2475    /// Retire a reserved-id gate only after its module has left supervision.
2476    ///
2477    /// A retained gate has no live nonce (`None`), so releasing any other entry
2478    /// would weaken a currently configured or otherwise active reservation.
2479    pub(crate) fn release_retained_reserved_gate(&self, module_id: &str) -> bool {
2480        if self.get(module_id).is_some() {
2481            return false;
2482        }
2483        let mut reserved_nonces = self
2484            .reserved_nonces
2485            .lock()
2486            .unwrap_or_else(|poisoned| poisoned.into_inner());
2487        if !matches!(reserved_nonces.get(module_id), Some(None)) {
2488            return false;
2489        }
2490        reserved_nonces.remove(module_id);
2491        true
2492    }
2493
2494    pub(crate) fn operation_lock(&self) -> Arc<AsyncMutex<()>> {
2495        Arc::clone(&self.operation_lock)
2496    }
2497}
2498
2499/// Process supervisor for subc-owned singleton modules.
2500#[derive(Debug, Clone)]
2501pub struct Supervisor {
2502    registry: Arc<Registry>,
2503    restart_policy: RestartPolicy,
2504    drain_timeout: Duration,
2505    connection_file_path: Option<PathBuf>,
2506    capture_logs_dir: Option<PathBuf>,
2507    forwarding: Option<Arc<ForwardingTable>>,
2508    process_liveness: Arc<SupervisorProcessLiveness>,
2509    supervisor_handle: Option<SupervisorHandle>,
2510    health: HealthConfig,
2511    daemon_start_clock: crate::clock::StartClock,
2512    terminal_journal: Option<Arc<crate::terminal_journal::TerminalJournal>>,
2513    spawn_events: SpawnEventFeed,
2514    provenance_probe: ExecutableIdentityProbe,
2515    /// Every process spawned through this supervisor (and its clones) and not
2516    /// yet reaped, so daemon shutdown can end them.
2517    child_roster: ChildRoster,
2518    #[cfg(target_os = "linux")]
2519    cgroup_placement: Option<subc_cgroup::Placement>,
2520    #[cfg(test)]
2521    test_after_first_spawn: AfterFirstSpawnHook,
2522}
2523
2524/// Test-only hook run on the path that takes on a new module, right after its
2525/// first `spawn_child` returns (the process exists and could already be
2526/// registering) and before that process is handed to the module's supervise
2527/// loop and put on the roster. Lets a test observe what a fast child would see
2528/// in that window without racing a real one.
2529#[cfg(test)]
2530#[derive(Clone, Default)]
2531struct AfterFirstSpawnHook(Option<AfterFirstSpawnFn>);
2532
2533#[cfg(test)]
2534type AfterFirstSpawnFn = Arc<dyn Fn(&str) + Send + Sync>;
2535
2536#[cfg(test)]
2537impl fmt::Debug for AfterFirstSpawnHook {
2538    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2539        f.write_str("AfterFirstSpawnHook")
2540    }
2541}
2542
2543#[cfg(test)]
2544impl AfterFirstSpawnHook {
2545    fn run(&self, module_id: &str) {
2546        if let Some(hook) = &self.0 {
2547            hook(module_id);
2548        }
2549    }
2550}
2551
2552impl Supervisor {
2553    #[cfg(test)]
2554    pub(crate) fn new_for_test(registry: Arc<Registry>, policy: RestartPolicy) -> Self {
2555        let supervisor = Self::new(registry, policy);
2556        #[cfg(target_os = "macos")]
2557        let supervisor = supervisor.with_privacy_trampoline(test_privacy_trampoline());
2558        supervisor
2559    }
2560    /// Verify the trampoline once when configured. Missing private OS support
2561    /// refuses every macOS launch by name but does not stop the daemon's control
2562    /// server. Embedders must explicitly provide a binary with the subc-os hidden
2563    /// entry point; the library must not exec an arbitrary hosting program.
2564    pub fn with_privacy_trampoline(self, path: impl Into<PathBuf>) -> Self {
2565        let path = path.into();
2566        #[cfg(target_os = "macos")]
2567        {
2568            let result = probe_privacy_trampoline(&path).map(|()| path);
2569            if let Err(cause) = &result {
2570                error!(%cause, "macOS privacy identity unavailable; supervised launches will refuse");
2571            }
2572            self.child_roster.set_privacy_trampoline(result);
2573        }
2574        #[cfg(not(target_os = "macos"))]
2575        let _ = path;
2576        self
2577    }
2578    /// The first step of an announced daemon shutdown, before the notice and
2579    /// before any connection is closed.
2580    ///
2581    /// Sets the daemon-shutdown flag first: from here on no module is
2582    /// respawned (crash restart, operator restart, or swap), and every child
2583    /// exit is recorded as `daemon_shutdown` rather than as a crash, whether
2584    /// the module exits on the EOF this shutdown gives it or is signalled by a
2585    /// service manager that kills the whole cgroup. Then writes the journal's
2586    /// shutdown marker, which records the instant and closes this daemon
2587    /// incarnation's stretch of the journal.
2588    #[cfg(unix)]
2589    pub(crate) fn begin_daemon_shutdown(&self) {
2590        self.child_roster.close();
2591        if let Some(journal) = &self.terminal_journal {
2592            journal.stamp_shutdown();
2593        }
2594    }
2595
2596    /// Announce a cut while established connections can still carry replies.
2597    /// These budgets promise notice and a bounded wait, not child completion;
2598    /// they are local policy, not an estimate of launchd's unknown kill ceiling.
2599    #[cfg(unix)]
2600    pub(crate) async fn drain_for_daemon_shutdown(&self) -> Result<(), SuperviseError> {
2601        const NOTICE_BUDGET: Duration = Duration::from_millis(500);
2602        const DRAIN_BUDGET: Duration = Duration::from_secs(2);
2603        let Some(forwarding) = &self.forwarding else {
2604            return Ok(());
2605        };
2606        let module_ids = forwarding
2607            .begin_daemon_drain()
2608            .map_err(SuperviseError::Forwarding)?;
2609        let deadline_ms =
2610            unix_ms_now().saturating_add((NOTICE_BUDGET + DRAIN_BUDGET).as_millis() as u64);
2611        let mut notices = tokio::task::JoinSet::new();
2612        let mut drains = Vec::new();
2613        for module_id in module_ids {
2614            let Some(target) = forwarding
2615                .begin_module_drain(&module_id, RouteCloseReason::Restart)
2616                .map_err(SuperviseError::Forwarding)?
2617            else {
2618                continue;
2619            };
2620            let routes = forwarding
2621                .endpoint_routes(target.endpoint)
2622                .map_err(SuperviseError::Forwarding)?;
2623            // Restart allows deployed consumers to reopen after the new daemon
2624            // appears. The wire reason stays `restart`; what tells a daemon cut
2625            // apart from a module restart afterwards is the terminal record
2626            // itself, whose disposition is `daemon_shutdown` for every exit
2627            // observed once `begin_daemon_shutdown` has run.
2628            let command = serde_json::to_vec(&ModuleControlCommand::Draining {
2629                reason: RouteCloseReason::Restart,
2630                deadline_ms,
2631            })
2632            .expect("module draining serializes");
2633            let mut recipients = vec![(target.sink.clone(), target.negotiated_ver, command)];
2634            let mut clients: Vec<(crate::forwarding::GoodbyeTarget, Vec<u16>)> = Vec::new();
2635            for route in routes {
2636                let client = route.goodbye_target;
2637                if let Some((_, channels)) = clients
2638                    .iter_mut()
2639                    .find(|(existing, _)| existing.connection_id == client.connection_id)
2640                {
2641                    channels.push(client.channel);
2642                } else {
2643                    let channel = client.channel;
2644                    clients.push((client, vec![channel]));
2645                }
2646            }
2647            for (client, mut channels) in clients {
2648                channels.sort_unstable();
2649                channels.dedup();
2650                let closing = serde_json::to_vec(&ClientControlPush::RouteClosing {
2651                    module_id: module_id.clone(),
2652                    channels,
2653                    reason: RouteCloseReason::Restart,
2654                })
2655                .expect("route closing serializes");
2656                recipients.push((client.sink, client.negotiated_ver, closing));
2657            }
2658            for (sink, version, body) in recipients {
2659                notices.spawn(async move {
2660                    let frame = Frame::build_with_version(
2661                        version,
2662                        FrameType::Push,
2663                        control_flags(),
2664                        0,
2665                        0,
2666                        0,
2667                        body,
2668                    )
2669                    .expect("bounded lifecycle notice frame builds");
2670                    sink.send_flushed(frame).await
2671                });
2672            }
2673            let gauges = declared_busy_gauges(&self.registry, &module_id)?;
2674            drains.push((module_id, target.endpoint, gauges));
2675        }
2676        // A quiet forwarding table is not proof that queued notices reached the
2677        // socket. Wait for writer flush acknowledgements before testing quiescence.
2678        let notice_deadline = Instant::now() + NOTICE_BUDGET;
2679        while let Ok(Some(result)) = timeout_at(notice_deadline, notices.join_next()).await {
2680            if !matches!(result, Ok(Ok(()))) {
2681                warn!(?result, "daemon shutdown notice delivery failed");
2682            }
2683        }
2684        notices.abort_all();
2685        let deadline = Instant::now() + DRAIN_BUDGET;
2686        let mut waits = tokio::task::JoinSet::new();
2687        for (module_id, endpoint, gauges) in drains {
2688            let forwarding = Arc::clone(forwarding);
2689            let mut runtime = self.runtime_config();
2690            runtime.health.cadence = Duration::from_millis(100);
2691            waits.spawn(async move {
2692                wait_for_forwarding_quiescence(
2693                    &forwarding,
2694                    &module_id,
2695                    &runtime,
2696                    endpoint,
2697                    deadline,
2698                    &gauges,
2699                    DrainScope::Active,
2700                )
2701                .await
2702            });
2703        }
2704        while let Ok(Some(result)) = timeout_at(deadline, waits.join_next()).await {
2705            if !matches!(result, Ok(Ok(true))) {
2706                warn!(?result, "daemon shutdown drain did not reach quiescence");
2707            }
2708        }
2709        Ok(())
2710    }
2711
2712    /// The last step of an announced daemon shutdown, after the notice and the
2713    /// drain: send every registered module a module GOODBYE, the same planned
2714    /// stop signal `ck module stop` gives, then close every connection so each
2715    /// subc module sees EOF and starts its own teardown, then end every
2716    /// supervised child that has not exited
2717    /// by its own deadline (its drain budget, capped). Modules lead their own
2718    /// process groups, so a
2719    /// service manager's group kill no longer reaches them; without this a
2720    /// child that does not stop on EOF (every `protocol: "none"` child, which
2721    /// has no connection) would outlive the daemon. Every wait is bounded (see
2722    /// `child_roster`), and `escalate` resolving (a second SIGTERM) cuts them.
2723    #[cfg(unix)]
2724    pub(crate) async fn end_children_for_daemon_shutdown(
2725        &self,
2726        already_escalated: bool,
2727        escalate: impl std::future::Future<Output = ()>,
2728    ) {
2729        tokio::pin!(escalate);
2730        let mut escalated = already_escalated;
2731        if let Some(forwarding) = &self.forwarding {
2732            let reason = CloseReason::new(
2733                "daemon_shutdown",
2734                "the daemon is exiting after its shutdown notice and drain",
2735            );
2736            if escalated {
2737                // The operator asked to stop waiting: queue the GOODBYEs but
2738                // do not wait for them to be written.
2739                send_module_goodbyes_for_daemon_shutdown(forwarding, &reason, false).await;
2740            } else {
2741                tokio::select! {
2742                    biased;
2743                    _ = escalate.as_mut() => {
2744                        info!("second SIGTERM: abandoning module GOODBYE delivery");
2745                        escalated = true;
2746                    }
2747                    _ = send_module_goodbyes_for_daemon_shutdown(forwarding, &reason, true) => {}
2748                }
2749            }
2750            let closed = forwarding.close_all_connections(&reason);
2751            debug!(closed, "closed established connections for daemon shutdown");
2752        }
2753        // If the second SIGTERM arrived during GOODBYE delivery, `escalate` has
2754        // already completed and must not be polled again; the child shutdown
2755        // wait is told it is escalated and gets a future that never fires.
2756        let escalated_here = escalated && !already_escalated;
2757        let remaining_escalate = async move {
2758            if escalated_here {
2759                std::future::pending::<()>().await;
2760            } else {
2761                escalate.await;
2762            }
2763        };
2764        crate::child_roster::end_children_for_daemon_shutdown(
2765            &self.child_roster,
2766            escalated,
2767            remaining_escalate,
2768        )
2769        .await;
2770    }
2771
2772    pub fn new(registry: Arc<Registry>, restart_policy: RestartPolicy) -> Self {
2773        Self {
2774            registry,
2775            restart_policy,
2776            drain_timeout: DEFAULT_DRAIN_TIMEOUT,
2777            connection_file_path: None,
2778            capture_logs_dir: None,
2779            forwarding: None,
2780            process_liveness: Arc::new(SupervisorProcessLiveness::default()),
2781            supervisor_handle: None,
2782            health: HealthConfig::default(),
2783            daemon_start_clock: crate::clock::StartClock::capture(),
2784            terminal_journal: None,
2785            spawn_events: SpawnEventFeed::default(),
2786            provenance_probe: ExecutableIdentityProbe::default(),
2787            child_roster: ChildRoster::default(),
2788            #[cfg(target_os = "linux")]
2789            cgroup_placement: None,
2790            #[cfg(test)]
2791            test_after_first_spawn: AfterFirstSpawnHook::default(),
2792        }
2793    }
2794
2795    pub fn with_drain_timeout(mut self, drain_timeout: Duration) -> Self {
2796        self.drain_timeout = drain_timeout;
2797        self
2798    }
2799
2800    pub fn with_process_liveness(
2801        mut self,
2802        process_liveness: Arc<SupervisorProcessLiveness>,
2803    ) -> Self {
2804        self.process_liveness = process_liveness;
2805        self
2806    }
2807
2808    pub fn with_connection_file_path(mut self, connection_file_path: impl Into<PathBuf>) -> Self {
2809        self.connection_file_path = Some(connection_file_path.into());
2810        self
2811    }
2812
2813    /// Enables daemon-owned capture files for supervised stdout and stderr.
2814    pub fn with_capture_logs_dir(mut self, logs_dir: impl Into<PathBuf>) -> Self {
2815        self.capture_logs_dir = Some(logs_dir.into());
2816        self
2817    }
2818
2819    /// Names this daemon lifetime in spawn events, independently of whether a
2820    /// terminal journal is configured.
2821    pub fn with_daemon_incarnation(self, daemon_incarnation: String) -> Self {
2822        // A millisecond start stamp can repeat after clock rollback or a rapid
2823        // restart. Use the connection file's random daemon_id instead: it already
2824        // identifies this daemon lifetime independently of the wall clock.
2825        self.spawn_events.configure_incarnation(daemon_incarnation);
2826        self
2827    }
2828
2829    /// Enables best-effort history shared by every supervised module. Without
2830    /// it, terminal history is kept only in each module's in-memory ring.
2831    pub fn with_terminal_journal(self, path: PathBuf, daemon_incarnation: String) -> Self {
2832        let mut this = self.with_daemon_incarnation(daemon_incarnation.clone());
2833        this.terminal_journal = Some(Arc::new(crate::terminal_journal::TerminalJournal::open(
2834            path,
2835            daemon_incarnation,
2836        )));
2837        this
2838    }
2839
2840    pub fn with_forwarding(mut self, forwarding: Arc<ForwardingTable>) -> Self {
2841        self.forwarding = Some(forwarding);
2842        self
2843    }
2844
2845    pub fn with_handle(mut self, supervisor_handle: SupervisorHandle) -> Self {
2846        self.spawn_events = supervisor_handle.spawn_events.clone();
2847        self.supervisor_handle = Some(supervisor_handle);
2848        self
2849    }
2850
2851    pub fn with_health_config(mut self, health: HealthConfig) -> Self {
2852        self.health = health;
2853        self
2854    }
2855
2856    /// Keeps a record of every live child at `path`, rewritten on each spawn and
2857    /// reap, for the orphan sweep a later daemon runs at boot. Without it no
2858    /// record is kept.
2859    pub fn with_live_children_record(self, path: impl Into<PathBuf>) -> Self {
2860        self.child_roster.record_to(path.into());
2861        self
2862    }
2863
2864    #[cfg(target_os = "linux")]
2865    pub fn with_cgroup_placement(
2866        mut self,
2867        cgroup_placement: Option<subc_cgroup::Placement>,
2868    ) -> Self {
2869        self.cgroup_placement = cgroup_placement;
2870        self
2871    }
2872
2873    /// Spawn `spec.program` and start monitoring it.
2874    ///
2875    /// The child is expected to parse `--subc <connection-file-path>`, read the
2876    /// TCP+key connection file, authenticate to the already-running listener, and
2877    /// register with channel-0 `HELLO` using `spec.module_id` as its manifest id.
2878    pub fn spawn(&self, spec: ModuleSpec) -> Result<SupervisedModule, SuperviseError> {
2879        validate_spec(&spec)?;
2880        self.establish_identity(&spec);
2881
2882        let runtime = self.runtime_config();
2883        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
2884        let spawned = spawn_child(
2885            &spec,
2886            runtime.connection_file_path.as_deref(),
2887            self.supervisor_handle.as_ref(),
2888            &runtime.stderr_ring,
2889            runtime.capture_logs_dir.as_deref(),
2890            &runtime.child_roster,
2891            #[cfg(target_os = "linux")]
2892            runtime.cgroup_placement.as_ref(),
2893        );
2894        #[cfg(test)]
2895        self.test_after_first_spawn.run(&spec.module_id);
2896        let child = match spawned {
2897            Ok(child) => child,
2898            Err(err) => {
2899                // Unlike the configured paths, a failed `spawn` leaves nothing
2900                // on the roster, so the module must not stay marked configured.
2901                self.abandon_unrostered(&spec.module_id);
2902                return Err(err);
2903            }
2904        };
2905        self.mark_first_process_running(&spec, &runtime, &snapshot, &child)?;
2906
2907        Ok(self.supervised_module(spec, runtime, snapshot, Some(child)))
2908    }
2909
2910    /// Make `spec`'s module count as configured, with its identity gates
2911    /// (reserved id, reserved prefixes) in place, BEFORE any process of it
2912    /// exists.
2913    ///
2914    /// Every path that takes on a new module calls this before `spawn_child`.
2915    /// The order is the point: the child can connect, register, sync its
2916    /// scopes and ask about them as soon as it is spawned, and the module is
2917    /// only put on the roster after `spawn_child` returns. Were the mark set
2918    /// with the roster entry, a fast child would see its own owner reported
2919    /// as not configured, and a scoped `route.open` in that window would be
2920    /// refused as terminal `scope_not_live` ("will never sync") instead of
2921    /// retryable `scope_not_synced`.
2922    fn establish_identity(&self, spec: &ModuleSpec) {
2923        if let Some(supervisor_handle) = &self.supervisor_handle {
2924            supervisor_handle.apply_identity_configuration(spec);
2925            supervisor_handle.mark_configured(&spec.module_id);
2926        }
2927    }
2928
2929    /// Take back [`Self::establish_identity`]'s configured mark when the
2930    /// module will not be put on the roster after all.
2931    fn abandon_unrostered(&self, module_id: &str) {
2932        if let Some(supervisor_handle) = &self.supervisor_handle {
2933            supervisor_handle.unmark_configured_unless_rostered(module_id);
2934        }
2935    }
2936
2937    /// Record a freshly spawned first process as running. On failure the
2938    /// module never reaches the roster, so its configured mark is taken back.
2939    fn mark_first_process_running(
2940        &self,
2941        spec: &ModuleSpec,
2942        runtime: &SupervisorRuntimeConfig,
2943        snapshot: &SharedSnapshot,
2944        child: &SupervisedChild,
2945    ) -> Result<(), SuperviseError> {
2946        if let Err(err) = set_running(snapshot, child, &spec.module_id, &runtime.spawn_events) {
2947            self.abandon_unrostered(&spec.module_id);
2948            return Err(err);
2949        }
2950        self.process_liveness
2951            .track(spec.module_id.clone(), Arc::clone(snapshot));
2952        Ok(())
2953    }
2954
2955    /// Start supervising a module declared in daemon configuration.
2956    ///
2957    /// Unlike [`Self::spawn`], this records disabled modules and immediate spawn
2958    /// failures in the supervisor handle so operator-facing `supervisor.list`
2959    /// reflects every configured module while daemon startup continues.
2960    pub fn supervise_configured(
2961        &self,
2962        spec: ModuleSpec,
2963        enabled: bool,
2964    ) -> Result<SupervisedModule, SuperviseError> {
2965        validate_spec(&spec)?;
2966        self.establish_identity(&spec);
2967
2968        let runtime = self.runtime_config();
2969        if !enabled {
2970            let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::disabled()));
2971            return Ok(self.supervised_module(spec, runtime, snapshot, None));
2972        }
2973
2974        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
2975        let spawned = spawn_child(
2976            &spec,
2977            runtime.connection_file_path.as_deref(),
2978            self.supervisor_handle.as_ref(),
2979            &runtime.stderr_ring,
2980            runtime.capture_logs_dir.as_deref(),
2981            &runtime.child_roster,
2982            #[cfg(target_os = "linux")]
2983            runtime.cgroup_placement.as_ref(),
2984        );
2985        #[cfg(test)]
2986        self.test_after_first_spawn.run(&spec.module_id);
2987        match spawned {
2988            Ok(child) => {
2989                self.mark_first_process_running(&spec, &runtime, &snapshot, &child)?;
2990                Ok(self.supervised_module(spec, runtime, snapshot, Some(child)))
2991            }
2992            Err(err) => {
2993                error!(
2994                    module_id = %spec.module_id,
2995                    program = %spec.program.display(),
2996                    error = %err,
2997                    "configured module failed to spawn; marking failed and continuing"
2998                );
2999                let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::failed()));
3000                Ok(self.supervised_module(spec, runtime, snapshot, None))
3001            }
3002        }
3003    }
3004
3005    /// Supervise a configured module with its own health, drain, and crash
3006    /// budget. The restart policy is per-module because the config file is:
3007    /// `modules.<id>.restart` resolves to a full policy at parse time, and a
3008    /// module that is expensive to restart should not be forced onto the same
3009    /// budget as one that is cheap.
3010    pub fn supervise_configured_with_health(
3011        &self,
3012        spec: ModuleSpec,
3013        enabled: bool,
3014        health: HealthConfig,
3015        drain_timeout_ms: Option<u64>,
3016        restart_policy: RestartPolicy,
3017    ) -> Result<SupervisedModule, SuperviseError> {
3018        validate_spec(&spec)?;
3019        self.establish_identity(&spec);
3020
3021        let mut runtime = self.runtime_config();
3022        runtime.health = health.clone();
3023        runtime.restart_policy = restart_policy;
3024        if let Some(ms) = drain_timeout_ms {
3025            runtime.drain_timeout = Duration::from_millis(ms);
3026            *runtime
3027                .effective_drain_timeout
3028                .lock()
3029                .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.drain_timeout;
3030        }
3031        if !enabled {
3032            let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::disabled()));
3033            return Ok(self.supervised_module(spec, runtime, snapshot, None));
3034        }
3035
3036        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
3037        let spawned = spawn_child(
3038            &spec,
3039            runtime.connection_file_path.as_deref(),
3040            self.supervisor_handle.as_ref(),
3041            &runtime.stderr_ring,
3042            runtime.capture_logs_dir.as_deref(),
3043            &runtime.child_roster,
3044            #[cfg(target_os = "linux")]
3045            runtime.cgroup_placement.as_ref(),
3046        );
3047        #[cfg(test)]
3048        self.test_after_first_spawn.run(&spec.module_id);
3049        match spawned {
3050            Ok(child) => {
3051                self.mark_first_process_running(&spec, &runtime, &snapshot, &child)?;
3052                Ok(self.supervised_module(spec, runtime, snapshot, Some(child)))
3053            }
3054            Err(err) => {
3055                if health.critical {
3056                    error!(
3057                        module_id = %spec.module_id,
3058                        program = %spec.program.display(),
3059                        error = %err,
3060                        "critical configured module failed to spawn; marking failed and alerting"
3061                    );
3062                } else {
3063                    error!(
3064                        module_id = %spec.module_id,
3065                        program = %spec.program.display(),
3066                        error = %err,
3067                        "configured module failed to spawn; marking failed and continuing"
3068                    );
3069                }
3070                let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::failed()));
3071                Ok(self.supervised_module(spec, runtime, snapshot, None))
3072            }
3073        }
3074    }
3075
3076    fn runtime_config(&self) -> SupervisorRuntimeConfig {
3077        let effective_drain_timeout = Arc::new(Mutex::new(self.drain_timeout));
3078        SupervisorRuntimeConfig {
3079            scheduled_respawn: Arc::default(),
3080            deferred_reload_reply: Arc::default(),
3081            restart_policy: self.restart_policy,
3082            drain_timeout: self.drain_timeout,
3083            // Shared with this module's roster copy: daemon shutdown waits on
3084            // each child for the module's own drain budget, as resolved now.
3085            child_roster: self
3086                .child_roster
3087                .for_module(Arc::clone(&effective_drain_timeout)),
3088            effective_drain_timeout,
3089            default_drain_timeout: self.drain_timeout,
3090            health: self.health.clone(),
3091            connection_file_path: self.connection_file_path.clone(),
3092            capture_logs_dir: self.capture_logs_dir.clone(),
3093            forwarding: self.forwarding.clone(),
3094            supervisor_handle: self.supervisor_handle.clone(),
3095            stderr_ring: Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default()))),
3096            terminal_ring: Arc::new(Mutex::new(
3097                TerminalRing::new(
3098                    TerminalRingConfig::default(),
3099                    self.daemon_start_clock.started_at_ms(),
3100                )
3101                .with_start_clock(self.daemon_start_clock)
3102                .with_journal(self.terminal_journal.clone())
3103                .with_daemon_shutdown(self.child_roster.shutdown_flag()),
3104            )),
3105            spawn_events: self.spawn_events.clone(),
3106            #[cfg(target_os = "linux")]
3107            cgroup_placement: self.cgroup_placement.clone(),
3108            #[cfg(test)]
3109            test_seed_stale_facts_before_enable_spawn: false,
3110            #[cfg(test)]
3111            test_reload_exit_record_gate: None,
3112        }
3113    }
3114
3115    fn supervised_module(
3116        &self,
3117        spec: ModuleSpec,
3118        runtime: SupervisorRuntimeConfig,
3119        snapshot: SharedSnapshot,
3120        child: Option<SupervisedChild>,
3121    ) -> SupervisedModule {
3122        let configuration = Arc::new(Mutex::new(SupervisedConfiguration {
3123            spec: spec.clone(),
3124            health: runtime.health.clone(),
3125        }));
3126        let stderr_ring = Arc::clone(&runtime.stderr_ring);
3127        let terminal_ring = Arc::clone(&runtime.terminal_ring);
3128        // The module's OWN policy, which may be its per-module config rather than
3129        // the supervisor-wide one; status must report the budget the supervise
3130        // loop actually enforces.
3131        let restart_policy = runtime.restart_policy;
3132        let effective_drain_timeout = Arc::clone(&runtime.effective_drain_timeout);
3133        let (tx, rx) = mpsc::channel(4);
3134        let monitor = tokio::spawn(supervise_loop(
3135            spec.clone(),
3136            runtime,
3137            Arc::clone(&self.registry),
3138            Arc::clone(&self.process_liveness),
3139            Arc::clone(&snapshot),
3140            child,
3141            rx,
3142        ));
3143
3144        let module_id = spec.module_id.clone();
3145        let module = SupervisedModule {
3146            inner: Arc::new(SupervisedModuleInner {
3147                module_id: module_id.clone(),
3148                registry: Arc::clone(&self.registry),
3149                snapshot,
3150                configuration,
3151                stderr_ring,
3152                terminal_ring,
3153                commands: tx,
3154                monitor: Mutex::new(Some(monitor)),
3155                restart_policy,
3156                effective_drain_timeout,
3157                provenance_probe: self.provenance_probe.clone(),
3158            }),
3159        };
3160        // The identity gates and the configured mark were set by
3161        // `establish_identity` before any process was spawned; only the roster
3162        // entry waits for the module handle, which needs the spawned child.
3163        if let Some(supervisor_handle) = &self.supervisor_handle {
3164            supervisor_handle.insert(module.clone());
3165        }
3166        module
3167    }
3168}
3169
3170impl Default for Supervisor {
3171    fn default() -> Self {
3172        Self::new(Arc::new(Registry::default()), RestartPolicy::default())
3173    }
3174}
3175
3176/// Handle to one supervised child process.
3177#[derive(Clone)]
3178pub struct SupervisedModule {
3179    inner: Arc<SupervisedModuleInner>,
3180}
3181
3182struct SupervisedModuleInner {
3183    module_id: String,
3184    registry: Arc<Registry>,
3185    snapshot: SharedSnapshot,
3186    configuration: Arc<Mutex<SupervisedConfiguration>>,
3187    stderr_ring: Arc<Mutex<StderrRing>>,
3188    terminal_ring: Arc<Mutex<TerminalRing>>,
3189    commands: mpsc::Sender<SupervisorCommand>,
3190    monitor: Mutex<Option<JoinHandle<()>>>,
3191    /// Copied from the supervisor's runtime config at spawn so `status()` can
3192    /// report the restart budget without reaching back into the supervisor. The
3193    /// policy is fixed for the process's lifetime, so a copy cannot drift.
3194    restart_policy: RestartPolicy,
3195    effective_drain_timeout: Arc<Mutex<Duration>>,
3196    provenance_probe: ExecutableIdentityProbe,
3197}
3198
3199impl fmt::Debug for SupervisedModule {
3200    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3201        f.debug_struct("SupervisedModule")
3202            .field("module_id", &self.inner.module_id)
3203            .field("status", &self.status())
3204            .finish_non_exhaustive()
3205    }
3206}
3207
3208impl SupervisedModule {
3209    pub fn module_id(&self) -> &str {
3210        &self.inner.module_id
3211    }
3212
3213    /// Test-only: put one probe miss on the streak, the way
3214    /// `handle_health_probe_failure` does, so tests can assert what a later
3215    /// event does to the streak without driving the whole probe loop.
3216    #[cfg(test)]
3217    pub(crate) fn record_health_probe_failure_for_test(
3218        &self,
3219        detail: &str,
3220    ) -> Result<(), SuperviseError> {
3221        update_snapshot(&self.inner.snapshot, Some(&self.inner.module_id), |state| {
3222            state.health.consecutive_failures = state.health.consecutive_failures.saturating_add(1);
3223            state.health.detail = Some(detail.to_string());
3224        })
3225    }
3226
3227    pub fn state(&self) -> Result<ModuleState, SuperviseError> {
3228        Ok(lock_snapshot(&self.inner.snapshot)?.state)
3229    }
3230
3231    pub(crate) fn registration_end_reason(
3232        &self,
3233    ) -> Result<Option<RegistrationEndReason>, SuperviseError> {
3234        let snapshot = lock_snapshot(&self.inner.snapshot)?;
3235        Ok(match snapshot.state {
3236            ModuleState::Draining if snapshot.draining_to_replace => {
3237                Some(RegistrationEndReason::SupervisorRestart)
3238            }
3239            ModuleState::Draining => Some(RegistrationEndReason::SupervisorStop),
3240            ModuleState::Restarting if snapshot.draining_to_replace => {
3241                Some(RegistrationEndReason::SupervisorRestart)
3242            }
3243            _ => None,
3244        })
3245    }
3246
3247    /// The module's retained stderr, newest lines last.
3248    ///
3249    /// Deliberately NOT on [`Self::status`]: a bounded tail is kilobytes per
3250    /// module, `supervisor.list` renders every module, and putting it in the
3251    /// shared snapshot would make each status read carry a payload almost nobody
3252    /// asked for. Callers that want the text ask for it.
3253    pub fn stderr_tail(
3254        &self,
3255        max_lines: Option<usize>,
3256        max_bytes: Option<usize>,
3257    ) -> StderrTailSnapshot {
3258        self.inner
3259            .stderr_ring
3260            .lock()
3261            .unwrap_or_else(|poisoned| poisoned.into_inner())
3262            .snapshot(max_lines, max_bytes)
3263    }
3264
3265    /// The module's bounded terminal history, oldest retained exit first.
3266    ///
3267    /// The daemon-start stamp distinguishes a quiet supervisor from a replacement
3268    /// daemon whose in-memory history was necessarily reset.
3269    pub fn terminal_history(&self) -> TerminalHistorySnapshot {
3270        self.inner
3271            .terminal_ring
3272            .lock()
3273            .unwrap_or_else(|poisoned| poisoned.into_inner())
3274            .snapshot()
3275    }
3276
3277    /// Retained observations from the current ring and all journal generations.
3278    ///
3279    /// Blocking: this reads the journal files. Async callers use
3280    /// [`Self::read_durable_terminal_history`].
3281    pub fn durable_terminal_history(&self) -> subc_control::TerminalHistory {
3282        durable_terminal_history_of(&self.inner.terminal_ring, &self.inner.module_id)
3283    }
3284
3285    /// [`Self::durable_terminal_history`] on a blocking thread, so the journal
3286    /// read (up to every retained generation) never occupies a runtime worker.
3287    /// Fails only if the blocking task could not finish (runtime shutdown or a
3288    /// panic in the read).
3289    pub(crate) async fn read_durable_terminal_history(
3290        &self,
3291    ) -> Result<subc_control::TerminalHistory, tokio::task::JoinError> {
3292        let terminal_ring = Arc::clone(&self.inner.terminal_ring);
3293        let module_id = self.inner.module_id.clone();
3294        tokio::task::spawn_blocking(move || durable_terminal_history_of(&terminal_ring, &module_id))
3295            .await
3296    }
3297
3298    pub fn status(&self) -> Result<ModuleStatus, SuperviseError> {
3299        self.status_with_snapshot_lock(&self.inner.snapshot, None)
3300            .map(|(status, _)| status)
3301    }
3302
3303    pub(crate) fn record_deliberate_severance(
3304        &self,
3305        identity: ProcessIdentity,
3306    ) -> Result<bool, SuperviseError> {
3307        let mut snapshot = lock_snapshot(&self.inner.snapshot)?;
3308        if snapshot.pid != Some(identity.pid)
3309            || snapshot.process_start_time != Some(identity.start_time)
3310        {
3311            return Ok(false);
3312        }
3313        snapshot.deliberate_severance = Some(identity);
3314        Ok(true)
3315    }
3316
3317    /// Read status for a channel-0 renderer and report a contended snapshot lock.
3318    ///
3319    /// Internal supervision callers use [`Self::status`] so writer-side machinery
3320    /// does not produce reader-observability logs.
3321    pub(crate) fn status_for_control(
3322        &self,
3323        caller: &'static str,
3324    ) -> Result<ModuleStatus, SuperviseError> {
3325        self.status_with_snapshot_lock(&self.inner.snapshot, Some(caller))
3326            .map(|(status, _)| status)
3327    }
3328
3329    fn status_with_snapshot_lock(
3330        &self,
3331        snapshot: &SharedSnapshot,
3332        caller: Option<&'static str>,
3333    ) -> Result<(ModuleStatus, Option<SpawnedFileIdentity>), SuperviseError> {
3334        let mut guard = match caller {
3335            Some(caller) => lock_snapshot_for_control(snapshot, &self.inner.module_id, caller)?,
3336            None => lock_snapshot(snapshot)?,
3337        };
3338        // Read the budget through the pruning path so a reader sees the same
3339        // in-window count the restart decision would use, not a stale total.
3340        let restart_count =
3341            guard.crash_restarts_in_window(self.inner.restart_policy.window, Instant::now());
3342        let snapshot = guard.clone();
3343        drop(guard);
3344        let drain_timeout = *self.inner.effective_drain_timeout.lock().map_err(|_| {
3345            SuperviseError::StatePoisoned {
3346                module_id: Some(self.inner.module_id.clone()),
3347            }
3348        })?;
3349        let registration_active = self
3350            .inner
3351            .registry
3352            .get_module(&self.inner.module_id)
3353            .map_err(SuperviseError::Registry)?
3354            .is_some();
3355        let protocol = snapshot
3356            .spawned_protocol
3357            .unwrap_or(self.declared_protocol()?);
3358        let running_process =
3359            snapshot.enabled && snapshot.state == ModuleState::Running && snapshot.process_alive;
3360        // Registration is the difference between the two protocols and the only
3361        // one: a subc module that has not registered cannot serve a request even
3362        // though its process is up, and a `none` module never registers at all,
3363        // so requiring it there would pin `live` to false for the whole life of
3364        // a perfectly healthy process.
3365        let live = match protocol {
3366            ModuleProtocol::Subc => running_process && registration_active,
3367            ModuleProtocol::None => running_process,
3368        };
3369
3370        Ok((
3371            ModuleStatus {
3372                module_id: self.inner.module_id.clone(),
3373                launch_nonce_source: observed_launch_nonce_source(&snapshot),
3374                state: snapshot.state,
3375                enabled: snapshot.enabled,
3376                process_alive: snapshot.process_alive,
3377                registration_active,
3378                protocol,
3379                live,
3380                restart_count,
3381                lifetime_restarts: snapshot.lifetime_restarts,
3382                spawn_generation: snapshot.spawn_generation,
3383                max_restarts: self.inner.restart_policy.max_restarts,
3384                restart_window: self.inner.restart_policy.window,
3385                drain_timeout,
3386                restart_backoff: self.inner.restart_policy.backoff,
3387                restart_max_backoff: self.inner.restart_policy.max_backoff,
3388                pid: snapshot.reported_pid(),
3389                spawned_at_ms: snapshot.spawned_at_ms,
3390                spawned_from: snapshot.spawned_from,
3391                process_start_time: snapshot.process_start_time,
3392                last_exit: snapshot.last_exit,
3393                health: snapshot.health,
3394            },
3395            snapshot.spawned_file_identity.clone(),
3396        ))
3397    }
3398
3399    #[cfg(test)]
3400    pub(crate) fn hold_snapshot_for_test(
3401        &self,
3402        acquired: std::sync::mpsc::Sender<()>,
3403        hold: Duration,
3404    ) -> std::thread::JoinHandle<()> {
3405        let snapshot = Arc::clone(&self.inner.snapshot);
3406        std::thread::spawn(move || {
3407            let _guard = snapshot.lock().expect("test snapshot lock is not poisoned");
3408            acquired
3409                .send(())
3410                .expect("test receiver waits for snapshot lock");
3411            std::thread::sleep(hold);
3412        })
3413    }
3414
3415    /// The status and the running-image check for `supervisor.provenance`,
3416    /// taken from one status read. The exec acknowledgement can land between
3417    /// two separate reads, and the reply would then pair "no pid yet" with an
3418    /// image observed after the module started, which describes no single
3419    /// moment.
3420    pub(crate) async fn status_and_running_image_agreement(
3421        &self,
3422    ) -> Result<(ModuleStatus, subc_control::RunningImageAgreement), SuperviseError> {
3423        let (status, identity) = self.status_with_snapshot_lock(&self.inner.snapshot, None)?;
3424        let image = self
3425            .inner
3426            .provenance_probe
3427            .observe(
3428                status.pid,
3429                status.spawned_from.as_deref(),
3430                identity,
3431                status.process_start_time,
3432            )
3433            .await;
3434        Ok((status, image))
3435    }
3436
3437    pub(crate) async fn running_image_agreement(&self) -> subc_control::RunningImageAgreement {
3438        let snapshot = match lock_snapshot(&self.inner.snapshot) {
3439            Ok(snapshot) => snapshot.clone(),
3440            Err(_) => {
3441                return subc_control::RunningImageAgreement::Unavailable {
3442                    reason: subc_control::RunningImageUnavailableReason::NotRunning,
3443                };
3444            }
3445        };
3446        self.inner
3447            .provenance_probe
3448            .observe(
3449                snapshot.reported_pid(),
3450                snapshot.spawned_from.as_deref(),
3451                snapshot.spawned_file_identity.clone(),
3452                snapshot.process_start_time,
3453            )
3454            .await
3455    }
3456
3457    /// Memory and CPU time of the module's current process, read now. Only the
3458    /// process the supervisor spawned is read, not processes it has started.
3459    pub(crate) fn child_resource_usage(&self) -> subc_control::ChildResourceUsage {
3460        #[cfg(windows)]
3461        {
3462            let image = lock_snapshot(&self.inner.snapshot)
3463                .ok()
3464                .and_then(|snapshot| {
3465                    snapshot
3466                        .spawned_file_identity
3467                        .as_ref()
3468                        .and_then(|identity| identity.windows_image.clone())
3469                });
3470            match image {
3471                Some(image) => {
3472                    crate::child_resources::read_windows_handle(image.process(), image.start_time())
3473                }
3474                None => subc_control::ChildResourceUsage::Unavailable {
3475                    reason: subc_control::ChildResourceUnavailableReason::NotRunning,
3476                },
3477            }
3478        }
3479        #[cfg(not(windows))]
3480        {
3481            let (pid, start_time) = match lock_snapshot(&self.inner.snapshot) {
3482                Ok(snapshot) => (snapshot.reported_pid(), snapshot.process_start_time),
3483                Err(_) => {
3484                    return subc_control::ChildResourceUsage::Unavailable {
3485                        reason: subc_control::ChildResourceUnavailableReason::Unreadable,
3486                    }
3487                }
3488            };
3489            crate::child_resources::read(pid, start_time)
3490        }
3491    }
3492
3493    pub(crate) fn will_recover_after_connection_loss(&self) -> Result<bool, SuperviseError> {
3494        let mut snapshot = lock_snapshot(&self.inner.snapshot)?;
3495        Ok(match snapshot.state {
3496            ModuleState::Restarting => true,
3497            ModuleState::Failed | ModuleState::Disabled => false,
3498            _ => daemon_will_restart(&mut snapshot, &self.inner.restart_policy, Instant::now()),
3499        })
3500    }
3501
3502    #[cfg(test)]
3503    pub(crate) fn is_warming(&self) -> Result<bool, SuperviseError> {
3504        self.is_warming_with_snapshot_lock(None)
3505    }
3506
3507    pub(crate) fn is_warming_for_control(
3508        &self,
3509        caller: &'static str,
3510    ) -> Result<bool, SuperviseError> {
3511        self.is_warming_with_snapshot_lock(Some(caller))
3512    }
3513
3514    fn is_warming_with_snapshot_lock(
3515        &self,
3516        caller: Option<&'static str>,
3517    ) -> Result<bool, SuperviseError> {
3518        let snapshot = match caller {
3519            Some(caller) => {
3520                lock_snapshot_for_control(&self.inner.snapshot, &self.inner.module_id, caller)?
3521            }
3522            None => lock_snapshot(&self.inner.snapshot)?,
3523        }
3524        .clone();
3525        Ok(matches!(
3526            snapshot.state,
3527            ModuleState::Starting | ModuleState::Running | ModuleState::Restarting
3528        ))
3529    }
3530
3531    /// Drain the module and stop monitoring it.
3532    pub async fn drain(&self) -> Result<(), SuperviseError> {
3533        self.stop().await
3534    }
3535
3536    pub(crate) async fn retire(&self) -> Result<(), SuperviseError> {
3537        match self.state()? {
3538            ModuleState::Stopped | ModuleState::Failed => return Ok(()),
3539            ModuleState::Starting
3540            | ModuleState::Running
3541            | ModuleState::Unresponsive
3542            | ModuleState::Restarting
3543            | ModuleState::Draining
3544            | ModuleState::Disabled => {}
3545        }
3546
3547        let (reply_tx, reply_rx) = oneshot::channel();
3548        self.inner
3549            .commands
3550            .send(SupervisorCommand::Retire { reply: reply_tx })
3551            .await
3552            .map_err(|_| SuperviseError::CommandClosed {
3553                module_id: self.inner.module_id.clone(),
3554            })?;
3555        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3556            module_id: self.inner.module_id.clone(),
3557        })?
3558    }
3559
3560    pub async fn stop(&self) -> Result<(), SuperviseError> {
3561        match self.state()? {
3562            ModuleState::Stopped | ModuleState::Failed => return Ok(()),
3563            ModuleState::Starting
3564            | ModuleState::Running
3565            | ModuleState::Unresponsive
3566            | ModuleState::Restarting
3567            | ModuleState::Draining
3568            | ModuleState::Disabled => {}
3569        }
3570
3571        let (reply_tx, reply_rx) = oneshot::channel();
3572        self.inner
3573            .commands
3574            .send(SupervisorCommand::Drain { reply: reply_tx })
3575            .await
3576            .map_err(|_| SuperviseError::CommandClosed {
3577                module_id: self.inner.module_id.clone(),
3578            })?;
3579        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3580            module_id: self.inner.module_id.clone(),
3581        })?
3582    }
3583
3584    pub async fn restart(&self, drain_timeout_ms: Option<u64>) -> Result<(), SuperviseError> {
3585        let received_at_generation = lock_snapshot(&self.inner.snapshot)?.spawn_generation;
3586        let (reply_tx, reply_rx) = oneshot::channel();
3587        self.inner
3588            .commands
3589            .send(SupervisorCommand::Restart {
3590                drain_timeout_ms,
3591                received_at_generation,
3592                queued_at: Instant::now(),
3593                reply: reply_tx,
3594            })
3595            .await
3596            .map_err(|_| SuperviseError::CommandClosed {
3597                module_id: self.inner.module_id.clone(),
3598            })?;
3599        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3600            module_id: self.inner.module_id.clone(),
3601        })?
3602    }
3603
3604    /// Blue/green restart: see [`SupervisorCommand::Swap`] and the
3605    /// `supervisor_swap` module. Returns once the swap has cut over (the old
3606    /// process then drains in the background of the supervise loop) or has
3607    /// failed, leaving the old process serving.
3608    pub async fn swap(&self, ready_timeout: Option<Duration>) -> Result<(), SuperviseError> {
3609        let (reply_tx, reply_rx) = oneshot::channel();
3610        self.inner
3611            .commands
3612            .send(SupervisorCommand::Swap {
3613                ready_timeout,
3614                reply: reply_tx,
3615            })
3616            .await
3617            .map_err(|_| SuperviseError::CommandClosed {
3618                module_id: self.inner.module_id.clone(),
3619            })?;
3620        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3621            module_id: self.inner.module_id.clone(),
3622        })?
3623    }
3624
3625    pub async fn reload(&self) -> Result<(), SuperviseError> {
3626        let (reply_tx, reply_rx) = oneshot::channel();
3627        self.inner
3628            .commands
3629            .send(SupervisorCommand::Reload { reply: reply_tx })
3630            .await
3631            .map_err(|_| SuperviseError::CommandClosed {
3632                module_id: self.inner.module_id.clone(),
3633            })?;
3634        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3635            module_id: self.inner.module_id.clone(),
3636        })?
3637    }
3638
3639    pub async fn set_enabled(&self, enabled: bool) -> Result<bool, SuperviseError> {
3640        let (reply_tx, reply_rx) = oneshot::channel();
3641        self.inner
3642            .commands
3643            .send(SupervisorCommand::SetEnabled {
3644                enabled,
3645                reply: reply_tx,
3646            })
3647            .await
3648            .map_err(|_| SuperviseError::CommandClosed {
3649                module_id: self.inner.module_id.clone(),
3650            })?;
3651        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3652            module_id: self.inner.module_id.clone(),
3653        })?
3654    }
3655
3656    /// The current process's protocol, or the configured protocol when down.
3657    /// A rescan stores the next launch spec without changing how an existing
3658    /// process registers, serves routes, is probed, or exits.
3659    pub(crate) fn declared_protocol(&self) -> Result<ModuleProtocol, SuperviseError> {
3660        let configured = self
3661            .inner
3662            .configuration
3663            .lock()
3664            .map_err(|_| SuperviseError::StatePoisoned {
3665                module_id: Some(self.inner.module_id.clone()),
3666            })?
3667            .spec
3668            .protocol;
3669        let state = lock_snapshot(&self.inner.snapshot)?;
3670        Ok(state.spawned_protocol.unwrap_or(configured))
3671    }
3672
3673    pub(crate) fn configuration(&self) -> Result<(ModuleSpec, HealthConfig), SuperviseError> {
3674        let configuration =
3675            self.inner
3676                .configuration
3677                .lock()
3678                .map_err(|_| SuperviseError::StatePoisoned {
3679                    module_id: Some(self.inner.module_id.clone()),
3680                })?;
3681        Ok((configuration.spec.clone(), configuration.health.clone()))
3682    }
3683
3684    /// Replace this module's launch spec, keeping its health and drain policy,
3685    /// the way a rescan does for a changed config entry. The running process is
3686    /// untouched; the next spawn (a restart, or a swap's candidate) uses it.
3687    #[cfg(any(test, feature = "test-support"))]
3688    pub async fn update_spec_for_test(&self, spec: ModuleSpec) -> Result<(), SuperviseError> {
3689        let (_, health) = self.configuration()?;
3690        let drain_timeout_ms = u64::try_from(
3691            self.inner
3692                .effective_drain_timeout
3693                .lock()
3694                .unwrap_or_else(|poisoned| poisoned.into_inner())
3695                .as_millis(),
3696        )
3697        .ok();
3698        self.update_configuration(spec, health, drain_timeout_ms)
3699            .await
3700    }
3701
3702    pub(crate) async fn update_configuration(
3703        &self,
3704        spec: ModuleSpec,
3705        health: HealthConfig,
3706        drain_timeout_ms: Option<u64>,
3707    ) -> Result<(), SuperviseError> {
3708        if spec.module_id != self.inner.module_id {
3709            return Err(SuperviseError::InvalidSpec {
3710                reason: "a supervised module's module_id cannot be changed".to_string(),
3711            });
3712        }
3713        validate_spec(&spec)?;
3714        let (reply_tx, reply_rx) = oneshot::channel();
3715        self.inner
3716            .commands
3717            .send(SupervisorCommand::UpdateConfiguration {
3718                spec: spec.clone(),
3719                health: health.clone(),
3720                drain_timeout_ms,
3721                reply: reply_tx,
3722            })
3723            .await
3724            .map_err(|_| SuperviseError::CommandClosed {
3725                module_id: self.inner.module_id.clone(),
3726            })?;
3727        reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3728            module_id: self.inner.module_id.clone(),
3729        })?;
3730        let mut configuration =
3731            self.inner
3732                .configuration
3733                .lock()
3734                .map_err(|_| SuperviseError::StatePoisoned {
3735                    module_id: Some(self.inner.module_id.clone()),
3736                })?;
3737        configuration.spec = spec;
3738        configuration.health = health;
3739        Ok(())
3740    }
3741}
3742
3743impl Drop for SupervisedModuleInner {
3744    fn drop(&mut self) {
3745        let Ok(mut monitor) = self.monitor.lock() else {
3746            return;
3747        };
3748        if let Some(monitor) = monitor.as_ref().filter(|monitor| !monitor.is_finished()) {
3749            let _ = update_snapshot(&self.snapshot, Some(&self.module_id), |state| {
3750                state.state = ModuleState::Stopped;
3751                clear_current_process_facts(state);
3752            });
3753            monitor.abort();
3754        }
3755        let _ = monitor.take();
3756    }
3757}
3758
3759#[derive(Debug)]
3760enum SupervisorCommand {
3761    Drain {
3762        reply: oneshot::Sender<Result<(), SuperviseError>>,
3763    },
3764    Retire {
3765        reply: oneshot::Sender<Result<(), SuperviseError>>,
3766    },
3767    Restart {
3768        /// Operator override for this one restart's drain budget, in ms. `None`
3769        /// uses the module's configured/default budget; `Some(0)` cuts
3770        /// immediately (wedge bounce: a stuck request never settles, so
3771        /// waiting only delays recovery).
3772        drain_timeout_ms: Option<u64>,
3773        /// The module's `spawn_generation` when the request was received, before
3774        /// it waited in the command queue. A queued restart whose module has
3775        /// since spawned a newer process is already satisfied (see the handler).
3776        received_at_generation: u64,
3777        /// When the request entered the command queue, so the handler can log
3778        /// how long it waited behind the loop's other work.
3779        queued_at: Instant,
3780        reply: oneshot::Sender<Result<(), SuperviseError>>,
3781    },
3782    Reload {
3783        reply: oneshot::Sender<Result<(), SuperviseError>>,
3784    },
3785    SetEnabled {
3786        enabled: bool,
3787        reply: oneshot::Sender<Result<bool, SuperviseError>>,
3788    },
3789    UpdateConfiguration {
3790        spec: ModuleSpec,
3791        health: HealthConfig,
3792        /// Per-module drain override from the new config; `None` re-resolves to
3793        /// the supervisor-wide default.
3794        drain_timeout_ms: Option<u64>,
3795        reply: oneshot::Sender<()>,
3796    },
3797    Swap {
3798        /// How long the candidate may take to register and declare itself
3799        /// ready. `None` uses [`DEFAULT_SWAP_READY_TIMEOUT`].
3800        ready_timeout: Option<Duration>,
3801        /// Answered at cutover or failure; the incumbent's drain follows.
3802        reply: oneshot::Sender<Result<(), SuperviseError>>,
3803    },
3804}
3805
3806#[derive(Debug)]
3807pub enum SuperviseError {
3808    InvalidSpec {
3809        reason: String,
3810    },
3811    Spawn {
3812        program: PathBuf,
3813        source: io::Error,
3814        cgroup_path: Option<PathBuf>,
3815    },
3816    Cgroup {
3817        module_id: String,
3818        source: io::Error,
3819    },
3820    /// CSPRNG failure generating a reserved module's launch nonce. Fail loud rather
3821    /// than spawn a reserved module without its identity binding.
3822    LaunchNonce {
3823        reason: String,
3824    },
3825    Wait {
3826        module_id: String,
3827        source: io::Error,
3828    },
3829    Kill {
3830        module_id: String,
3831        source: io::Error,
3832    },
3833    Forwarding(ForwardingError),
3834    Registry(RegistryError),
3835    ReloadUnavailable {
3836        module_id: String,
3837        reason: String,
3838    },
3839    /// An operator restart/reload was requested for a module that is currently
3840    /// disabled. Restart/reload cycle a *running* module; a disabled module must
3841    /// be explicitly re-enabled (set_enabled(true)) rather than silently started
3842    /// by a restart, so these commands are rejected instead of re-enabling it.
3843    Disabled {
3844        module_id: String,
3845    },
3846    ReloadFailed {
3847        module_id: String,
3848        reason: String,
3849    },
3850    RegistrationStillActive {
3851        module_id: String,
3852        waited: Duration,
3853    },
3854    StatePoisoned {
3855        module_id: Option<String>,
3856    },
3857    CommandClosed {
3858        module_id: String,
3859    },
3860    /// A restart or reload arrived while a swap's candidate was warming. The
3861    /// swap owns the module until it cuts over or fails; a stop or disable
3862    /// would have aborted it instead.
3863    SwapInProgress {
3864        module_id: String,
3865    },
3866    /// A swap was refused before anything was spawned.
3867    SwapRefused {
3868        module_id: String,
3869        reason: SwapRefusal,
3870    },
3871    /// A swap spawned a candidate and gave up on it. The candidate has been
3872    /// killed and its slot freed; the incumbent was left serving and was never
3873    /// drained, except in the one `CutoverLost` case described on that arm.
3874    SwapFailed {
3875        module_id: String,
3876        arm: SwapFailureArm,
3877        detail: String,
3878        /// How the candidate exited, when it exited on its own before the
3879        /// supervisor gave up on it.
3880        candidate_exit: Option<ExitReport>,
3881    },
3882}
3883
3884/// Why a swap was refused before a candidate was spawned.
3885#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3886pub enum SwapRefusal {
3887    /// The module's config does not declare `overlap: "safe"`.
3888    OverlapExclusive,
3889    /// The module is not registered, so there is no incumbent to keep serving
3890    /// and nothing a swap would improve on; a plain restart is the tool.
3891    NotRegistered,
3892    /// The module does not speak the subc wire, so a candidate could never
3893    /// register or declare itself ready.
3894    ProtocolNone,
3895    /// The supervisor lacks the forwarding table (to cut routes over) or the
3896    /// shared handle (to admit the candidate's HELLO) that a swap needs.
3897    NotConfigured,
3898    /// A swap is already open for this module.
3899    AlreadySwapping,
3900}
3901
3902impl SwapRefusal {
3903    pub fn as_str(self) -> &'static str {
3904        match self {
3905            Self::OverlapExclusive => "overlap_exclusive",
3906            Self::NotRegistered => "not_registered",
3907            Self::ProtocolNone => "protocol_none",
3908            Self::NotConfigured => "not_configured",
3909            Self::AlreadySwapping => "already_swapping",
3910        }
3911    }
3912}
3913
3914/// Which failure arm ended a swap. Every arm but one leaves the incumbent
3915/// serving and undrained; see `CutoverLost`.
3916#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3917pub enum SwapFailureArm {
3918    /// The candidate process could not be started.
3919    SpawnFailed,
3920    /// The candidate did not register within the readiness budget.
3921    NeverRegistered,
3922    /// The candidate registered but did not declare itself ready in time.
3923    NeverReady,
3924    /// The candidate exited before cutover.
3925    CandidateExited,
3926    /// The candidate declared itself ready but failed its health probe.
3927    CandidateUnhealthy,
3928    /// An operator stop, disable or retire arrived while the candidate warmed.
3929    /// The candidate was killed and the operator's command then carried out on
3930    /// the incumbent.
3931    Interrupted,
3932    /// The candidate's connection closed at the moment of cutover. If it
3933    /// closed before forwarding moved, the incumbent is untouched. If it closed
3934    /// between the forwarding and registry halves of cutover, forwarding can no
3935    /// longer route to the incumbent, so the module is restarted plainly.
3936    CutoverLost,
3937}
3938
3939impl SwapFailureArm {
3940    pub fn as_str(self) -> &'static str {
3941        match self {
3942            Self::SpawnFailed => "spawn_failed",
3943            Self::NeverRegistered => "never_registered",
3944            Self::NeverReady => "never_ready",
3945            Self::CandidateExited => "candidate_exited",
3946            Self::CandidateUnhealthy => "candidate_unhealthy",
3947            Self::Interrupted => "interrupted",
3948            Self::CutoverLost => "cutover_lost",
3949        }
3950    }
3951}
3952
3953impl fmt::Display for SuperviseError {
3954    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3955        match self {
3956            Self::InvalidSpec { reason } => write!(f, "invalid module spec: {reason}"),
3957            Self::Spawn {
3958                program,
3959                source,
3960                cgroup_path: Some(cgroup_path),
3961            } => write!(
3962                f,
3963                "failed to place module in cgroup '{}' while spawning '{}': {source}",
3964                cgroup_path.display(),
3965                program.display()
3966            ),
3967            Self::Spawn {
3968                program,
3969                source,
3970                cgroup_path: None,
3971            } => write!(
3972                f,
3973                "failed to spawn module '{}': {source}",
3974                program.display()
3975            ),
3976            Self::Cgroup { module_id, source } => {
3977                write!(
3978                    f,
3979                    "failed to prepare cgroup for module '{module_id}': {source}"
3980                )
3981            }
3982            Self::LaunchNonce { reason } => {
3983                write!(
3984                    f,
3985                    "failed to generate reserved-module launch nonce: {reason}"
3986                )
3987            }
3988            Self::Wait { module_id, source } => {
3989                write!(f, "failed to wait for module '{module_id}': {source}")
3990            }
3991            Self::Kill { module_id, source } => {
3992                write!(f, "failed to kill module '{module_id}': {source}")
3993            }
3994            Self::Forwarding(err) => write!(f, "forwarding error: {err}"),
3995            Self::Registry(err) => write!(f, "registry error: {err}"),
3996            Self::ReloadUnavailable { module_id, reason } => {
3997                write!(f, "reload unavailable for module '{module_id}': {reason}")
3998            }
3999            Self::Disabled { module_id } => {
4000                write!(
4001                    f,
4002                    "module '{module_id}' is disabled; enable it before restart or reload"
4003                )
4004            }
4005            Self::ReloadFailed { module_id, reason } => {
4006                write!(f, "reload failed for module '{module_id}': {reason}")
4007            }
4008            Self::RegistrationStillActive { module_id, waited } => write!(
4009                f,
4010                "module '{module_id}' registration remained active after waiting {waited:?}"
4011            ),
4012            Self::StatePoisoned { module_id } => match module_id {
4013                Some(module_id) => {
4014                    write!(f, "supervisor state for module '{module_id}' was poisoned")
4015                }
4016                None => write!(f, "supervisor state was poisoned"),
4017            },
4018            Self::CommandClosed { module_id } => {
4019                write!(
4020                    f,
4021                    "supervisor command channel for module '{module_id}' is closed"
4022                )
4023            }
4024            Self::SwapInProgress { module_id } => write!(
4025                f,
4026                "module '{module_id}' is being swapped; retry once the swap has cut over or failed, or stop the module to abort the swap"
4027            ),
4028            Self::SwapRefused { module_id, reason } => match reason {
4029                SwapRefusal::OverlapExclusive => write!(
4030                    f,
4031                    "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"
4032                ),
4033                SwapRefusal::NotRegistered => write!(
4034                    f,
4035                    "module '{module_id}' is not registered, so there is no serving process to keep while a replacement warms; use a plain restart"
4036                ),
4037                SwapRefusal::ProtocolNone => write!(
4038                    f,
4039                    "module '{module_id}' is protocol: \"none\" and never registers, so a swap could never see its replacement become ready; use a plain restart"
4040                ),
4041                SwapRefusal::NotConfigured => write!(
4042                    f,
4043                    "module '{module_id}' cannot be swapped: the supervisor was built without the forwarding table or shared handle a swap needs"
4044                ),
4045                SwapRefusal::AlreadySwapping => {
4046                    write!(f, "module '{module_id}' is already being swapped")
4047                }
4048            },
4049            Self::SwapFailed {
4050                module_id,
4051                arm,
4052                detail,
4053                ..
4054            } => write!(
4055                f,
4056                "swap of module '{module_id}' failed ({}): {detail}; the running process was left serving",
4057                arm.as_str()
4058            ),
4059        }
4060    }
4061}
4062
4063impl Error for SuperviseError {
4064    fn source(&self) -> Option<&(dyn Error + 'static)> {
4065        match self {
4066            Self::Spawn { source, .. }
4067            | Self::Cgroup { source, .. }
4068            | Self::Wait { source, .. }
4069            | Self::Kill { source, .. } => Some(source),
4070            Self::Forwarding(err) => Some(err),
4071            Self::Registry(err) => Some(err),
4072            Self::LaunchNonce { .. }
4073            | Self::InvalidSpec { .. }
4074            | Self::ReloadUnavailable { .. }
4075            | Self::Disabled { .. }
4076            | Self::ReloadFailed { .. }
4077            | Self::RegistrationStillActive { .. }
4078            | Self::StatePoisoned { .. }
4079            | Self::CommandClosed { .. }
4080            | Self::SwapInProgress { .. }
4081            | Self::SwapRefused { .. }
4082            | Self::SwapFailed { .. } => None,
4083        }
4084    }
4085}
4086
4087pub(crate) fn validate_spec(spec: &ModuleSpec) -> Result<(), SuperviseError> {
4088    if spec.module_id.trim().is_empty() {
4089        return Err(SuperviseError::InvalidSpec {
4090            reason: "module_id must not be empty".to_string(),
4091        });
4092    }
4093
4094    Ok(())
4095}
4096
4097#[derive(Debug, Default)]
4098struct HealthProbeRuntime {
4099    configured_health: Option<HealthConfig>,
4100    registered_connection: Option<crate::ConnectionId>,
4101    advertised: bool,
4102    next_probe_at: Option<Instant>,
4103    probe_index: u64,
4104}
4105
4106fn running_protocol(spec: &ModuleSpec, snapshot: &SharedSnapshot) -> ModuleProtocol {
4107    lock_snapshot(snapshot)
4108        .ok()
4109        .and_then(|state| state.spawned_protocol)
4110        .unwrap_or(spec.protocol)
4111}
4112
4113impl HealthProbeRuntime {
4114    fn refresh_registration(
4115        &mut self,
4116        spec: &ModuleSpec,
4117        runtime: &SupervisorRuntimeConfig,
4118        registry: &Registry,
4119        snapshot: &SharedSnapshot,
4120    ) {
4121        if self.configured_health.as_ref() != Some(&runtime.health) {
4122            self.configured_health = Some(runtime.health.clone());
4123            self.next_probe_at = None;
4124            self.registered_connection = None;
4125            self.probe_index = 0;
4126        }
4127        // A non-wire process never registers. Only an explicitly configured
4128        // HTTP endpoint can arm its health probe; an absent HELLO is not a
4129        // health failure for that kind of process.
4130        if running_protocol(spec, snapshot) == ModuleProtocol::None {
4131            self.registered_connection = None;
4132            self.advertised = runtime.health.http.is_some();
4133            if !self.advertised {
4134                self.next_probe_at = None;
4135                let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4136                    let unknown = ModuleHealthStatus::default();
4137                    if state.health != unknown {
4138                        state.health = unknown;
4139                    }
4140                });
4141            } else if self.next_probe_at.is_none() {
4142                self.next_probe_at = Some(
4143                    Instant::now()
4144                        + jittered_health_delay(&spec.module_id, 0, runtime.health.cadence),
4145                );
4146            }
4147            return;
4148        }
4149
4150        let registration = match registry.get_module(&spec.module_id) {
4151            Ok(registration) => registration,
4152            Err(err) => {
4153                warn!(module_id = %spec.module_id, error = %err, "health prober could not read registry");
4154                self.advertised = false;
4155                self.next_probe_at = None;
4156                return;
4157            }
4158        };
4159
4160        let Some(registration) = registration else {
4161            self.registered_connection = None;
4162            self.advertised = false;
4163            self.next_probe_at = None;
4164            return;
4165        };
4166
4167        let advertised = registration
4168            .control_ops
4169            .iter()
4170            .any(|op| op == MODULE_CONTROL_OP_HEALTH_CHECK);
4171        if !advertised {
4172            self.registered_connection = Some(registration.connection_id);
4173            self.advertised = false;
4174            self.next_probe_at = None;
4175            let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4176                if state.health.status != SupervisorHealthStatus::Unknown
4177                    || state.health.consecutive_failures != 0
4178                    || state.health.last_probe_ms.is_some()
4179                    || state.health.detail.is_some()
4180                    || state.health.metrics.is_some()
4181                {
4182                    state.health.status = SupervisorHealthStatus::Unknown;
4183                    state.health.consecutive_failures = 0;
4184                    state.health.last_probe_ms = None;
4185                    state.health.detail = None;
4186                    state.health.metrics = None;
4187                }
4188            });
4189            return;
4190        }
4191
4192        let reregistered = self.registered_connection != Some(registration.connection_id);
4193        self.registered_connection = Some(registration.connection_id);
4194        self.advertised = true;
4195        if reregistered || self.next_probe_at.is_none() {
4196            self.probe_index = 0;
4197            self.next_probe_at = Some(
4198                Instant::now() + jittered_health_delay(&spec.module_id, 0, runtime.health.cadence),
4199            );
4200            let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4201                state.health.status = SupervisorHealthStatus::Unknown;
4202                state.health.consecutive_failures = 0;
4203                state.health.detail = None;
4204                state.health.metrics = None;
4205            });
4206        }
4207    }
4208
4209    fn wake_after(&self) -> Option<Duration> {
4210        if !self.advertised {
4211            return None;
4212        }
4213        self.next_probe_at
4214            .map(|next| next.saturating_duration_since(Instant::now()))
4215    }
4216
4217    fn due(&self) -> bool {
4218        self.advertised
4219            && self
4220                .next_probe_at
4221                .is_some_and(|next| Instant::now() >= next)
4222    }
4223
4224    fn schedule_next(&mut self, spec: &ModuleSpec, cadence: Duration) {
4225        self.probe_index = self.probe_index.wrapping_add(1);
4226        self.next_probe_at = Some(
4227            Instant::now() + jittered_health_delay(&spec.module_id, self.probe_index, cadence),
4228        );
4229    }
4230}
4231
4232/// What a failed health probe actually OBSERVED, kept apart from how it reads.
4233///
4234/// This was a struct with a single `message: String`, and every one of the
4235/// fifteen construction sites collapsed into it. Each site knows exactly what it
4236/// saw -- the lane is gone, the module did not answer in time, the module
4237/// answered with the wrong thing -- and `handle_health_probe_failure` then
4238/// treated all of them identically: increment a counter, compare to a threshold,
4239/// restart the module. THE DISTINCTION EXISTED AT EVERY CALL SITE AND WAS
4240/// DESTROYED BEFORE THE DECISION THAT NEEDED IT.
4241///
4242/// The distinction that matters is not severity, it is EVIDENTIAL WEIGHT:
4243///
4244/// * `LaneDead` is PROOF. The module's control connection is gone; nothing will
4245///   answer on it again.
4246/// * `NoAnswer` is ABSENCE OF EVIDENCE. It is consistent with a wedged module
4247///   AND with a perfectly healthy one that lost a CPU race -- which is what
4248///   happens under machine load, and is how this supervisor killed a healthy
4249///   module three times in one day.
4250/// * `BadAnswer` proves the module is ALIVE. It replied; the reply was wrong.
4251///   Restarting on it is defensible, but it is not the silence case and should
4252///   never be counted as one.
4253/// * `Misconfigured` is a daemon-side fault. The module has not been asked
4254///   anything, so it cannot be evidence about the module at all.
4255///
4256/// The asymmetry is the whole point: under saturation the WEAKEST signal is the
4257/// one that fires most often, and while every variant collapsed into one string
4258/// it carried the same weight as the strongest.
4259///
4260/// LIVE BEHAVIOUR TODAY, stated here because this doc block describes the
4261/// DESIGN and a reader stopping at it gets the build backwards: the restart
4262/// decision does NOT yet consult this classification -- consecutive `NoAnswer`
4263/// probes still increment the failure streak and drive escalation at the
4264/// threshold (see `is_proof_of_death` below for why that is deliberate and
4265/// what gates the change). Absence of evidence restarts modules today.
4266#[derive(Debug)]
4267enum HealthProbeEvidence {
4268    /// The module's control lane is gone. Proof of death.
4269    LaneDead,
4270    /// No reply within the deadline. Proves nothing about the module's state.
4271    NoAnswer,
4272    /// The module replied, but not with a usable health report. Proves it is alive.
4273    BadAnswer,
4274    /// The daemon could not ask. Says nothing about the module.
4275    Misconfigured,
4276}
4277
4278#[derive(Debug)]
4279struct HealthProbeError {
4280    evidence: HealthProbeEvidence,
4281    message: String,
4282}
4283
4284impl HealthProbeError {
4285    fn lane_dead(message: impl Into<String>) -> Self {
4286        Self::with(HealthProbeEvidence::LaneDead, message)
4287    }
4288
4289    fn no_answer(message: impl Into<String>) -> Self {
4290        Self::with(HealthProbeEvidence::NoAnswer, message)
4291    }
4292
4293    fn bad_answer(message: impl Into<String>) -> Self {
4294        Self::with(HealthProbeEvidence::BadAnswer, message)
4295    }
4296
4297    fn misconfigured(message: impl Into<String>) -> Self {
4298        Self::with(HealthProbeEvidence::Misconfigured, message)
4299    }
4300
4301    fn with(evidence: HealthProbeEvidence, message: impl Into<String>) -> Self {
4302        Self {
4303            evidence,
4304            message: message.into(),
4305        }
4306    }
4307
4308    /// Whether this observation is proof the module cannot serve.
4309    ///
4310    /// Only `LaneDead` qualifies. `NoAnswer` is deliberately excluded: it is the
4311    /// variant that fires under CPU starvation, and treating it as proof is the
4312    /// defect this enum exists to make impossible to reintroduce silently.
4313    ///
4314    /// NOT YET CONSULTED BY THE RESTART DECISION, deliberately. Requiring proof
4315    /// to restart also needs a bound for the case it excludes -- a genuinely
4316    /// wedged module, alive but never answering -- and that bound must come from
4317    /// the distribution of real late-answer latencies, which nothing measures
4318    /// yet. Landing the classification first makes the later change a one-line
4319    /// decision against evidence that already exists, rather than two unproven
4320    /// changes at once.
4321    #[allow(dead_code)]
4322    fn is_proof_of_death(&self) -> bool {
4323        matches!(self.evidence, HealthProbeEvidence::LaneDead)
4324    }
4325
4326    /// Short stable label for logs and the health snapshot.
4327    ///
4328    /// An operator reading `ck health` currently cannot tell "the module is gone"
4329    /// from "the module did not answer in five seconds", because both render as
4330    /// prose in the same field. These labels are what make the two
4331    /// distinguishable at a glance, and they are what a later restart-policy
4332    /// change will be argued from.
4333    fn label(&self) -> &'static str {
4334        match self.evidence {
4335            HealthProbeEvidence::LaneDead => "lane-dead",
4336            HealthProbeEvidence::NoAnswer => "no-answer",
4337            HealthProbeEvidence::BadAnswer => "bad-answer",
4338            HealthProbeEvidence::Misconfigured => "daemon-misconfigured",
4339        }
4340    }
4341}
4342
4343impl fmt::Display for HealthProbeError {
4344    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4345        f.write_str(&self.message)
4346    }
4347}
4348
4349async fn run_health_probe_cycle(
4350    spec: &ModuleSpec,
4351    runtime: &SupervisorRuntimeConfig,
4352    registry: &Registry,
4353    process_liveness: &SupervisorProcessLiveness,
4354    snapshot: &SharedSnapshot,
4355    child: &mut Option<SupervisedChild>,
4356) {
4357    let now_ms = unix_ms_now();
4358    let http = (running_protocol(spec, snapshot) == ModuleProtocol::None)
4359        .then_some(runtime.health.http.as_deref())
4360        .flatten();
4361    let result = match http {
4362        Some(url) => probe_http_health(url, runtime.health.deadline).await,
4363        None => probe_module_health(&spec.module_id, runtime, None).await,
4364    };
4365    match result {
4366        Ok(report) => {
4367            handle_health_report(
4368                spec,
4369                runtime,
4370                registry,
4371                process_liveness,
4372                snapshot,
4373                child,
4374                report,
4375                now_ms,
4376            )
4377            .await;
4378        }
4379        Err(err) => {
4380            if http.is_some() {
4381                let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4382                    state.health.status = SupervisorHealthStatus::Failing;
4383                });
4384            }
4385            handle_health_probe_failure(
4386                spec,
4387                runtime,
4388                registry,
4389                process_liveness,
4390                snapshot,
4391                child,
4392                err,
4393                now_ms,
4394            )
4395            .await;
4396        }
4397    }
4398}
4399
4400pub(crate) struct HttpProbeTarget<'a> {
4401    address: std::net::SocketAddr,
4402    localhost: bool,
4403    authority: &'a str,
4404    path: String,
4405}
4406
4407/// Resolve only literal loopback endpoints, without DNS, redirects, proxies,
4408/// or TLS. A URL cannot turn a local health check into an outbound connection.
4409pub(crate) fn parse_http_probe_url(url: &str) -> Result<HttpProbeTarget<'_>, String> {
4410    if url.bytes().any(|b| b <= b' ' || b == 127) || url.contains('#') {
4411        return Err("must not contain whitespace, controls, or a fragment".into());
4412    }
4413    let rest = url
4414        .strip_prefix("http://")
4415        .ok_or("must use plain http://")?;
4416    let split = rest.find(['/', '?']).unwrap_or(rest.len());
4417    let (authority, suffix) = rest.split_at(split);
4418    let (host, port) = if let Some(rest) = authority.strip_prefix("[::1]") {
4419        ("::1", rest)
4420    } else {
4421        let split = authority.find(':').unwrap_or(authority.len());
4422        authority.split_at(split)
4423    };
4424    let ip: std::net::IpAddr = match host {
4425        "127.0.0.1" | "localhost" => std::net::Ipv4Addr::LOCALHOST.into(),
4426        "::1" => std::net::Ipv6Addr::LOCALHOST.into(),
4427        _ => return Err("host must be 127.0.0.1, [::1], or localhost".into()),
4428    };
4429    let port = if port.is_empty() {
4430        80
4431    } else {
4432        port.strip_prefix(':')
4433            .and_then(|p| p.parse::<u16>().ok())
4434            .filter(|p| *p > 0)
4435            .ok_or("must have a valid nonzero TCP port")?
4436    };
4437    let path = if suffix.is_empty() {
4438        "/".into()
4439    } else if suffix.starts_with('?') {
4440        format!("/{suffix}")
4441    } else {
4442        suffix.into()
4443    };
4444    Ok(HttpProbeTarget {
4445        address: std::net::SocketAddr::new(ip, port),
4446        localhost: host == "localhost",
4447        authority,
4448        path,
4449    })
4450}
4451
4452async fn probe_http_health(
4453    url: &str,
4454    deadline: Duration,
4455) -> Result<HealthReport, HealthProbeError> {
4456    use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader};
4457    let target = parse_http_probe_url(url).map_err(HealthProbeError::misconfigured)?;
4458    // Keep partial diagnostics outside the timed future so cancellation does
4459    // not discard a status line or body bytes already received.
4460    let mut response_status = String::new();
4461    let mut body = Vec::new();
4462    let probe = async {
4463        // Resolve localhost ourselves so a hosts-file override cannot turn
4464        // this into an outbound request, while IPv6-only local servers work.
4465        let connection = match tokio::net::TcpStream::connect(target.address).await {
4466            Err(_) if target.localhost => {
4467                tokio::net::TcpStream::connect((
4468                    std::net::Ipv6Addr::LOCALHOST,
4469                    target.address.port(),
4470                ))
4471                .await
4472            }
4473            result => result,
4474        };
4475        let mut stream = connection.map_err(|error| {
4476            HealthProbeError::no_answer(format!("HTTP connect failed: {error}"))
4477        })?;
4478        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
4479            .map_err(|error| HealthProbeError::no_answer(format!("HTTP write failed: {error}")))?;
4480        let mut reader = BufReader::new(stream);
4481        let mut budget = 16 * 1024;
4482        let status = http_line(&mut reader, &mut budget).await?;
4483        let mut words = status.split_ascii_whitespace();
4484        let version = words.next();
4485        let code = words
4486            .next()
4487            .filter(|word| word.len() == 3)
4488            .and_then(|word| word.parse::<u16>().ok());
4489        if !matches!(version, Some("HTTP/1.1" | "HTTP/1.0"))
4490            || !code.is_some_and(|code| (100..600).contains(&code))
4491        {
4492            return Err(HealthProbeError::bad_answer(format!(
4493                "invalid HTTP status: {status}"
4494            )));
4495        }
4496        let code = code.expect("validated status code");
4497        response_status = status.clone();
4498        let mut length = None;
4499        let mut chunked = false;
4500        loop {
4501            let line = http_line(&mut reader, &mut budget).await?;
4502            if line.is_empty() {
4503                break;
4504            }
4505            if let Some((name, value)) = line.split_once(':') {
4506                if name.eq_ignore_ascii_case("content-length") {
4507                    length = Some(value.trim().parse::<u64>().map_err(|_| {
4508                        HealthProbeError::bad_answer("invalid HTTP Content-Length")
4509                    })?);
4510                } else if name.eq_ignore_ascii_case("transfer-encoding") {
4511                    chunked = value.trim().eq_ignore_ascii_case("chunked");
4512                }
4513            }
4514        }
4515        if chunked {
4516            while body.len() < 200 {
4517                let line = http_line(&mut reader, &mut budget).await?;
4518                let size = u64::from_str_radix(line.split(';').next().unwrap_or("").trim(), 16)
4519                    .map_err(|_| HealthProbeError::bad_answer("invalid HTTP chunk size"))?;
4520                if size == 0 {
4521                    break;
4522                }
4523                let count = size.min((200 - body.len()) as u64) as usize;
4524                let start = body.len();
4525                (&mut reader)
4526                    .take(count as u64)
4527                    .read_to_end(&mut body)
4528                    .await
4529                    .map_err(|error| {
4530                        HealthProbeError::no_answer(format!("HTTP body read failed: {error}"))
4531                    })?;
4532                if body.len() - start != count {
4533                    return Err(HealthProbeError::bad_answer("truncated HTTP chunk"));
4534                }
4535                if size > count as u64 || body.len() == 200 {
4536                    break;
4537                }
4538                if !http_line(&mut reader, &mut budget).await?.is_empty() {
4539                    return Err(HealthProbeError::bad_answer("invalid HTTP chunk delimiter"));
4540                }
4541            }
4542        } else {
4543            reader
4544                .take(length.unwrap_or(200).min(200))
4545                .read_to_end(&mut body)
4546                .await
4547                .map_err(|error| {
4548                    HealthProbeError::no_answer(format!("HTTP body read failed: {error}"))
4549                })?;
4550        }
4551        if (200..300).contains(&code) {
4552            Ok(HealthReport::ok())
4553        } else {
4554            Err(HealthProbeError::bad_answer(
4555                "HTTP health endpoint returned non-2xx",
4556            ))
4557        }
4558    };
4559    let mut result = timeout(deadline, probe).await.unwrap_or_else(|_| {
4560        Err(HealthProbeError::no_answer(format!(
4561            "HTTP probe timed out after {deadline:?}"
4562        )))
4563    });
4564    if let Err(error) = &mut result {
4565        if !response_status.is_empty() {
4566            error.message = format!(
4567                "{}; {response_status}: {}",
4568                error.message,
4569                String::from_utf8_lossy(&body)
4570            );
4571        }
4572    }
4573    result
4574}
4575
4576async fn http_line(
4577    reader: &mut tokio::io::BufReader<tokio::net::TcpStream>,
4578    remaining: &mut usize,
4579) -> Result<String, HealthProbeError> {
4580    use tokio::io::{AsyncBufReadExt, AsyncReadExt};
4581    let mut line = Vec::new();
4582    (&mut *reader)
4583        .take(*remaining as u64)
4584        .read_until(b'\n', &mut line)
4585        .await
4586        .map_err(|error| {
4587            HealthProbeError::no_answer(format!("HTTP header read failed: {error}"))
4588        })?;
4589    *remaining -= line.len();
4590    if !line.ends_with(b"\r\n") {
4591        return Err(HealthProbeError::bad_answer(
4592            "HTTP headers are incomplete or exceed 16 KiB",
4593        ));
4594    }
4595    line.truncate(line.len() - 2);
4596    String::from_utf8(line).map_err(|_| HealthProbeError::bad_answer("HTTP header is not UTF-8"))
4597}
4598
4599async fn probe_module_health(
4600    module_id: &str,
4601    runtime: &SupervisorRuntimeConfig,
4602    drain_deadline: Option<Instant>,
4603) -> Result<HealthReport, HealthProbeError> {
4604    let Some(forwarding) = runtime.forwarding.as_ref() else {
4605        return Err(HealthProbeError::misconfigured(
4606            "supervisor was not configured with a forwarding table",
4607        ));
4608    };
4609    let probe_started_at = Instant::now();
4610    let mut deadline = probe_started_at + runtime.health.deadline;
4611    if let Some(drain_deadline) = drain_deadline {
4612        deadline = deadline.min(drain_deadline);
4613    }
4614    let pending = if drain_deadline.is_some() {
4615        forwarding.begin_drain_health_probe_rpc_for(
4616            module_id,
4617            MODULE_CONTROL_OP_HEALTH_CHECK,
4618            probe_started_at,
4619            deadline,
4620        )
4621    } else {
4622        forwarding.begin_health_probe_rpc_for(
4623            module_id,
4624            MODULE_CONTROL_OP_HEALTH_CHECK,
4625            probe_started_at,
4626            deadline,
4627        )
4628    }
4629    .map_err(|err| {
4630        // The endpoint is not registered, so there is no live control lane to
4631        // ask. That is the module being absent, not slow.
4632        HealthProbeError::lane_dead(format!("failed to begin health.check RPC: {err}"))
4633    })?;
4634    await_health_probe(forwarding, pending, deadline, runtime.health.deadline).await
4635}
4636
4637/// [`probe_module_health`] for one endpoint rather than the id's active one.
4638///
4639/// A swap probes two processes that no by-id lookup reaches: its candidate
4640/// before cutover, and its superseded incumbent (for busy gauges) while the
4641/// incumbent drains. `deadline_cap` bounds the probe the way a drain deadline
4642/// bounds the by-id drain probe.
4643async fn probe_endpoint_health(
4644    endpoint: crate::ModuleEndpointId,
4645    runtime: &SupervisorRuntimeConfig,
4646    deadline_cap: Option<Instant>,
4647) -> Result<HealthReport, HealthProbeError> {
4648    let Some(forwarding) = runtime.forwarding.as_ref() else {
4649        return Err(HealthProbeError::misconfigured(
4650            "supervisor was not configured with a forwarding table",
4651        ));
4652    };
4653    let probe_started_at = Instant::now();
4654    let mut deadline = probe_started_at + runtime.health.deadline;
4655    if let Some(cap) = deadline_cap {
4656        deadline = deadline.min(cap);
4657    }
4658    let pending = forwarding
4659        .begin_endpoint_health_probe_rpc_for(
4660            endpoint,
4661            MODULE_CONTROL_OP_HEALTH_CHECK,
4662            probe_started_at,
4663            deadline,
4664        )
4665        .map_err(|err| {
4666            HealthProbeError::lane_dead(format!("failed to begin health.check RPC: {err}"))
4667        })?;
4668    await_health_probe(forwarding, pending, deadline, runtime.health.deadline).await
4669}
4670
4671/// Send a begun health probe and classify its answer.
4672async fn await_health_probe(
4673    forwarding: &ForwardingTable,
4674    pending: PendingModuleControlRpc,
4675    deadline: Instant,
4676    probe_budget: Duration,
4677) -> Result<HealthReport, HealthProbeError> {
4678    let PendingModuleControlRpc {
4679        endpoint,
4680        module_sink,
4681        negotiated_ver,
4682        corr,
4683        receiver,
4684    } = pending;
4685    let body = serde_json::to_vec(&ModuleControlRequest::HealthCheck {}).map_err(|err| {
4686        HealthProbeError::misconfigured(format!("failed to encode health.check: {err}"))
4687    })?;
4688    let frame = Frame::build_with_version(
4689        negotiated_ver,
4690        FrameType::Request,
4691        control_flags(),
4692        0,
4693        0,
4694        corr,
4695        body,
4696    )
4697    .map_err(|err| {
4698        HealthProbeError::misconfigured(format!("failed to build health.check frame: {err}"))
4699    })?;
4700
4701    // The enqueue itself must be bounded by the probe deadline: FrameSink.send
4702    // blocks waiting for capacity when the module's egress queue is full, and an
4703    // unbounded await here freezes the whole supervision actor (it stops polling
4704    // Child::wait and supervisor commands), making the module unrecoverable
4705    // in-band. On timeout the probe fails like any transport failure.
4706    match timeout_at(deadline, module_sink.send(frame)).await {
4707        Ok(Ok(())) => {}
4708        Ok(Err(err)) => {
4709            let _ = forwarding.cancel_module_control_rpc(endpoint, corr);
4710            // A closed sink means the module's egress channel is gone -- the
4711            // receiving half is dropped when its connection tears down. Proof.
4712            return Err(HealthProbeError::lane_dead(format!(
4713                "failed to send health.check: {err}"
4714            )));
4715        }
4716        Err(_elapsed) => {
4717            let _ = forwarding.cancel_module_control_rpc(endpoint, corr);
4718            // A full egress queue means the module is not draining its socket, which
4719            // is consistent with a wedged module AND with one whose reader is merely
4720            // starved. Silence, not proof.
4721            return Err(HealthProbeError::no_answer(
4722                "health.check send timed out before enqueue (module egress full)",
4723            ));
4724        }
4725    }
4726
4727    match timeout_at(deadline, receiver).await {
4728        // Each arm records WHAT WAS OBSERVED. Four of them are the module
4729        // demonstrably answering -- rejected, non-health, malformed, wrong op --
4730        // and those prove it is alive even though the probe failed.
4731        Ok(Ok(ModuleControlRpcOutcome::Response(response))) => {
4732            response.health_report().ok_or_else(|| {
4733                HealthProbeError::bad_answer("health.check RPC returned a non-health response")
4734            })
4735        }
4736        Ok(Ok(ModuleControlRpcOutcome::Rejected(body))) => Err(HealthProbeError::bad_answer(
4737            format!("health.check rejected: {}", body.message),
4738        )),
4739        Ok(Ok(ModuleControlRpcOutcome::ModuleGone(message))) => {
4740            Err(HealthProbeError::lane_dead(message))
4741        }
4742        Ok(Ok(ModuleControlRpcOutcome::MalformedResponse(message))) => {
4743            Err(HealthProbeError::bad_answer(message))
4744        }
4745        Ok(Ok(ModuleControlRpcOutcome::UnexpectedOp { expected, actual })) => {
4746            Err(HealthProbeError::bad_answer(format!(
4747                "expected module-control op '{expected}', got '{actual}'"
4748            )))
4749        }
4750        // A reply that crosses the deadline before this waiter observes it is
4751        // still proof of life. The forwarding path records its end-to-end latency
4752        // before delivering this classification.
4753        Ok(Ok(ModuleControlRpcOutcome::DeadlineElapsed)) => Err(HealthProbeError::bad_answer(
4754            "module answered health.check after its daemon deadline",
4755        )),
4756        Ok(Err(_)) => Err(HealthProbeError::misconfigured(
4757            "health.check waiter was canceled before the module responded",
4758        )),
4759        Err(_) => {
4760            let _ = forwarding.tombstone_health_probe_rpc(endpoint, corr);
4761            Err(HealthProbeError::no_answer(format!(
4762                "module did not answer health.check within {probe_budget:?}"
4763            )))
4764        }
4765    }
4766}
4767
4768#[allow(clippy::too_many_arguments)]
4769async fn handle_health_report(
4770    spec: &ModuleSpec,
4771    runtime: &SupervisorRuntimeConfig,
4772    registry: &Registry,
4773    process_liveness: &SupervisorProcessLiveness,
4774    snapshot: &SharedSnapshot,
4775    child: &mut Option<SupervisedChild>,
4776    report: HealthReport,
4777    now_ms: u64,
4778) {
4779    let status = supervisor_health_status(report.status);
4780    let detail = report.detail.clone();
4781    let metrics = truncate_health_metrics(report.metrics);
4782    let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4783        state.health.status = status;
4784        state.health.last_probe_ms = Some(now_ms);
4785        state.health.detail = detail.clone();
4786        state.health.metrics = metrics.clone();
4787        state.health.consecutive_failures = 0;
4788    });
4789
4790    let action = match report.status {
4791        HealthStatus::Ok => return,
4792        HealthStatus::Degraded => runtime.health.on_degraded,
4793        HealthStatus::Failing => runtime.health.on_failing,
4794    };
4795    apply_l3_health_action(
4796        spec,
4797        runtime,
4798        registry,
4799        process_liveness,
4800        snapshot,
4801        child,
4802        status,
4803        detail.as_deref(),
4804        action,
4805        now_ms,
4806    )
4807    .await;
4808}
4809
4810#[allow(clippy::too_many_arguments)]
4811async fn handle_health_probe_failure(
4812    spec: &ModuleSpec,
4813    runtime: &SupervisorRuntimeConfig,
4814    registry: &Registry,
4815    process_liveness: &SupervisorProcessLiveness,
4816    snapshot: &SharedSnapshot,
4817    child: &mut Option<SupervisedChild>,
4818    err: HealthProbeError,
4819    now_ms: u64,
4820) {
4821    let threshold = runtime.health.failure_threshold.max(1);
4822    let mut failures = 0;
4823    // Carry the evidence class into the operator-visible detail. Without it,
4824    // "module did not answer within 5s" and "the control lane is gone" are two
4825    // prose strings in the same field, and the reader has to know the codebase to
4826    // tell which one is proof of anything.
4827    let detail = format!("[{}] {err}", err.label());
4828    let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4829        // A failed wire probe invalidates the last report, even before the
4830        // restart threshold. HTTP probes already mark failures as Failing.
4831        if state.spawned_protocol.unwrap_or(spec.protocol) == ModuleProtocol::Subc {
4832            state.health.status = SupervisorHealthStatus::Unknown;
4833        }
4834        state.health.last_probe_ms = Some(now_ms);
4835        state.health.consecutive_failures = state.health.consecutive_failures.saturating_add(1);
4836        state.health.detail = Some(detail.clone());
4837        state.health.metrics = None;
4838        failures = state.health.consecutive_failures;
4839    });
4840
4841    if failures < threshold {
4842        warn!(
4843            module_id = %spec.module_id,
4844            consecutive_failures = failures,
4845            threshold,
4846            evidence = err.label(),
4847            detail = %detail,
4848            "health.check probe failed"
4849        );
4850        return;
4851    }
4852
4853    let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4854        state.state = ModuleState::Unresponsive;
4855        state.health.status = SupervisorHealthStatus::Unresponsive;
4856    });
4857    // The evidence class is logged at the kill site because this is the line an
4858    // operator reads after an unexplained restart. A streak of `no-answer` under
4859    // machine load is the known false-positive shape; a `lane-dead` is not.
4860    if runtime.health.critical {
4861        error!(
4862            module_id = %spec.module_id,
4863            status = "unresponsive",
4864            evidence = err.label(),
4865            detail = %detail,
4866            "critical module health alert"
4867        );
4868    } else {
4869        warn!(
4870            module_id = %spec.module_id,
4871            status = "unresponsive",
4872            evidence = err.label(),
4873            detail = %detail,
4874            "module health threshold breached"
4875        );
4876    }
4877    if let Err(err) = health_restart_child(
4878        spec,
4879        runtime,
4880        registry,
4881        process_liveness,
4882        snapshot,
4883        child,
4884        SupervisorHealthStatus::Unresponsive,
4885        Some(&detail),
4886        now_ms,
4887    )
4888    .await
4889    {
4890        error!(module_id = %spec.module_id, error = %err, "health-triggered restart failed");
4891    }
4892}
4893
4894#[allow(clippy::too_many_arguments)]
4895async fn apply_l3_health_action(
4896    spec: &ModuleSpec,
4897    runtime: &SupervisorRuntimeConfig,
4898    registry: &Registry,
4899    process_liveness: &SupervisorProcessLiveness,
4900    snapshot: &SharedSnapshot,
4901    child: &mut Option<SupervisedChild>,
4902    status: SupervisorHealthStatus,
4903    detail: Option<&str>,
4904    action: HealthAction,
4905    now_ms: u64,
4906) {
4907    record_health_action(snapshot, &spec.module_id, action.to_string(), now_ms);
4908    match action {
4909        HealthAction::Report => {
4910            info!(
4911                module_id = %spec.module_id,
4912                status = ?status,
4913                detail,
4914                "module reported non-ok health"
4915            );
4916        }
4917        HealthAction::Alert => {
4918            error!(
4919                module_id = %spec.module_id,
4920                status = ?status,
4921                detail,
4922                "module health alert"
4923            );
4924        }
4925        HealthAction::Restart => {
4926            if let Err(err) = health_restart_child(
4927                spec,
4928                runtime,
4929                registry,
4930                process_liveness,
4931                snapshot,
4932                child,
4933                status,
4934                detail,
4935                now_ms,
4936            )
4937            .await
4938            {
4939                error!(module_id = %spec.module_id, error = %err, "health-triggered restart failed");
4940            }
4941        }
4942    }
4943}
4944
4945#[allow(clippy::too_many_arguments)]
4946async fn health_restart_child(
4947    spec: &ModuleSpec,
4948    runtime: &SupervisorRuntimeConfig,
4949    registry: &Registry,
4950    process_liveness: &SupervisorProcessLiveness,
4951    snapshot: &SharedSnapshot,
4952    child: &mut Option<SupervisedChild>,
4953    status: SupervisorHealthStatus,
4954    detail: Option<&str>,
4955    now_ms: u64,
4956) -> Result<(), SuperviseError> {
4957    let (enabled, schedule) = {
4958        let mut state = lock_snapshot(snapshot)?;
4959        let enabled = state.enabled;
4960        let schedule = if enabled {
4961            state.next_crash_restart(&runtime.restart_policy, Instant::now())
4962        } else {
4963            None
4964        };
4965        (enabled, schedule)
4966    };
4967
4968    if !enabled {
4969        return Err(SuperviseError::Disabled {
4970            module_id: spec.module_id.clone(),
4971        });
4972    }
4973
4974    if schedule.is_none() {
4975        record_health_action(snapshot, &spec.module_id, "failed".to_string(), now_ms);
4976        error!(
4977            module_id = %spec.module_id,
4978            status = ?status,
4979            detail,
4980            max_restarts = runtime.restart_policy.max_restarts,
4981            window_secs = runtime.restart_policy.window.as_secs(),
4982            reason = %runtime.restart_policy.budget_exhausted_detail(),
4983            "health restart budget exhausted; marking module failed"
4984        );
4985        let stop_notice = begin_forwarding_drain_if_configured(
4986            spec,
4987            runtime,
4988            registry,
4989            snapshot,
4990            Some(true),
4991            RouteCloseReason::Disable,
4992        )
4993        .await?;
4994        update_snapshot(snapshot, Some(&spec.module_id), |state| {
4995            state.drain_disposition_detail = Some(runtime.restart_policy.budget_exhausted_detail());
4996        })?;
4997        drain_optional_child(
4998            &spec.module_id,
4999            spec.protocol,
5000            stop_notice,
5001            registry,
5002            runtime.forwarding.as_deref(),
5003            snapshot,
5004            &runtime.terminal_ring,
5005            &runtime.spawn_events,
5006            child,
5007            runtime.drain_timeout,
5008            ModuleState::Failed,
5009            Some(true),
5010        )
5011        .await?;
5012        process_liveness.untrack_if_current(&spec.module_id, snapshot);
5013        return Ok(());
5014    }
5015
5016    let schedule = schedule.expect("a health restart must have a crash-restart schedule");
5017    let mut restart_count = 0;
5018    update_snapshot(snapshot, Some(&spec.module_id), |state| {
5019        restart_count = state.crash_restarts.len();
5020        state.state = ModuleState::Unresponsive;
5021        state.health.status = status;
5022        state.health.last_action = Some(HealthAction::Restart.to_string());
5023        state.health.last_action_ms = Some(now_ms);
5024    })?;
5025    warn!(
5026        module_id = %spec.module_id,
5027        status = ?status,
5028        detail,
5029        restart_count,
5030        restart_in_window = schedule.restart_in_window,
5031        delay_ms = schedule.delay.as_millis() as u64,
5032        "health-triggered module restart"
5033    );
5034
5035    let stop_notice = begin_forwarding_drain_if_configured(
5036        spec,
5037        runtime,
5038        registry,
5039        snapshot,
5040        Some(true),
5041        RouteCloseReason::Restart,
5042    )
5043    .await?;
5044    drain_optional_child(
5045        &spec.module_id,
5046        spec.protocol,
5047        stop_notice,
5048        registry,
5049        runtime.forwarding.as_deref(),
5050        snapshot,
5051        &runtime.terminal_ring,
5052        &runtime.spawn_events,
5053        child,
5054        runtime.drain_timeout,
5055        ModuleState::Restarting,
5056        Some(true),
5057    )
5058    .await?;
5059    schedule_respawn(
5060        runtime,
5061        snapshot,
5062        &spec.module_id,
5063        schedule.delay,
5064        RespawnKind::Spawn,
5065    )
5066}
5067
5068fn cancel_deferred_reload(runtime: &SupervisorRuntimeConfig, module_id: &str, reason: &str) {
5069    if let Some(reply) = runtime
5070        .deferred_reload_reply
5071        .lock()
5072        .unwrap_or_else(|p| p.into_inner())
5073        .take()
5074    {
5075        let _ = reply.send(Err(SuperviseError::ReloadFailed {
5076            module_id: module_id.to_string(),
5077            reason: reason.to_string(),
5078        }));
5079    }
5080}
5081
5082fn schedule_respawn(
5083    runtime: &SupervisorRuntimeConfig,
5084    snapshot: &SharedSnapshot,
5085    module_id: &str,
5086    delay: Duration,
5087    kind: RespawnKind,
5088) -> Result<(), SuperviseError> {
5089    cancel_deferred_reload(runtime, module_id, "respawn superseded by another restart");
5090    update_snapshot(snapshot, Some(module_id), |state| {
5091        state.respawn_pending = true
5092    })?;
5093    *runtime
5094        .scheduled_respawn
5095        .lock()
5096        .unwrap_or_else(|p| p.into_inner()) = Some(PendingRespawn {
5097        deadline: Instant::now() + delay,
5098        kind,
5099    });
5100    Ok(())
5101}
5102
5103fn record_health_action(snapshot: &SharedSnapshot, module_id: &str, action: String, now_ms: u64) {
5104    let _ = update_snapshot(snapshot, Some(module_id), |state| {
5105        state.health.last_action = Some(action);
5106        state.health.last_action_ms = Some(now_ms);
5107    });
5108}
5109
5110fn supervisor_health_status(status: HealthStatus) -> SupervisorHealthStatus {
5111    match status {
5112        HealthStatus::Ok => SupervisorHealthStatus::Ok,
5113        HealthStatus::Degraded => SupervisorHealthStatus::Degraded,
5114        HealthStatus::Failing => SupervisorHealthStatus::Failing,
5115    }
5116}
5117
5118/// Caps the metrics blob stored in the cached supervisor snapshot, which is
5119/// returned to every `supervisor.list` and `supervisor.health` caller.
5120///
5121/// This cap is deliberately NOT applied on the one-shot `supervisor.health_probe`
5122/// path: that request exists to return a module's complete metrics object, and
5123/// `ck health <module-id>` documents it as the way to see what the cached view
5124/// truncates. The asymmetry is the feature.
5125///
5126/// So a new caller must decide which side it is on rather than assume the cap is
5127/// universal. Reaching for it on a fresh-probe path would silently reintroduce
5128/// the truncation that path exists to avoid.
5129fn truncate_health_metrics(metrics: Option<Value>) -> Option<Value> {
5130    let metrics = metrics?;
5131    match serde_json::to_vec(&metrics) {
5132        Ok(encoded) if encoded.len() > MAX_HEALTH_METRICS_BYTES => Some(serde_json::json!({
5133            "truncated": true,
5134            "original_bytes": encoded.len(),
5135        })),
5136        Ok(_) | Err(_) => Some(metrics),
5137    }
5138}
5139
5140/// Spread health probes so a fleet-wide restart does not converge them.
5141///
5142/// The delay is derived from the module id and probe index rather than a random
5143/// source, so it is deterministic per module: a module keeps its own offset
5144/// across daemon restarts instead of re-rolling into a collision.
5145fn jittered_health_delay(module_id: &str, probe_index: u64, cadence: Duration) -> Duration {
5146    if cadence.is_zero() {
5147        return Duration::ZERO;
5148    }
5149    let cadence_ms = cadence.as_millis() as u64;
5150    // This early return is REDUNDANT, deliberately, and a mutation run will show
5151    // it surviving removal. Recording why here so the next person to notice does
5152    // not have to re-derive it:
5153    //
5154    // - It is unreachable in practice. `positive_millis` in daemon_config rejects
5155    //   a zero cadence and builds the Duration from whole milliseconds, so a
5156    //   sub-millisecond cadence cannot come from config.
5157    // - Even if reached it changes no answer. The `.max(1)` below makes the span
5158    //   1, and `hash % 1` is 0, so the fall-through returns `cadence` unchanged
5159    //   -- exactly what this returns.
5160    //
5161    // Kept as a guard against a future widening of the config parser (accepting
5162    // microseconds, say), which would make the sub-millisecond case reachable.
5163    // The `.max(1)` is the load-bearing half TODAY: remove it and the modulo
5164    // divides by zero. Remove this and nothing changes.
5165    if cadence_ms == 0 {
5166        return cadence;
5167    }
5168    // Note that this never returns less than one cadence, including for the FIRST
5169    // probe. So a freshly registered module reports health `unknown` for a full
5170    // cadence plus jitter -- 30-33s at the default -- no matter how quickly it is
5171    // ready to answer.
5172    //
5173    // That is a property of the supervisor's schedule, not of any module: an
5174    // operator watching a restart sees `unknown` and cannot tell it from a module
5175    // that is slow to warm. Measured on two unrelated modules, both flipping to
5176    // `ok` between 22s and 32s after restart.
5177    //
5178    // Left as-is because spreading the first probe is what keeps a fleet-wide
5179    // restart from firing fourteen simultaneous probes into a cold machine. The
5180    // alternative -- probe at t+0 and jitter only from the second onward -- trades
5181    // that thundering herd for a faster first reading.
5182    let jitter_span = (cadence_ms / 10).max(1);
5183    let hash = module_id.as_bytes().iter().fold(
5184        probe_index.wrapping_mul(0x9E37_79B9_7F4A_7C15),
5185        |acc, byte| {
5186            acc.wrapping_mul(1099511628211)
5187                .wrapping_add(u64::from(*byte))
5188        },
5189    );
5190    cadence + Duration::from_millis(hash % jitter_span)
5191}
5192
5193#[cfg(test)]
5194mod tests {
5195    use super::*;
5196
5197    #[test]
5198    fn readding_a_module_clears_its_rescan_removal_tombstone() {
5199        let handle = SupervisorHandle::new();
5200        let module_id = "readded-tombstone";
5201        handle.record_rescan_removal(module_id);
5202        assert!(handle.removal_tombstone_age_ms(module_id).is_some());
5203
5204        handle.apply_identity_configuration(&ModuleSpec {
5205            module_id: module_id.to_string(),
5206            program: PathBuf::from("/test/module"),
5207            args: Vec::new(),
5208            env: Vec::new(),
5209            reserved: false,
5210            reserved_prefixes: Vec::new(),
5211            protocol: ModuleProtocol::Subc,
5212            overlap: Default::default(),
5213        });
5214
5215        assert!(
5216            handle.removal_tombstone_age_ms(module_id).is_none(),
5217            "a re-added module must not retain a stale removal tombstone"
5218        );
5219    }
5220
5221    /// What one module's owner looked like from the control plane at the
5222    /// instant after its first process was spawned.
5223    #[derive(Debug, PartialEq, Eq)]
5224    struct OwnerInSpawnWindow {
5225        module_id: String,
5226        configured: bool,
5227        on_roster: bool,
5228        admission_refusal: Option<&'static str>,
5229    }
5230
5231    /// A supervised module's process can connect, register, sync its scopes
5232    /// and describe them as soon as it is spawned, which is BEFORE the
5233    /// supervisor puts the module on the roster. In that window the owner must
5234    /// already read as configured, so a scoped `route.open` against it is
5235    /// refused as retryable `scope_not_synced` and not as terminal
5236    /// `scope_not_live` ("will never sync").
5237    ///
5238    /// The hook runs in exactly that window on every path that takes on a new
5239    /// module, so no race with a real child is needed: `on_roster: false`
5240    /// proves each observation was taken before the roster insert.
5241    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5242    async fn a_new_module_is_configured_before_its_first_process_can_register() {
5243        use crate::scopes::ScopeTable;
5244        use subc_protocol::{error_codes, scope::ScopeSelector, Principal};
5245
5246        let dir = cortexkit_test_support::ScratchDir::new("configured-before-spawn");
5247        let stub = |module_id: &str, program: PathBuf| ModuleSpec {
5248            module_id: module_id.to_string(),
5249            program,
5250            args: Vec::new(),
5251            env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
5252                .into_iter()
5253                .map(|key| (key.to_string(), dir.path().display().to_string()))
5254                .chain([("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string())])
5255                .collect(),
5256            reserved: false,
5257            reserved_prefixes: Vec::new(),
5258            protocol: ModuleProtocol::Subc,
5259            overlap: Default::default(),
5260        };
5261        let live = super::terminal_history_tests::fake_aft_stub_path();
5262        let missing = dir.path().join("definitely-missing-module");
5263
5264        let handle = SupervisorHandle::new();
5265        let mut supervisor =
5266            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5267                .with_handle(handle.clone());
5268        let observed = Arc::new(Mutex::new(Vec::<OwnerInSpawnWindow>::new()));
5269        let hook_handle = handle.clone();
5270        let hook_observed = Arc::clone(&observed);
5271        supervisor.test_after_first_spawn = AfterFirstSpawnHook(Some(Arc::new(move |module_id| {
5272            // Exactly what the control plane computes for a scoped route.open
5273            // naming this module as the owner of a scope it has not synced.
5274            let configured = hook_handle.is_configured(module_id);
5275            let selector = ScopeSelector {
5276                owner: Principal::Reserved {
5277                    module_id: module_id.to_string(),
5278                },
5279                scope_ref: "s".to_string(),
5280                scope_epoch: Some(1),
5281            };
5282            let carrier = Principal::Reserved {
5283                module_id: "carrier".to_string(),
5284            };
5285            let admission_refusal = match ScopeTable::new(Vec::<String>::new())
5286                .admit(&carrier, module_id, &selector, configured)
5287            {
5288                Ok(_) => None,
5289                Err(refusal) => Some(refusal.code),
5290            };
5291            hook_observed.lock().unwrap().push(OwnerInSpawnWindow {
5292                module_id: module_id.to_string(),
5293                configured,
5294                on_roster: hook_handle.get(module_id).is_some(),
5295                admission_refusal,
5296            });
5297        })));
5298
5299        let plain = supervisor.spawn(stub("plain", live.clone())).unwrap();
5300        let configured = supervisor
5301            .supervise_configured(stub("configured", live.clone()), true)
5302            .unwrap();
5303        let with_health = supervisor
5304            .supervise_configured_with_health(
5305                stub("with-health", live.clone()),
5306                true,
5307                HealthConfig::default(),
5308                None,
5309                RestartPolicy::default(),
5310            )
5311            .unwrap();
5312        // The failed-spawn path still puts the module on the roster (as
5313        // failed), so it is configured throughout.
5314        let failed = supervisor
5315            .supervise_configured_with_health(
5316                stub("failed-spawn", missing.clone()),
5317                true,
5318                HealthConfig::default(),
5319                None,
5320                RestartPolicy::default(),
5321            )
5322            .unwrap();
5323        // A failed plain `spawn` puts nothing on the roster, so its mark is
5324        // taken back once the spawn has failed.
5325        assert!(supervisor.spawn(stub("spawn-error", missing)).is_err());
5326
5327        let expected = [
5328            "plain",
5329            "configured",
5330            "with-health",
5331            "failed-spawn",
5332            "spawn-error",
5333        ]
5334        .into_iter()
5335        .map(|module_id| OwnerInSpawnWindow {
5336            module_id: module_id.to_string(),
5337            configured: true,
5338            on_roster: false,
5339            admission_refusal: Some(error_codes::SCOPE_NOT_SYNCED),
5340        })
5341        .collect::<Vec<_>>();
5342        assert_eq!(*observed.lock().unwrap(), expected);
5343
5344        for module_id in ["plain", "configured", "with-health", "failed-spawn"] {
5345            assert!(
5346                handle.get(module_id).is_some(),
5347                "{module_id} is on the roster"
5348            );
5349            assert!(
5350                handle.is_configured(module_id),
5351                "{module_id} stays configured"
5352            );
5353        }
5354        assert!(handle.get("spawn-error").is_none());
5355        assert!(
5356            !handle.is_configured("spawn-error"),
5357            "a plain spawn that failed must not leave its module marked configured"
5358        );
5359
5360        // Leaving the roster clears the mark with it.
5361        handle.retire("failed-spawn");
5362        assert!(!handle.is_configured("failed-spawn"));
5363
5364        for module in [plain, configured, with_health] {
5365            module.stop().await.unwrap();
5366        }
5367        drop(failed);
5368    }
5369
5370    fn stale_process_snapshot(state: ModuleState, enabled: bool) -> SharedSnapshot {
5371        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(state, enabled)));
5372        update_snapshot(&snapshot, Some("stale-process-facts"), |snapshot| {
5373            snapshot.process_alive = true;
5374            snapshot.pid = Some(41);
5375            snapshot.spawned_at_ms = Some(42);
5376            snapshot.spawned_from = Some(PathBuf::from("/spawned/module"));
5377            snapshot.spawned_file_identity = Some(SpawnedFileIdentity {
5378                device: 43,
5379                inode: 44,
5380                #[cfg(windows)]
5381                windows_image: None,
5382            });
5383        })
5384        .unwrap();
5385        snapshot
5386    }
5387
5388    fn assert_snapshot_process_facts_cleared(snapshot: &SharedSnapshot) {
5389        let snapshot = lock_snapshot(snapshot).unwrap();
5390        assert!(!snapshot.process_alive);
5391        assert_eq!(snapshot.pid, None);
5392        assert_eq!(snapshot.spawned_at_ms, None);
5393        assert_eq!(snapshot.spawned_from, None);
5394        assert_eq!(snapshot.spawned_file_identity, None);
5395    }
5396
5397    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5398    async fn failed_enable_spawn_clears_preexisting_current_process_facts() {
5399        let supervisor =
5400            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5401        let mut runtime = supervisor.runtime_config();
5402        runtime.test_seed_stale_facts_before_enable_spawn = true;
5403        let snapshot = stale_process_snapshot(ModuleState::Disabled, false);
5404        let mut child = None;
5405        let spec = ModuleSpec {
5406            module_id: "failed-enable-clears-facts".to_string(),
5407            program: PathBuf::from("/definitely/missing/failed-enable-module"),
5408            args: Vec::new(),
5409            env: Vec::new(),
5410            reserved: false,
5411            reserved_prefixes: Vec::new(),
5412            protocol: ModuleProtocol::Subc,
5413            overlap: Default::default(),
5414        };
5415
5416        let result = set_child_enabled(
5417            &spec,
5418            &runtime,
5419            &supervisor.registry,
5420            &supervisor.process_liveness,
5421            &snapshot,
5422            &mut child,
5423            true,
5424        )
5425        .await;
5426
5427        assert!(matches!(result, Err(SuperviseError::Spawn { .. })));
5428        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
5429        assert_snapshot_process_facts_cleared(&snapshot);
5430    }
5431
5432    #[tokio::test]
5433    async fn start_revives_stranded_restarting_but_not_pending_backoff() {
5434        let supervisor =
5435            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5436        let runtime = supervisor.runtime_config();
5437        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(
5438            ModuleState::Restarting,
5439            true,
5440        )));
5441        let spec = ModuleSpec {
5442            module_id: "start-stranded-restarting".to_string(),
5443            program: super::terminal_history_tests::fake_aft_stub_path(),
5444            args: Vec::new(),
5445            env: vec![("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string())],
5446            reserved: false,
5447            reserved_prefixes: Vec::new(),
5448            protocol: ModuleProtocol::None,
5449            overlap: Default::default(),
5450        };
5451        let mut child = None;
5452        lock_snapshot(&snapshot).unwrap().respawn_pending = true;
5453        assert!(!super::set_child_enabled(
5454            &spec,
5455            &runtime,
5456            &Registry::default(),
5457            &supervisor.process_liveness,
5458            &snapshot,
5459            &mut child,
5460            true
5461        )
5462        .await
5463        .unwrap());
5464        assert!(child.is_none());
5465        lock_snapshot(&snapshot).unwrap().respawn_pending = false;
5466        assert!(super::set_child_enabled(
5467            &spec,
5468            &runtime,
5469            &Registry::default(),
5470            &supervisor.process_liveness,
5471            &snapshot,
5472            &mut child,
5473            true
5474        )
5475        .await
5476        .unwrap());
5477        assert_eq!(
5478            lock_snapshot(&snapshot).unwrap().state,
5479            ModuleState::Running
5480        );
5481        let mut child = child.unwrap();
5482        child.start_kill().unwrap();
5483        child.wait().await.unwrap();
5484    }
5485
5486    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5487    async fn failed_reload_spawn_clears_current_process_facts() {
5488        let supervisor =
5489            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5490        let mut runtime = supervisor.runtime_config();
5491        runtime.restart_policy = RestartPolicy::new(0, Duration::ZERO);
5492        let snapshot = stale_process_snapshot(ModuleState::Running, true);
5493        let mut child = None;
5494        let spec = ModuleSpec {
5495            module_id: "failed-reload-clears-facts".to_string(),
5496            program: PathBuf::from("/unused/failed-reload-module"),
5497            args: Vec::new(),
5498            env: Vec::new(),
5499            reserved: false,
5500            reserved_prefixes: Vec::new(),
5501            protocol: ModuleProtocol::Subc,
5502            overlap: Default::default(),
5503        };
5504
5505        let result = handle_reload_spawn_failure(
5506            &spec,
5507            &runtime,
5508            &supervisor.process_liveness,
5509            &snapshot,
5510            &mut child,
5511            "forced reload spawn failure".to_string(),
5512        )
5513        .await;
5514
5515        assert!(matches!(result, Err(SuperviseError::ReloadFailed { .. })));
5516        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
5517        assert_snapshot_process_facts_cleared(&snapshot);
5518    }
5519
5520    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5521    async fn dropping_a_module_with_an_active_monitor_clears_current_process_facts() {
5522        let supervisor =
5523            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5524        let snapshot = stale_process_snapshot(ModuleState::Running, true);
5525        let module = supervisor.supervised_module(
5526            ModuleSpec {
5527                module_id: "drop-clears-facts".to_string(),
5528                program: PathBuf::from("/unused/drop-module"),
5529                args: Vec::new(),
5530                env: Vec::new(),
5531                reserved: false,
5532                reserved_prefixes: Vec::new(),
5533                protocol: ModuleProtocol::Subc,
5534                overlap: Default::default(),
5535            },
5536            supervisor.runtime_config(),
5537            Arc::clone(&snapshot),
5538            None,
5539        );
5540        assert!(!module
5541            .inner
5542            .monitor
5543            .lock()
5544            .unwrap()
5545            .as_ref()
5546            .unwrap()
5547            .is_finished());
5548
5549        drop(module);
5550
5551        assert_eq!(
5552            lock_snapshot(&snapshot).unwrap().state,
5553            ModuleState::Stopped
5554        );
5555        assert_snapshot_process_facts_cleared(&snapshot);
5556    }
5557
5558    #[cfg(unix)]
5559    #[tokio::test]
5560    async fn rescan_preserves_running_protocol_until_respawn() {
5561        let dir = cortexkit_test_support::ScratchDir::new("rescan-protocol");
5562        let initial = ModuleSpec {
5563            module_id: "rescan-protocol".into(),
5564            program: PathBuf::from("/bin/sleep"),
5565            args: vec!["60".into()],
5566            env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
5567                .into_iter()
5568                .map(|key| (key.into(), dir.path().to_string_lossy().into_owned()))
5569                .collect(),
5570            reserved: false,
5571            reserved_prefixes: vec![],
5572            protocol: ModuleProtocol::None,
5573            overlap: Default::default(),
5574        };
5575        let supervisor =
5576            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5577        let module = supervisor.spawn(initial.clone()).unwrap();
5578        assert!(module.status().unwrap().live);
5579        let mut next = initial;
5580        next.protocol = ModuleProtocol::Subc;
5581        module
5582            .update_configuration(next.clone(), HealthConfig::default(), None)
5583            .await
5584            .unwrap();
5585        assert!(
5586            module.status().unwrap().live,
5587            "rescan must not require HELLO from the old non-wire process"
5588        );
5589        let runtime = supervisor.runtime_config();
5590        let action = on_child_exit(
5591            &next,
5592            RestartPolicy::default(),
5593            &supervisor.registry,
5594            &module.inner.snapshot,
5595            &runtime.terminal_ring,
5596            &runtime.spawn_events,
5597            &runtime.child_roster,
5598            ExitReport {
5599                kind: ExitKind::Clean,
5600                code: Some(0),
5601                signal: None,
5602                at_ms: unix_ms_now(),
5603            },
5604        )
5605        .await;
5606        assert!(
5607            matches!(action, NextAction::Restart { .. }),
5608            "the old non-wire process's clean exit must restart"
5609        );
5610        module.drain().await.unwrap();
5611    }
5612
5613    #[cfg(unix)]
5614    #[tokio::test]
5615    async fn orphan_identity_matches_path_names_and_shebang_interpreters() {
5616        use std::os::unix::fs::PermissionsExt;
5617        let dir = cortexkit_test_support::ScratchDir::new("spawn-image-identity");
5618        let script = dir.join("module.sh");
5619        std::fs::write(&script, "#!/bin/sh\nwhile :; do sleep 0.1; done\n").unwrap();
5620        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o700)).unwrap();
5621        let record_path = dir.join("live-children.json");
5622        let supervisor =
5623            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5624                .with_live_children_record(&record_path);
5625        for (program, args) in [
5626            (PathBuf::from("sleep"), vec!["60".into()]),
5627            (script, vec![]),
5628        ] {
5629            let spec = ModuleSpec {
5630                module_id: "image-identity".into(),
5631                program: program.clone(),
5632                args,
5633                env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
5634                    .into_iter()
5635                    .map(|key| (key.into(), dir.to_string_lossy().into_owned()))
5636                    .collect(),
5637                reserved: false,
5638                reserved_prefixes: vec![],
5639                protocol: ModuleProtocol::None,
5640                overlap: Default::default(),
5641            };
5642            let module = supervisor.spawn(spec).unwrap();
5643            #[cfg(target_os = "macos")]
5644            {
5645                // SETEXEC confirmation is asynchronous; the orphan record must
5646                // identify the final image, never the intermediate trampoline.
5647                let deadline = Instant::now() + Duration::from_secs(5);
5648                while crate::live_children::read_record(&record_path)
5649                    .unwrap()
5650                    .iter()
5651                    .all(|entry| entry.executable.is_none())
5652                {
5653                    assert!(Instant::now() < deadline, "module image was not confirmed");
5654                    tokio::time::sleep(Duration::from_millis(5)).await;
5655                }
5656            }
5657            let entry = crate::live_children::read_record(&record_path)
5658                .unwrap()
5659                .pop()
5660                .unwrap();
5661            let observed = subc_os::Process::open(entry.pid)
5662                .unwrap()
5663                .unwrap()
5664                .observe()
5665                .unwrap();
5666            let verdict = crate::live_children::identity_verdict(&entry, entry.pid, &observed);
5667            module.drain().await.unwrap();
5668            assert_eq!(
5669                verdict,
5670                crate::live_children::IdentityVerdict::Matches,
5671                "program {program:?}: recorded {entry:?}, observed {observed:?}"
5672            );
5673        }
5674    }
5675
5676    #[cfg(unix)]
5677    fn http_fixture(
5678        dir: &std::path::Path,
5679        url: &str,
5680        threshold: u32,
5681    ) -> crate::daemon_config::ConfiguredModule {
5682        let path = dir.join("subc.jsonc");
5683        std::fs::write(&path, serde_json::json!({"version": 1, "modules": {"http-process": {
5684            "program": "/bin/sleep", "args": ["60"], "protocol": "none",
5685            "env": {"XDG_DATA_HOME":dir,"XDG_RUNTIME_DIR":dir,"XDG_CONFIG_HOME":dir},
5686            "health": {"http":url,"cadence_ms":20,"deadline_ms":200,"failure_threshold":threshold},
5687            "restart": {"backoff_ms":1,"max_backoff_ms":1}, "drain_timeout_ms":10
5688        }}}).to_string()).unwrap();
5689        crate::daemon_config::load(&path)
5690            .unwrap()
5691            .unwrap()
5692            .modules
5693            .pop()
5694            .unwrap()
5695    }
5696
5697    #[cfg(unix)]
5698    async fn wait_http_health(module: &SupervisedModule, status: SupervisorHealthStatus) {
5699        timeout(Duration::from_secs(5), async {
5700            loop {
5701                if module.status().unwrap().health.status == status {
5702                    break;
5703                }
5704                sleep(Duration::from_millis(5)).await;
5705            }
5706        })
5707        .await
5708        .unwrap_or_else(|_| {
5709            panic!(
5710                "expected {status:?}, got {:?}",
5711                module.status().unwrap().health
5712            )
5713        });
5714    }
5715
5716    #[cfg(unix)]
5717    #[tokio::test]
5718    async fn http_health_status_flips_ok_failing_ok() {
5719        use tokio::io::{AsyncReadExt, AsyncWriteExt};
5720        let dir = cortexkit_test_support::ScratchDir::new("http-status-flips");
5721        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5722        let status = Arc::new(std::sync::atomic::AtomicU16::new(200));
5723        let serving_status = status.clone();
5724        let url = format!("http://{}/healthz", listener.local_addr().unwrap());
5725        let server = tokio::spawn(async move {
5726            loop {
5727                let (mut stream, _) = listener.accept().await.unwrap();
5728                let mut request = [0u8; 2048];
5729                let count = stream.read(&mut request).await.unwrap();
5730                assert!(count > 0, "a probe must send an HTTP request");
5731                let code = serving_status.load(std::sync::atomic::Ordering::SeqCst);
5732                let body = if code == 200 {
5733                    "ready"
5734                } else {
5735                    "{\"status\":\"unavailable\",\"error\":\"scratch failure\"}"
5736                };
5737                let response = format!("HTTP/1.1 {code} scratch\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());
5738                let _ = stream.write_all(response.as_bytes()).await;
5739            }
5740        });
5741        let configured = http_fixture(&dir, &url, 1000);
5742        let module =
5743            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5744                .supervise_configured_with_health(
5745                    configured.module_spec(),
5746                    true,
5747                    configured.health,
5748                    configured.drain_timeout_ms,
5749                    configured.restart,
5750                )
5751                .unwrap();
5752        wait_http_health(&module, SupervisorHealthStatus::Ok).await;
5753        status.store(503, std::sync::atomic::Ordering::SeqCst);
5754        wait_http_health(&module, SupervisorHealthStatus::Failing).await;
5755        assert!(module
5756            .status()
5757            .unwrap()
5758            .health
5759            .detail
5760            .unwrap()
5761            .contains("scratch failure"));
5762        status.store(200, std::sync::atomic::Ordering::SeqCst);
5763        wait_http_health(&module, SupervisorHealthStatus::Ok).await;
5764        assert_eq!(module.status().unwrap().health.consecutive_failures, 0);
5765        let before = module.status().unwrap();
5766        let (spec, mut health) = module.configuration().unwrap();
5767        health.http = None;
5768        module
5769            .update_configuration(spec.clone(), health.clone(), Some(10))
5770            .await
5771            .unwrap();
5772        wait_http_health(&module, SupervisorHealthStatus::Unknown).await;
5773        health.http = Some(url);
5774        module
5775            .update_configuration(spec, health, Some(10))
5776            .await
5777            .unwrap();
5778        wait_http_health(&module, SupervisorHealthStatus::Ok).await;
5779        assert_eq!(
5780            module.status().unwrap().pid,
5781            before.pid,
5782            "changing a probe must apply live, not restart its process"
5783        );
5784        let (spec, mut health) = module.configuration().unwrap();
5785        health.failure_threshold = 2;
5786        module
5787            .update_configuration(spec, health, Some(10))
5788            .await
5789            .unwrap();
5790        status.store(503, std::sync::atomic::Ordering::SeqCst);
5791        timeout(Duration::from_secs(5), async {
5792            while module.status().unwrap().spawn_generation == before.spawn_generation {
5793                sleep(Duration::from_millis(5)).await;
5794            }
5795        })
5796        .await
5797        .expect("sustained HTTP 503 responses must trigger the health restart policy");
5798        module.drain().await.unwrap();
5799        server.abort();
5800    }
5801
5802    #[cfg(unix)]
5803    #[tokio::test]
5804    async fn http_health_no_listener_restarts_after_consecutive_failures() {
5805        let dir = cortexkit_test_support::ScratchDir::new("http-refused");
5806        let unused = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5807        let url = format!("http://{}/healthz", unused.local_addr().unwrap());
5808        drop(unused);
5809        let configured = http_fixture(&dir, &url, 2);
5810        let module =
5811            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5812                .supervise_configured_with_health(
5813                    configured.module_spec(),
5814                    true,
5815                    configured.health,
5816                    configured.drain_timeout_ms,
5817                    configured.restart,
5818                )
5819                .unwrap();
5820        let before = module.status().unwrap().spawn_generation;
5821        timeout(Duration::from_secs(5), async {
5822            loop {
5823                let status = module.status().unwrap();
5824                if status.spawn_generation > before {
5825                    assert!(status.lifetime_restarts > 0);
5826                    break;
5827                }
5828                sleep(Duration::from_millis(5)).await;
5829            }
5830        })
5831        .await
5832        .expect("sustained HTTP refusal must trigger the health restart policy");
5833        module.drain().await.unwrap();
5834    }
5835
5836    #[cfg(unix)]
5837    #[tokio::test]
5838    async fn http_health_timeout_honours_deadline() {
5839        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5840        let url = format!("http://{}/healthz", listener.local_addr().unwrap());
5841        let server = tokio::spawn(async move {
5842            let _held = listener.accept().await.unwrap();
5843            std::future::pending::<()>().await;
5844        });
5845        let error = timeout(
5846            Duration::from_secs(1),
5847            probe_http_health(&url, Duration::from_millis(10)),
5848        )
5849        .await
5850        .expect("the probe must enforce its own deadline")
5851        .unwrap_err();
5852        server.abort();
5853        assert!(error.to_string().contains("timed out"));
5854    }
5855
5856    #[cfg(unix)]
5857    #[tokio::test]
5858    async fn http_health_localhost_reaches_an_ipv6_only_listener() {
5859        use tokio::io::{AsyncReadExt, AsyncWriteExt};
5860        let listener = tokio::net::TcpListener::bind("[::1]:0").await.unwrap();
5861        let url = format!(
5862            "http://localhost:{}/healthz",
5863            listener.local_addr().unwrap().port()
5864        );
5865        let server = tokio::spawn(async move {
5866            let (mut stream, _) = listener.accept().await.unwrap();
5867            let mut request = [0u8; 2048];
5868            assert!(stream.read(&mut request).await.unwrap() > 0);
5869            stream
5870                .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
5871                .await
5872                .unwrap();
5873        });
5874        // The deadline only bounds a hang. A probe that never tried the IPv6
5875        // address would be refused on 127.0.0.1 and fail at once, so a longer
5876        // deadline does not weaken the assertion; one second timed out under a
5877        // loaded parallel test run.
5878        assert_eq!(
5879            probe_http_health(&url, Duration::from_secs(10))
5880                .await
5881                .unwrap()
5882                .status,
5883            HealthStatus::Ok
5884        );
5885        server.await.unwrap();
5886    }
5887
5888    #[cfg(unix)]
5889    #[tokio::test]
5890    async fn http_health_timeout_keeps_partial_status_and_body() {
5891        use tokio::io::{AsyncReadExt, AsyncWriteExt};
5892        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5893        let url = format!("http://{}/healthz", listener.local_addr().unwrap());
5894        let server = tokio::spawn(async move {
5895            let (mut stream, _) = listener.accept().await.unwrap();
5896            let mut request = [0u8; 2048];
5897            assert!(stream.read(&mut request).await.unwrap() > 0);
5898            stream
5899                .write_all(
5900                    b"HTTP/1.1 503 Unavailable\r\nContent-Length: 100\r\n\r\npartial diagnostic",
5901                )
5902                .await
5903                .unwrap();
5904            std::future::pending::<()>().await;
5905        });
5906        let error = probe_http_health(&url, Duration::from_secs(1))
5907            .await
5908            .unwrap_err()
5909            .to_string();
5910        server.abort();
5911        assert!(
5912            error.contains("timed out")
5913                && error.contains("503 Unavailable")
5914                && error.contains("partial diagnostic"),
5915            "{error}"
5916        );
5917    }
5918
5919    #[cfg(unix)]
5920    #[tokio::test]
5921    async fn http_health_diagnostic_body_is_bounded_to_200_bytes() {
5922        use tokio::io::{AsyncReadExt, AsyncWriteExt};
5923        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5924        let url = format!("http://{}/healthz", listener.local_addr().unwrap());
5925        let server = tokio::spawn(async move {
5926            let (mut stream, _) = listener.accept().await.unwrap();
5927            let mut request = [0u8; 2048];
5928            assert!(stream.read(&mut request).await.unwrap() > 0);
5929            let body = format!("{}not-in-diagnostic", "x".repeat(400));
5930            let response = format!(
5931                "HTTP/1.1 503 Unavailable\r\nContent-Length: {}\r\n\r\n{body}",
5932                body.len()
5933            );
5934            stream.write_all(response.as_bytes()).await.unwrap();
5935        });
5936        let error = probe_http_health(&url, Duration::from_secs(1))
5937            .await
5938            .unwrap_err()
5939            .to_string();
5940        server.await.unwrap();
5941        assert!(error.ends_with(&"x".repeat(200)), "{error}");
5942        assert_eq!(error.split(": ").last().unwrap().len(), 200);
5943        assert!(!error.contains("not-in-diagnostic"));
5944    }
5945
5946    #[cfg(unix)]
5947    #[tokio::test]
5948    async fn http_health_real_nats_server_monitoring() {
5949        if std::process::Command::new("nats-server")
5950            .arg("--version")
5951            .env("XDG_DATA_HOME", std::env::temp_dir())
5952            .env("XDG_RUNTIME_DIR", std::env::temp_dir())
5953            .env("XDG_CONFIG_HOME", std::env::temp_dir())
5954            .output()
5955            .is_err()
5956        {
5957            eprintln!(
5958                "skipped: http_health_real_nats_server_monitoring (nats-server is not on PATH)"
5959            );
5960            return;
5961        }
5962        let dir = cortexkit_test_support::ScratchDir::new("nats-http-monitoring");
5963        let monitor = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5964        let port = monitor.local_addr().unwrap().port();
5965        let client = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5966        let client_port = client.local_addr().unwrap().port();
5967        let config = dir.join("server.conf");
5968        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();
5969        let mut configured = http_fixture(&dir, &format!("http://127.0.0.1:{port}/healthz"), 1000);
5970        configured.program = PathBuf::from("nats-server");
5971        configured.args = vec!["-c".into(), config.to_string_lossy().into_owned()];
5972        drop(monitor);
5973        drop(client);
5974        let module =
5975            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5976                .supervise_configured_with_health(
5977                    configured.module_spec(),
5978                    true,
5979                    configured.health,
5980                    configured.drain_timeout_ms,
5981                    configured.restart,
5982                )
5983                .unwrap();
5984        wait_http_health(&module, SupervisorHealthStatus::Ok).await;
5985        module.drain().await.unwrap();
5986    }
5987
5988    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5989    async fn configuration_update_does_not_replace_captured_running_process_facts() {
5990        let supervisor =
5991            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5992        let snapshot = stale_process_snapshot(ModuleState::Running, true);
5993        let initial = ModuleSpec {
5994            module_id: "rescan-preserves-spawn-facts".to_string(),
5995            program: PathBuf::from("/spawned/module"),
5996            args: Vec::new(),
5997            env: Vec::new(),
5998            reserved: false,
5999            reserved_prefixes: Vec::new(),
6000            protocol: ModuleProtocol::Subc,
6001            overlap: Default::default(),
6002        };
6003        let module = supervisor.supervised_module(
6004            initial.clone(),
6005            supervisor.runtime_config(),
6006            snapshot,
6007            None,
6008        );
6009        let before = module.status().unwrap();
6010        let mut replacement = initial;
6011        replacement.program = PathBuf::from("/rescanned/replacement-module");
6012
6013        module
6014            .update_configuration(replacement, HealthConfig::default(), None)
6015            .await
6016            .unwrap();
6017
6018        let after = module.status().unwrap();
6019        assert_eq!(after.pid, before.pid);
6020        assert_eq!(after.spawned_at_ms, before.spawned_at_ms);
6021        assert_eq!(after.spawned_from, before.spawned_from);
6022        drop(module);
6023    }
6024}
6025
6026fn unix_ms_now() -> u64 {
6027    SystemTime::now()
6028        .duration_since(UNIX_EPOCH)
6029        .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64)
6030        .unwrap_or(0)
6031}
6032
6033async fn supervise_loop(
6034    mut spec: ModuleSpec,
6035    mut runtime: SupervisorRuntimeConfig,
6036    registry: Arc<Registry>,
6037    process_liveness: Arc<SupervisorProcessLiveness>,
6038    snapshot: SharedSnapshot,
6039    mut child: Option<SupervisedChild>,
6040    mut commands: mpsc::Receiver<SupervisorCommand>,
6041) {
6042    let mut health_probe = HealthProbeRuntime::default();
6043    // Registry writes (including embedded callers) notify this module only.
6044    // Subscribe before the first refresh; watch retains changes that arrive
6045    // while commands or probes are running. No polling fallback is needed.
6046    let mut registration_changes = match registry.subscribe_module_changes(&spec.module_id) {
6047        Ok(changes) => Some(changes),
6048        Err(err) => {
6049            // A poisoned registry cannot accept further writes, so it cannot
6050            // recover via a timer. Keep exit and command handling alive.
6051            warn!(module_id = %spec.module_id, error = %err, "health prober could not subscribe to registry");
6052            None
6053        }
6054    };
6055    // All restart backoffs run here, including health and operator requests.
6056    // While one is pending the loop serves commands, so disable or drain can
6057    // cancel the replacement without spawning a process just to stop it.
6058    let mut pending_respawn: Option<PendingRespawn> = None;
6059    // Commands a swap handed back to run next (see `swap::SwapEnd`). Served
6060    // before anything else so a stop that interrupted a swap runs at once.
6061    let mut requeued: VecDeque<SupervisorCommand> = VecDeque::new();
6062    loop {
6063        #[cfg(test)]
6064        {
6065            lock_snapshot(&snapshot).unwrap().actor_select = None;
6066        }
6067        #[cfg(target_os = "macos")]
6068        if let Some(active) = child.as_mut() {
6069            active.confirm_privacy_exec().await;
6070        }
6071        if let Some(scheduled) = runtime
6072            .scheduled_respawn
6073            .lock()
6074            .unwrap_or_else(|p| p.into_inner())
6075            .take()
6076        {
6077            pending_respawn = Some(scheduled);
6078        }
6079        if pending_respawn.is_some() && (child.is_some() || !respawn_still_pending(&snapshot)) {
6080            pending_respawn = None;
6081            cancel_deferred_reload(
6082                &runtime,
6083                &spec.module_id,
6084                "respawn cancelled by a supervisor command",
6085            );
6086        }
6087        if child.is_none() && pending_respawn.is_none() {
6088            cancel_deferred_reload(
6089                &runtime,
6090                &spec.module_id,
6091                "respawn cancelled before a replacement was spawned",
6092            );
6093            let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| {
6094                state.respawn_pending = false;
6095                state.coalesced_restart_pending = false;
6096                if matches!(
6097                    state.state,
6098                    ModuleState::Restarting
6099                        | ModuleState::Starting
6100                        | ModuleState::Draining
6101                        | ModuleState::Unresponsive
6102                ) {
6103                    error!(module_id = %spec.module_id, state = ?state.state,
6104                        "supervision operation ended without a child or pending respawn; marking failed so start can retry");
6105                    state.state = ModuleState::Failed;
6106                    clear_current_process_facts(state);
6107                }
6108            });
6109        }
6110        if let Some(command) = requeued.pop_front() {
6111            if !handle_supervisor_command(
6112                command,
6113                &mut spec,
6114                &mut runtime,
6115                &registry,
6116                &process_liveness,
6117                &snapshot,
6118                &mut child,
6119                &mut commands,
6120                &mut requeued,
6121            )
6122            .await
6123            {
6124                return;
6125            }
6126            if child.is_some() || !respawn_still_pending(&snapshot) {
6127                pending_respawn = None;
6128                let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| {
6129                    state.respawn_pending = false
6130                });
6131            }
6132            continue;
6133        }
6134        if child.is_some() {
6135            #[cfg(test)]
6136            {
6137                lock_snapshot(&snapshot).unwrap().actor_turns += 1;
6138            }
6139            health_probe.refresh_registration(&spec, &runtime, &registry, &snapshot);
6140            let wake_after = health_probe.wake_after();
6141            let probe_sleep = async move {
6142                match wake_after {
6143                    Some(delay) => sleep(delay).await,
6144                    None => std::future::pending().await,
6145                }
6146            };
6147            tokio::pin!(probe_sleep);
6148            // A `protocol: "none"` child (a plain process that never registers
6149            // over the subc wire, such as nats-server) with no HTTP health
6150            // check has nothing to probe, so it waits only for its exit or a
6151            // supervisor command. A rescan that adds an HTTP check is a command.
6152            let wire_child = running_protocol(&spec, &snapshot) == ModuleProtocol::Subc;
6153            #[cfg(test)]
6154            {
6155                let registration_pending = wire_child
6156                    && registration_changes
6157                        .as_ref()
6158                        .is_some_and(|changes| changes.has_changed().unwrap());
6159                let mut state = lock_snapshot(&snapshot).unwrap();
6160                state.actor_select = (!registration_pending).then_some(ActorSelectCheckpoint {
6161                    generation: state.spawn_generation,
6162                    turn: state.actor_turns,
6163                    registered_connection: health_probe.registered_connection,
6164                    next_probe_at: health_probe.next_probe_at,
6165                    wake_after,
6166                });
6167            }
6168            let active_child = child.as_mut().expect("child checked above");
6169            tokio::select! {
6170                wait_result = active_child.wait() => {
6171                    #[cfg(test)]
6172                    { lock_snapshot(&snapshot).unwrap().actor_select = None; }
6173                    // Every arm below that gives up on the CHILD must keep the
6174                    // supervision task itself alive (child = None, loop
6175                    // continues into command-serving mode). Returning here
6176                    // closes the command channel, which makes the module
6177                    // permanently unrestartable in-band: a clean child exit
6178                    // of an enabled module once wedged the fleet this way
6179                    // ('supervisor command channel is closed') and required a
6180                    // full daemon restart to recover.
6181                    let exit_report = match wait_result {
6182                        Ok(status) => classify_reaped_child_exit(&snapshot, active_child, &status),
6183                        Err(err) => {
6184                            active_child.drain_stderr(&spec.module_id).await;
6185                            fail_snapshot(&snapshot, Some(&spec.module_id), None);
6186                            // Every other exit path (on_child_exit's Clean/Crash arms,
6187                            // the reload-registration-failure path) records a terminal
6188                            // before moving on. Without one here, a module whose wait()
6189                            // itself errored (e.g. already reaped) leaves no terminal
6190                            // record at all -- an empty ring reads as "nothing died".
6191                            record_wait_error_terminal(
6192                                &spec.module_id,
6193                                &runtime.terminal_ring,
6194                                &runtime.spawn_events,
6195                            );
6196                            untrack_if_registration_released(
6197                                &process_liveness,
6198                                &registry,
6199                                &spec.module_id,
6200                                &snapshot,
6201                            );
6202                            error!(module_id = %spec.module_id, error = %err, "failed to wait for supervised module");
6203                            child = None;
6204                            continue;
6205                        }
6206                    };
6207                    active_child.drain_stderr(&spec.module_id).await;
6208
6209                    let next = on_child_exit(
6210                        &spec,
6211                        runtime.restart_policy,
6212                        &registry,
6213                        &snapshot,
6214                        &runtime.terminal_ring,
6215                        &runtime.spawn_events,
6216                        &runtime.child_roster,
6217                        exit_report,
6218                    ).await;
6219                    // The exit is recorded, so a daemon shutdown may stop
6220                    // waiting for this child (see `SupervisedChild::wait`).
6221                    active_child.release_roster();
6222                    match next {
6223                        NextAction::Stop { registration_released } => {
6224                            if registration_released {
6225                                process_liveness.untrack_if_current(&spec.module_id, &snapshot);
6226                            }
6227                            child = None;
6228                        }
6229                        NextAction::Restart { schedule } => {
6230                            let delay = schedule.map_or(
6231                                runtime.restart_policy.delay_for_restart(0),
6232                                |schedule| schedule.delay,
6233                            );
6234                            if let Some(schedule) = schedule {
6235                                log_crash_respawn(&spec.module_id, schedule);
6236                            }
6237                            // The exited child is fully recorded at this point,
6238                            // so release it and count the backoff down in the
6239                            // command-serving branch below rather than sleeping
6240                            // here: commands cannot be received from inside this
6241                            // select arm, and an operator disable or drain that
6242                            // arrives during the backoff must cancel the pending
6243                            // respawn instead of waiting for it to spawn first.
6244                            child = None;
6245                            pending_respawn = Some(PendingRespawn { deadline: Instant::now() + delay, kind: RespawnKind::Spawn });
6246                             let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| state.respawn_pending = true);
6247                        }
6248                    }
6249                }
6250                command = commands.recv() => {
6251                    #[cfg(test)]
6252                    { lock_snapshot(&snapshot).unwrap().actor_select = None; }
6253                    let Some(command) = command else {
6254                        return;
6255                    };
6256                    if !handle_supervisor_command(
6257                        command,
6258                        &mut spec,
6259                        &mut runtime,
6260                        &registry,
6261                        &process_liveness,
6262                        &snapshot,
6263                        &mut child,
6264                        &mut commands,
6265                        &mut requeued,
6266                    ).await {
6267                        return;
6268                    }
6269                }
6270                _ = async {
6271                    match registration_changes.as_mut() {
6272                        Some(changes) => { let _ = changes.changed().await; }
6273                        None => std::future::pending().await,
6274                    }
6275                }, if wire_child => {
6276                    #[cfg(test)]
6277                    { lock_snapshot(&snapshot).unwrap().actor_select = None; }
6278                }
6279                _ = &mut probe_sleep => {
6280                    #[cfg(test)]
6281                    { lock_snapshot(&snapshot).unwrap().actor_select = None; }
6282                    if health_probe.due() {
6283                        run_health_probe_cycle(
6284                            &spec,
6285                            &runtime,
6286                            &registry,
6287                            &process_liveness,
6288                            &snapshot,
6289                            &mut child,
6290                        ).await;
6291                        if child.is_some() {
6292                            health_probe.schedule_next(&spec, runtime.health.cadence);
6293                        }
6294                    }
6295                }
6296            }
6297        } else if let Some(pending) = pending_respawn {
6298            tokio::select! {
6299                _ = sleep_until(pending.deadline) => {
6300                    pending_respawn = None;
6301                let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| state.respawn_pending = false);
6302                    // A command handled below while the backoff elapsed may
6303                    // have stopped the module; never respawn past an operator's
6304                    // disable or drain.
6305                    if !respawn_still_pending(&snapshot) {
6306                        continue;
6307                    }
6308                    // The daemon began shutting down during the backoff: the
6309                    // spawn would be refused anyway, and refusing it here
6310                    // leaves the module stopped instead of reporting a
6311                    // failed restart.
6312                    if runtime.child_roster.is_closed() {
6313                        let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| {
6314                            state.state = ModuleState::Stopped;
6315                        });
6316                        debug!(module_id = %spec.module_id, "crash respawn cancelled by daemon shutdown");
6317                        continue;
6318                    }
6319                    if let Err(err) = release_dead_registration(
6320                        &registry,
6321                        runtime.forwarding.as_deref(),
6322                        &snapshot,
6323                        &spec.module_id,
6324                    ).await {
6325                        fail_snapshot(&snapshot, Some(&spec.module_id), None);
6326                        error!(module_id = %spec.module_id, error = %err, "registration did not release before restart");
6327                        continue;
6328                    }
6329
6330                    if matches!(pending.kind, RespawnKind::Reload) {
6331                        let reply = runtime.deferred_reload_reply.lock().unwrap_or_else(|p| p.into_inner()).take();
6332                        let result = finish_reload_child(&spec, &runtime, &registry, &process_liveness, &snapshot, &mut child).await;
6333                        if let Some(reply) = reply { let _ = reply.send(result); }
6334                        continue;
6335                    }
6336                    process_liveness.track(spec.module_id.clone(), Arc::clone(&snapshot));
6337                    match spawn_and_mark_running(&spec, &runtime, &snapshot) {
6338                        Ok(next_child) => {
6339                            child = Some(next_child);
6340                            debug!(module_id = %spec.module_id, "supervised module restarted after crash");
6341                        }
6342                        Err(err) => {
6343                            fail_snapshot(&snapshot, Some(&spec.module_id), None);
6344                            process_liveness.untrack_if_current(&spec.module_id, &snapshot);
6345                            error!(module_id = %spec.module_id, error = %err, "failed to restart supervised module");
6346                        }
6347                    }
6348                }
6349                command = commands.recv() => {
6350                    let Some(command) = command else {
6351                        return;
6352                    };
6353                    if !handle_supervisor_command(
6354                        command,
6355                        &mut spec,
6356                        &mut runtime,
6357                        &registry,
6358                        &process_liveness,
6359                        &snapshot,
6360                        &mut child,
6361                        &mut commands,
6362                        &mut requeued,
6363                    ).await {
6364                        return;
6365                    }
6366                    // Reconcile the pending respawn with what the command did:
6367                    // a start may already have spawned a fresh child,
6368                    // while a disable or drain moved the snapshot out of the
6369                    // state the respawn was counting down from.
6370                    if child.is_some() || !respawn_still_pending(&snapshot) {
6371                        pending_respawn = None;
6372                let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| state.respawn_pending = false);
6373                    }
6374                }
6375            }
6376        } else {
6377            let Some(command) = commands.recv().await else {
6378                return;
6379            };
6380            if !handle_supervisor_command(
6381                command,
6382                &mut spec,
6383                &mut runtime,
6384                &registry,
6385                &process_liveness,
6386                &snapshot,
6387                &mut child,
6388                &mut commands,
6389                &mut requeued,
6390            )
6391            .await
6392            {
6393                return;
6394            }
6395        }
6396    }
6397}
6398
6399fn log_crash_respawn(module_id: &str, schedule: CrashRestartSchedule) {
6400    info!(
6401        module_id,
6402        restart_in_window = schedule.restart_in_window,
6403        delay_ms = schedule.delay.as_millis() as u64,
6404        "respawning after crash"
6405    );
6406}
6407
6408/// Whether the respawn a backoff was counting down to is still wanted. A
6409/// disable or drain handled while the backoff elapsed moves the snapshot out
6410/// of `Restarting`, and the operator's stop must win over the pending respawn,
6411/// so every sleep-then-spawn path re-validates against the live snapshot
6412/// instead of assuming the state it left behind still holds.
6413fn respawn_still_pending(snapshot: &SharedSnapshot) -> bool {
6414    matches!(
6415        lock_snapshot(snapshot),
6416        Ok(state) if state.enabled && state.state == ModuleState::Restarting
6417    )
6418}
6419
6420enum NextAction {
6421    Stop {
6422        registration_released: bool,
6423    },
6424    Restart {
6425        schedule: Option<CrashRestartSchedule>,
6426    },
6427}
6428
6429#[allow(clippy::too_many_arguments)]
6430async fn handle_supervisor_command(
6431    command: SupervisorCommand,
6432    spec: &mut ModuleSpec,
6433    runtime: &mut SupervisorRuntimeConfig,
6434    registry: &Arc<Registry>,
6435    process_liveness: &SupervisorProcessLiveness,
6436    snapshot: &SharedSnapshot,
6437    child: &mut Option<SupervisedChild>,
6438    commands: &mut mpsc::Receiver<SupervisorCommand>,
6439    requeued: &mut VecDeque<SupervisorCommand>,
6440) -> bool {
6441    match command {
6442        SupervisorCommand::Drain { reply } => {
6443            // A plain stop runs no forwarding drain, so nothing reaches the
6444            // module over its connection before the wait: ask by signal.
6445            let result = drain_optional_child(
6446                &spec.module_id,
6447                spec.protocol,
6448                StopNotice::NotSent,
6449                registry,
6450                runtime.forwarding.as_deref(),
6451                snapshot,
6452                &runtime.terminal_ring,
6453                &runtime.spawn_events,
6454                child,
6455                runtime.drain_timeout,
6456                ModuleState::Stopped,
6457                None,
6458            )
6459            .await;
6460            let registration_released = result.is_ok();
6461            let _ = reply.send(result);
6462            if registration_released {
6463                process_liveness.untrack_if_current(&spec.module_id, snapshot);
6464            }
6465            false
6466        }
6467        SupervisorCommand::Retire { reply } => {
6468            let result = async {
6469                let stop_notice = begin_forwarding_drain_if_configured(
6470                    spec,
6471                    runtime,
6472                    registry,
6473                    snapshot,
6474                    None,
6475                    RouteCloseReason::Disable,
6476                )
6477                .await?;
6478                drain_optional_child(
6479                    &spec.module_id,
6480                    spec.protocol,
6481                    stop_notice,
6482                    registry,
6483                    runtime.forwarding.as_deref(),
6484                    snapshot,
6485                    &runtime.terminal_ring,
6486                    &runtime.spawn_events,
6487                    child,
6488                    runtime.drain_timeout,
6489                    ModuleState::Stopped,
6490                    None,
6491                )
6492                .await
6493            }
6494            .await;
6495            let registration_released = result.is_ok();
6496            let _ = reply.send(result);
6497            if registration_released {
6498                process_liveness.untrack_if_current(&spec.module_id, snapshot);
6499            }
6500            false
6501        }
6502        SupervisorCommand::Restart {
6503            drain_timeout_ms,
6504            received_at_generation,
6505            queued_at,
6506            reply,
6507        } => {
6508            // Without this line a restart that waited in the queue (behind a
6509            // health probe cycle or another command) was invisible: the log
6510            // showed only the drain timing out, minutes after the operator's call.
6511            info!(
6512                module_id = %spec.module_id,
6513                queued_ms = u64::try_from(queued_at.elapsed().as_millis()).unwrap_or(u64::MAX),
6514                "restart command dequeued"
6515            );
6516            // ACK AT INITIATION, not completion. The blocking form deadlocked any
6517            // caller whose own request lane rides the module being restarted: the
6518            // caller's in-flight request keeps the drain from quiescing, the drain
6519            // keeps the restart from completing, and the completion keeps the reply
6520            // from releasing the caller — so the drain always timed out and cut the
6521            // initiator with a GOODBYE, even on a healthy module. Replying once the
6522            // restart is validated lets a self-lane caller settle, which is exactly
6523            // what makes the drain succeed. Completion is observable via
6524            // supervisor.list / module status; a post-ack failure lands the module
6525            // in a visible terminal state below rather than in a reply nobody can
6526            // receive.
6527            let validation = match lock_snapshot(snapshot) {
6528                Ok(state) if !state.enabled => Err(SuperviseError::Disabled {
6529                    module_id: spec.module_id.clone(),
6530                }),
6531                Ok(_) => Ok(()),
6532                Err(err) => Err(err),
6533            };
6534            let initiated = validation.is_ok();
6535            let _ = reply.send(validation);
6536            // A restart asks for a fresh process. Commands run one at a time,
6537            // so a restart queued behind another restart (two operator calls
6538            // in quick succession) is dequeued the moment the first one has
6539            // spawned its replacement -- before that process has sent HELLO.
6540            // Running it would drain and kill the process the first restart
6541            // just produced, which is the opposite of what both callers asked
6542            // for. If a process spawned after this request was received is
6543            // still supervised, the request is already satisfied. Not when the
6544            // configuration changed since that spawn: then the newer process
6545            // predates the spec this restart may exist to apply.
6546            let satisfied_by_generation = if initiated && child.is_some() {
6547                lock_snapshot(snapshot).ok().and_then(|state| {
6548                    (state.spawn_generation > received_at_generation
6549                        && !state.configuration_updated_since_spawn)
6550                        .then_some(state.spawn_generation)
6551                })
6552            } else {
6553                None
6554            };
6555            let satisfied_by_pending = initiated
6556                && child.is_none()
6557                && lock_snapshot(snapshot).ok().is_some_and(|mut state| {
6558                    let pending = state.respawn_pending && !state.configuration_updated_since_spawn;
6559                    if pending {
6560                        state.coalesced_restart_pending = true;
6561                    }
6562                    pending
6563                });
6564            if satisfied_by_pending {
6565                debug!(module_id = %spec.module_id, "restart coalesced into the pending replacement");
6566            } else if let Some(generation) = satisfied_by_generation {
6567                info!(
6568                    module_id = %spec.module_id,
6569                    received_at_generation,
6570                    "restart already satisfied by generation {generation}; not restarting again"
6571                );
6572            } else if initiated {
6573                // Precedence: this restart's operator override, else the module's
6574                // configured budget (already resolved into the runtime).
6575                let drain_timeout = drain_timeout_ms
6576                    .map(Duration::from_millis)
6577                    .unwrap_or(runtime.drain_timeout);
6578                if let Err(err) = restart_child(
6579                    spec,
6580                    runtime,
6581                    registry,
6582                    process_liveness,
6583                    snapshot,
6584                    child,
6585                    drain_timeout,
6586                )
6587                .await
6588                {
6589                    warn!(
6590                        module_id = %spec.module_id,
6591                        error = %err,
6592                        "operator restart failed after initiation ack; module state carries the outcome"
6593                    );
6594                    let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
6595                        state.state = ModuleState::Failed;
6596                        clear_current_process_facts(state);
6597                    });
6598                }
6599            }
6600            true
6601        }
6602        SupervisorCommand::Reload { reply } => {
6603            let result =
6604                reload_child(spec, runtime, registry, process_liveness, snapshot, child).await;
6605            if result.is_ok()
6606                && runtime
6607                    .scheduled_respawn
6608                    .lock()
6609                    .unwrap_or_else(|p| p.into_inner())
6610                    .as_ref()
6611                    .is_some_and(|pending| matches!(pending.kind, RespawnKind::Reload))
6612            {
6613                *runtime
6614                    .deferred_reload_reply
6615                    .lock()
6616                    .unwrap_or_else(|p| p.into_inner()) = Some(reply);
6617            } else {
6618                let _ = reply.send(result);
6619            }
6620            true
6621        }
6622        SupervisorCommand::SetEnabled { enabled, reply } => {
6623            let result = set_child_enabled(
6624                spec,
6625                runtime,
6626                registry,
6627                process_liveness,
6628                snapshot,
6629                child,
6630                enabled,
6631            )
6632            .await;
6633            let _ = reply.send(result);
6634            true
6635        }
6636        SupervisorCommand::UpdateConfiguration {
6637            spec: next_spec,
6638            health,
6639            drain_timeout_ms,
6640            reply,
6641        } => {
6642            if let Some(handle) = &runtime.supervisor_handle {
6643                handle.apply_identity_configuration(&next_spec);
6644            }
6645            *spec = next_spec;
6646            let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
6647                state.configuration_updated_since_spawn = true;
6648            });
6649            let health_changed = runtime.health != health;
6650            runtime.health = health;
6651            // Reset the cadence and old endpoint's failure streak on a live
6652            // health-policy change rather than waiting for its old deadline.
6653            if health_changed {
6654                let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
6655                    state.health = ModuleHealthStatus::default();
6656                });
6657            }
6658            runtime.drain_timeout = drain_timeout_ms
6659                .map(Duration::from_millis)
6660                .unwrap_or(runtime.default_drain_timeout);
6661            *runtime
6662                .effective_drain_timeout
6663                .lock()
6664                .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.drain_timeout;
6665            let _ = reply.send(());
6666            true
6667        }
6668        SupervisorCommand::Swap {
6669            ready_timeout,
6670            reply,
6671        } => {
6672            let end = swap::run_swap(
6673                spec,
6674                runtime,
6675                registry,
6676                process_liveness,
6677                snapshot,
6678                child,
6679                commands,
6680                ready_timeout.unwrap_or(DEFAULT_SWAP_READY_TIMEOUT),
6681                reply,
6682            )
6683            .await;
6684            requeued.extend(end.requeue);
6685            true
6686        }
6687    }
6688}
6689
6690async fn restart_child(
6691    spec: &ModuleSpec,
6692    runtime: &SupervisorRuntimeConfig,
6693    registry: &Registry,
6694    process_liveness: &SupervisorProcessLiveness,
6695    snapshot: &SharedSnapshot,
6696    child: &mut Option<SupervisedChild>,
6697    drain_timeout: Duration,
6698) -> Result<(), SuperviseError> {
6699    // Restart cycles a running module; it must not silently start a disabled one.
6700    if !lock_snapshot(snapshot)?.enabled {
6701        return Err(SuperviseError::Disabled {
6702            module_id: spec.module_id.clone(),
6703        });
6704    }
6705    let stop_notice = begin_forwarding_drain_with_timeout(
6706        spec,
6707        runtime,
6708        registry,
6709        snapshot,
6710        None,
6711        RouteCloseReason::Restart,
6712        drain_timeout,
6713    )
6714    .await?;
6715
6716    if child.is_some() {
6717        drain_optional_child(
6718            &spec.module_id,
6719            spec.protocol,
6720            stop_notice,
6721            registry,
6722            runtime.forwarding.as_deref(),
6723            snapshot,
6724            &runtime.terminal_ring,
6725            &runtime.spawn_events,
6726            child,
6727            drain_timeout,
6728            ModuleState::Restarting,
6729            Some(true),
6730        )
6731        .await?;
6732    } else {
6733        update_snapshot(snapshot, Some(&spec.module_id), |state| {
6734            state.enabled = true;
6735            state.state = ModuleState::Restarting;
6736            clear_current_process_facts(state);
6737        })?;
6738        release_dead_registration(
6739            registry,
6740            runtime.forwarding.as_deref(),
6741            snapshot,
6742            &spec.module_id,
6743        )
6744        .await?;
6745    }
6746
6747    reset_restart_count(snapshot, &spec.module_id)?;
6748    process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
6749    schedule_respawn(
6750        runtime,
6751        snapshot,
6752        &spec.module_id,
6753        runtime.restart_policy.backoff,
6754        RespawnKind::Spawn,
6755    )
6756}
6757
6758async fn reload_child(
6759    spec: &ModuleSpec,
6760    runtime: &SupervisorRuntimeConfig,
6761    registry: &Registry,
6762    process_liveness: &SupervisorProcessLiveness,
6763    snapshot: &SharedSnapshot,
6764    child: &mut Option<SupervisedChild>,
6765) -> Result<(), SuperviseError> {
6766    // Reload cycles a running module; it must not silently start a disabled one.
6767    if !lock_snapshot(snapshot)?.enabled {
6768        return Err(SuperviseError::Disabled {
6769            module_id: spec.module_id.clone(),
6770        });
6771    }
6772    let stop_notice = begin_forwarding_drain(
6773        spec,
6774        runtime,
6775        registry,
6776        snapshot,
6777        Some(true),
6778        RouteCloseReason::Reload,
6779    )
6780    .await?;
6781
6782    if child.is_some() {
6783        drain_optional_child(
6784            &spec.module_id,
6785            spec.protocol,
6786            stop_notice,
6787            registry,
6788            runtime.forwarding.as_deref(),
6789            snapshot,
6790            &runtime.terminal_ring,
6791            &runtime.spawn_events,
6792            child,
6793            runtime.drain_timeout,
6794            ModuleState::Restarting,
6795            Some(true),
6796        )
6797        .await?;
6798    } else {
6799        update_snapshot(snapshot, Some(&spec.module_id), |state| {
6800            state.enabled = true;
6801            state.state = ModuleState::Restarting;
6802            clear_current_process_facts(state);
6803        })?;
6804        release_dead_registration(
6805            registry,
6806            runtime.forwarding.as_deref(),
6807            snapshot,
6808            &spec.module_id,
6809        )
6810        .await?;
6811    }
6812
6813    reset_restart_count(snapshot, &spec.module_id)?;
6814    process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
6815    schedule_respawn(
6816        runtime,
6817        snapshot,
6818        &spec.module_id,
6819        runtime.restart_policy.backoff,
6820        RespawnKind::Reload,
6821    )
6822}
6823
6824async fn finish_reload_child(
6825    spec: &ModuleSpec,
6826    runtime: &SupervisorRuntimeConfig,
6827    registry: &Registry,
6828    process_liveness: &SupervisorProcessLiveness,
6829    snapshot: &SharedSnapshot,
6830    child: &mut Option<SupervisedChild>,
6831) -> Result<(), SuperviseError> {
6832    process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
6833    let next_child = match spawn_and_mark_running(spec, runtime, snapshot) {
6834        Ok(next_child) => next_child,
6835        Err(err) => {
6836            return handle_reload_spawn_failure(
6837                spec,
6838                runtime,
6839                process_liveness,
6840                snapshot,
6841                child,
6842                format!("new child failed to spawn: {err}"),
6843            )
6844            .await;
6845        }
6846    };
6847    *child = Some(next_child);
6848
6849    let wait_outcome = {
6850        let active_child = child.as_mut().expect("new reload child was just stored");
6851        wait_for_registration_after_reload(
6852            registry,
6853            &spec.module_id,
6854            snapshot,
6855            active_child,
6856            REGISTRY_RELEASE_TIMEOUT,
6857        )
6858        .await?
6859    };
6860
6861    match wait_outcome {
6862        RegistrationWaitOutcome::Registered => {
6863            debug!(module_id = %spec.module_id, "supervised module reloaded and registered");
6864            Ok(())
6865        }
6866        RegistrationWaitOutcome::Exited(exit_report) => {
6867            if let Some(active_child) = child.as_mut() {
6868                active_child.drain_stderr(&spec.module_id).await;
6869            }
6870            // Keep the reaped child's roster guard until its terminal is written.
6871            // Shutdown waits on that guard, not on the child Option used for respawn.
6872            let mut exited_child = child.take().expect("exited reload child is still stored");
6873            #[cfg(test)]
6874            if let Some(gate) = &runtime.test_reload_exit_record_gate {
6875                gate.reached.notify_one();
6876                gate.resume.notified().await;
6877            }
6878            let result = handle_reload_child_registration_failure(
6879                spec,
6880                runtime,
6881                registry,
6882                process_liveness,
6883                snapshot,
6884                child,
6885                ReloadRegistrationFailure {
6886                    exit_report: registration_failure_exit_report(exit_report),
6887                    reason: exited_child
6888                        .spawn_failure
6889                        .clone()
6890                        .unwrap_or_else(|| "new child exited before registering".to_string()),
6891                },
6892            )
6893            .await;
6894            exited_child.release_roster();
6895            result
6896        }
6897        RegistrationWaitOutcome::TimedOut => {
6898            let mut timed_out_child = child
6899                .take()
6900                .expect("timed-out reload child is still running");
6901            timed_out_child
6902                .start_kill()
6903                .map_err(|source| SuperviseError::Kill {
6904                    module_id: spec.module_id.clone(),
6905                    source,
6906                })?;
6907            let status = timed_out_child
6908                .wait()
6909                .await
6910                .map_err(|source| SuperviseError::Wait {
6911                    module_id: spec.module_id.clone(),
6912                    source,
6913                })?;
6914            timed_out_child.drain_stderr(&spec.module_id).await;
6915            handle_reload_child_registration_failure(
6916                spec,
6917                runtime,
6918                registry,
6919                process_liveness,
6920                snapshot,
6921                child,
6922                ReloadRegistrationFailure {
6923                    exit_report: registration_failure_exit_report(classify_reaped_child_exit(
6924                        snapshot,
6925                        &timed_out_child,
6926                        &status,
6927                    )),
6928                    reason: format!(
6929                        "new child did not register within {:?}",
6930                        REGISTRY_RELEASE_TIMEOUT
6931                    ),
6932                },
6933            )
6934            .await
6935        }
6936    }
6937}
6938
6939async fn set_child_enabled(
6940    spec: &ModuleSpec,
6941    runtime: &SupervisorRuntimeConfig,
6942    registry: &Registry,
6943    process_liveness: &SupervisorProcessLiveness,
6944    snapshot: &SharedSnapshot,
6945    child: &mut Option<SupervisedChild>,
6946    enabled: bool,
6947) -> Result<bool, SuperviseError> {
6948    let (current_enabled, current_state, respawn_pending) = {
6949        let state = lock_snapshot(snapshot)?;
6950        (state.enabled, state.state, state.respawn_pending)
6951    };
6952    // `start` (enable on an already-enabled module) heals TERMINAL states instead
6953    // of no-op'ing: a module whose restart budget exhausted (Failed) or that exited
6954    // clean (Stopped) has no live process and no other in-band recovery — the
6955    // operator's start is the explicit recovery act and resets the budget. Without
6956    // this arm the only revival was subc-probe --supervisor-restart in a terminal,
6957    // which the 2026-07-14 aft outage proved is a trap when the failed module is
6958    // the one providing every agent's shell.
6959    let revive_terminal = enabled
6960        && current_enabled
6961        && child.is_none()
6962        && (matches!(current_state, ModuleState::Failed | ModuleState::Stopped)
6963            || (current_state == ModuleState::Restarting && !respawn_pending));
6964    if current_enabled == enabled && !revive_terminal {
6965        return Ok(false);
6966    }
6967
6968    if enabled {
6969        update_snapshot(snapshot, Some(&spec.module_id), |state| {
6970            state.enabled = true;
6971            state.state = ModuleState::Starting;
6972            clear_current_process_facts(state);
6973        })?;
6974        #[cfg(test)]
6975        if runtime.test_seed_stale_facts_before_enable_spawn {
6976            update_snapshot(snapshot, Some(&spec.module_id), |state| {
6977                state.process_alive = true;
6978                state.pid = Some(41);
6979                state.spawned_at_ms = Some(42);
6980                state.spawned_from = Some(PathBuf::from("/spawned/module"));
6981                state.spawned_file_identity = Some(SpawnedFileIdentity {
6982                    device: 43,
6983                    inode: 44,
6984                    #[cfg(windows)]
6985                    windows_image: None,
6986                });
6987            })?;
6988        }
6989        release_dead_registration(
6990            registry,
6991            runtime.forwarding.as_deref(),
6992            snapshot,
6993            &spec.module_id,
6994        )
6995        .await?;
6996        reset_restart_count(snapshot, &spec.module_id)?;
6997        process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
6998        let next_child = match spawn_and_mark_running(spec, runtime, snapshot) {
6999            Ok(next_child) => next_child,
7000            Err(err) => {
7001                if let Err(state_err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
7002                    state.state = ModuleState::Failed;
7003                    clear_current_process_facts(state);
7004                }) {
7005                    error!(module_id = %spec.module_id, error = %state_err, "failed to record enable spawn failure");
7006                }
7007                process_liveness.untrack_if_current(&spec.module_id, snapshot);
7008                return Err(err);
7009            }
7010        };
7011        *child = Some(next_child);
7012        debug!(module_id = %spec.module_id, "supervised module enabled");
7013        Ok(true)
7014    } else {
7015        let stop_notice = begin_forwarding_drain_if_configured(
7016            spec,
7017            runtime,
7018            registry,
7019            snapshot,
7020            Some(false),
7021            RouteCloseReason::Disable,
7022        )
7023        .await?;
7024        drain_optional_child(
7025            &spec.module_id,
7026            spec.protocol,
7027            stop_notice,
7028            registry,
7029            runtime.forwarding.as_deref(),
7030            snapshot,
7031            &runtime.terminal_ring,
7032            &runtime.spawn_events,
7033            child,
7034            runtime.drain_timeout,
7035            ModuleState::Disabled,
7036            Some(false),
7037        )
7038        .await?;
7039        debug!(module_id = %spec.module_id, "supervised module disabled");
7040        Ok(true)
7041    }
7042}
7043
7044#[allow(clippy::too_many_arguments)]
7045async fn on_child_exit(
7046    spec: &ModuleSpec,
7047    policy: RestartPolicy,
7048    registry: &Registry,
7049    snapshot: &SharedSnapshot,
7050    terminal_ring: &Arc<Mutex<TerminalRing>>,
7051    spawn_events: &SpawnEventFeed,
7052    roster: &ChildRoster,
7053    exit_report: ExitReport,
7054) -> NextAction {
7055    // Once the daemon has begun shutting down, no exit is a crash to recover
7056    // from: the module is exiting because the daemon is going away (EOF on its
7057    // connection, or a service manager signalling the whole cgroup). Record it
7058    // as such and never schedule a respawn, which would only start a process
7059    // for the shutdown to end again.
7060    if roster.is_closed() {
7061        return on_child_exit_during_daemon_shutdown(
7062            spec,
7063            registry,
7064            snapshot,
7065            terminal_ring,
7066            spawn_events,
7067            exit_report,
7068        )
7069        .await;
7070    }
7071    // Every stop the supervisor itself asks for (operator stop, disable,
7072    // restart, reload, swap, a health restart, a drain that runs out of budget)
7073    // takes the child out of the supervise loop and reaps it in
7074    // `drain_child_to_state`, and daemon shutdown is handled above. So an exit
7075    // that reaches this point was not requested by the daemon.
7076    //
7077    // For a subc-wire module a clean exit is still a stop: those modules are
7078    // written to re-raise SIGTERM, so a stray outside signal already reads as a
7079    // crash, and exiting 0 is a deliberate choice the module made. A
7080    // `protocol: "none"` module is a stock program we cannot change, and many
7081    // of them (nats-server among them) exit 0 on SIGTERM. Treating that as a
7082    // stop would leave the module down for good after any stray signal, so it
7083    // goes through the crash path instead: it spends restart budget, respawns
7084    // with the crash backoff, and ends `failed` when the budget runs out.
7085    let unrequested_clean_exit_of_protocol_none = exit_report.kind == ExitKind::Clean
7086        && running_protocol(spec, snapshot) == ModuleProtocol::None;
7087    match exit_report.kind {
7088        ExitKind::Clean if !unrequested_clean_exit_of_protocol_none => {
7089            info!(
7090                module_id = %spec.module_id,
7091                exit_code = ?exit_report.code,
7092                exit_signal = ?exit_report.signal,
7093                "supervised module exited cleanly"
7094            );
7095            if let Err(err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
7096                state.state = ModuleState::Stopped;
7097                clear_current_process_facts(state);
7098                state.last_exit = Some(exit_report.clone());
7099            }) {
7100                error!(module_id = %spec.module_id, error = %err, "failed to record clean module exit");
7101            }
7102            record_terminal(
7103                &spec.module_id,
7104                terminal_ring,
7105                spawn_events,
7106                &exit_report,
7107                TerminalDisposition::Stopped,
7108            );
7109            let registration_released = match wait_for_registration_release(
7110                registry,
7111                &spec.module_id,
7112                REGISTRY_RELEASE_TIMEOUT,
7113            )
7114            .await
7115            {
7116                Ok(()) => true,
7117                Err(err) => {
7118                    warn!(module_id = %spec.module_id, error = %err, "registration still active after clean exit");
7119                    false
7120                }
7121            };
7122            NextAction::Stop {
7123                registration_released,
7124            }
7125        }
7126        ExitKind::Clean | ExitKind::Crash => {
7127            if unrequested_clean_exit_of_protocol_none {
7128                warn!(
7129                    module_id = %spec.module_id,
7130                    exit_code = ?exit_report.code,
7131                    exit_signal = ?exit_report.signal,
7132                    "protocol-none module exited cleanly without a stop request; handling it as a crash"
7133                );
7134            } else {
7135                warn!(
7136                    module_id = %spec.module_id,
7137                    exit_code = ?exit_report.code,
7138                    exit_signal = ?exit_report.signal,
7139                    "supervised module exited abnormally (crash)"
7140                );
7141            }
7142            let mut restart_schedule = None;
7143            let mut disposition = TerminalDisposition::Disabled;
7144            // Set only when the budget is what stopped the module, so the
7145            // terminal record says which limit was hit rather than leaving
7146            // `failed` to be read as "crashed once, badly".
7147            let mut disposition_detail = lock_snapshot(snapshot)
7148                .ok()
7149                .and_then(|mut state| state.spawn_failure.take());
7150            let now = Instant::now();
7151            if let Err(err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
7152                clear_current_process_facts(state);
7153                state.last_exit = Some(exit_report.clone());
7154                if state.enabled {
7155                    if let Some(schedule) = state.next_crash_restart(&policy, now) {
7156                        state.state = ModuleState::Restarting;
7157                        restart_schedule = Some(schedule);
7158                        disposition = TerminalDisposition::Restarting;
7159                    } else {
7160                        disposition = TerminalDisposition::Failed;
7161                        let budget = policy.budget_exhausted_detail();
7162                        disposition_detail =
7163                            Some(disposition_detail.take().map_or_else(
7164                                || budget.clone(),
7165                                |cause| format!("{cause}; {budget}"),
7166                            ));
7167                    }
7168                } else {
7169                    state.state = ModuleState::Disabled;
7170                    disposition = TerminalDisposition::Disabled;
7171                }
7172            }) {
7173                error!(module_id = %spec.module_id, error = %err, "failed to record crashed module exit");
7174                return NextAction::Stop {
7175                    registration_released: false,
7176                };
7177            }
7178            if disposition == TerminalDisposition::Failed {
7179                // The window is in the message, not only in the fields: this line
7180                // is read in a scrollback where a bare `max_restarts=3` reads as a
7181                // lifetime cap and sends the operator looking for three crashes
7182                // that never happened together.
7183                error!(
7184                    module_id = %spec.module_id,
7185                    max_restarts = policy.max_restarts,
7186                    window_secs = policy.window.as_secs(),
7187                    "module stopped: {}",
7188                    policy.budget_exhausted_detail()
7189                );
7190            }
7191            let budget_exhausted = disposition == TerminalDisposition::Failed;
7192            let record_exit = || {
7193                record_terminal_with_detail(
7194                    &spec.module_id,
7195                    terminal_ring,
7196                    spawn_events,
7197                    &exit_report,
7198                    disposition,
7199                    disposition_detail,
7200                );
7201            };
7202            if budget_exhausted {
7203                // Publish Failed only after its terminal record is available.
7204                // Recording takes the event-feed lock, then ring -> journal
7205                // writer (with file I/O), all without the hot snapshot lock.
7206                // No lock is held when the final snapshot update runs, nor
7207                // across the registration-release await below. Commands and
7208                // health actions run on this same supervisor task, so none can
7209                // act on the old state during the write; process facts already
7210                // say the child is dead to concurrent liveness readers.
7211                // A journal error is retained in history, not returned. If
7212                // recording panics, still publish Failed before resuming the
7213                // original unwind rather than leaving a dead child Running.
7214                let recorded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(record_exit));
7215                if let Err(err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
7216                    state.state = ModuleState::Failed;
7217                }) {
7218                    error!(module_id = %spec.module_id, error = %err, "failed to publish exhausted restart budget");
7219                }
7220                if let Err(panic) = recorded {
7221                    std::panic::resume_unwind(panic);
7222                }
7223            } else {
7224                record_exit();
7225            }
7226
7227            if let Some(schedule) = restart_schedule {
7228                NextAction::Restart {
7229                    schedule: Some(schedule),
7230                }
7231            } else {
7232                let registration_released = match wait_for_registration_release(
7233                    registry,
7234                    &spec.module_id,
7235                    REGISTRY_RELEASE_TIMEOUT,
7236                )
7237                .await
7238                {
7239                    Ok(()) => true,
7240                    Err(err) => {
7241                        warn!(module_id = %spec.module_id, error = %err, "registration still active after failed module");
7242                        false
7243                    }
7244                };
7245                NextAction::Stop {
7246                    registration_released,
7247                }
7248            }
7249        }
7250        ExitKind::DeliberateSeverance => {
7251            warn!(
7252                module_id = %spec.module_id,
7253                exit_code = ?exit_report.code,
7254                exit_signal = ?exit_report.signal,
7255                "supervised module exited after deliberate connection severance"
7256            );
7257            let mut should_restart = false;
7258            let mut disposition = TerminalDisposition::Disabled;
7259            if let Err(err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
7260                clear_current_process_facts(state);
7261                state.last_exit = Some(exit_report.clone());
7262                state.lifetime_restarts += 1;
7263                if state.enabled {
7264                    state.state = ModuleState::Restarting;
7265                    should_restart = true;
7266                    disposition = TerminalDisposition::Restarting;
7267                } else {
7268                    state.state = ModuleState::Disabled;
7269                }
7270            }) {
7271                error!(module_id = %spec.module_id, error = %err, "failed to record deliberately severed module exit");
7272                return NextAction::Stop {
7273                    registration_released: false,
7274                };
7275            }
7276            record_terminal(
7277                &spec.module_id,
7278                terminal_ring,
7279                spawn_events,
7280                &exit_report,
7281                disposition,
7282            );
7283
7284            if should_restart {
7285                NextAction::Restart { schedule: None }
7286            } else {
7287                let registration_released = match wait_for_registration_release(
7288                    registry,
7289                    &spec.module_id,
7290                    REGISTRY_RELEASE_TIMEOUT,
7291                )
7292                .await
7293                {
7294                    Ok(()) => true,
7295                    Err(err) => {
7296                        warn!(module_id = %spec.module_id, error = %err, "registration still active after deliberately severed module exit");
7297                        false
7298                    }
7299                };
7300                NextAction::Stop {
7301                    registration_released,
7302                }
7303            }
7304        }
7305    }
7306}
7307
7308async fn on_child_exit_during_daemon_shutdown(
7309    spec: &ModuleSpec,
7310    registry: &Registry,
7311    snapshot: &SharedSnapshot,
7312    terminal_ring: &Arc<Mutex<TerminalRing>>,
7313    spawn_events: &SpawnEventFeed,
7314    exit_report: ExitReport,
7315) -> NextAction {
7316    info!(
7317        module_id = %spec.module_id,
7318        exit_code = ?exit_report.code,
7319        exit_signal = ?exit_report.signal,
7320        exit_kind = ?exit_report.kind,
7321        "supervised module exited during daemon shutdown; not restarting it"
7322    );
7323    if let Err(err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
7324        state.state = ModuleState::Stopped;
7325        clear_current_process_facts(state);
7326        state.last_exit = Some(exit_report.clone());
7327    }) {
7328        error!(module_id = %spec.module_id, error = %err, "failed to record module exit during daemon shutdown");
7329    }
7330    record_terminal(
7331        &spec.module_id,
7332        terminal_ring,
7333        spawn_events,
7334        &exit_report,
7335        TerminalDisposition::DaemonShutdown,
7336    );
7337    let registration_released =
7338        wait_for_registration_release(registry, &spec.module_id, REGISTRY_RELEASE_TIMEOUT)
7339            .await
7340            .is_ok();
7341    NextAction::Stop {
7342        registration_released,
7343    }
7344}
7345
7346fn record_wait_error_terminal(
7347    module_id: &str,
7348    terminal_ring: &Arc<Mutex<TerminalRing>>,
7349    spawn_events: &SpawnEventFeed,
7350) {
7351    record_terminal(
7352        module_id,
7353        terminal_ring,
7354        spawn_events,
7355        &wait_error_exit_report(),
7356        TerminalDisposition::Failed,
7357    );
7358}
7359
7360fn record_terminal(
7361    module_id: &str,
7362    terminal_ring: &Arc<Mutex<TerminalRing>>,
7363    spawn_events: &SpawnEventFeed,
7364    exit_report: &ExitReport,
7365    disposition: TerminalDisposition,
7366) {
7367    record_terminal_with_detail(
7368        module_id,
7369        terminal_ring,
7370        spawn_events,
7371        exit_report,
7372        disposition,
7373        None,
7374    );
7375}
7376
7377/// The ring lock is held only to capture the read (see
7378/// `TerminalJournal::capture_read`), so this module's exits keep recording
7379/// while the journal files are read. Blocking: it reads files.
7380fn durable_terminal_history_of(
7381    terminal_ring: &Mutex<TerminalRing>,
7382    module_id: &str,
7383) -> subc_control::TerminalHistory {
7384    let read = terminal_ring
7385        .lock()
7386        .unwrap_or_else(|p| p.into_inner())
7387        .capture_durable_history();
7388    read.read(module_id)
7389}
7390
7391fn record_terminal_with_detail(
7392    module_id: &str,
7393    terminal_ring: &Arc<Mutex<TerminalRing>>,
7394    spawn_events: &SpawnEventFeed,
7395    exit_report: &ExitReport,
7396    disposition: TerminalDisposition,
7397    disposition_detail: Option<String>,
7398) {
7399    spawn_events.emit_exited(module_id, exit_report.code, exit_report.signal);
7400    let record = TerminalRecord {
7401        exit_code: exit_report.code,
7402        exit_signal: exit_report.signal,
7403        at_ms: exit_report.at_ms,
7404        disposition,
7405        exit_kind: exit_report.kind.into(),
7406        disposition_detail,
7407    };
7408    terminal_ring
7409        .lock()
7410        .unwrap_or_else(|poisoned| poisoned.into_inner())
7411        .record_exit(module_id, record);
7412}
7413
7414fn untrack_if_registration_released(
7415    process_liveness: &SupervisorProcessLiveness,
7416    registry: &Registry,
7417    module_id: &str,
7418    snapshot: &SharedSnapshot,
7419) {
7420    match registry.get_module(module_id) {
7421        Ok(None) => process_liveness.untrack_if_current(module_id, snapshot),
7422        Ok(Some(_)) => {}
7423        Err(err) => {
7424            warn!(module_id, error = %err, "could not determine whether supervisor liveness can be untracked");
7425        }
7426    }
7427}
7428
7429/// The child's environment plan: inherit the parent's, drop ambient `CK_LOG`,
7430/// then apply the module's configured entries minus daemon-private capture keys.
7431///
7432/// Separated from `spawn_child` only so it can be asserted without spawning a
7433/// process — a duplicate of this logic in a test would pass while the real one
7434/// drifted, which is the defect class this function exists to avoid.
7435/// The subc-wire half of a spawn: `--subc <connection file>` and the launch
7436/// nonce. A `protocol: "none"` module gets neither, because it cannot use
7437/// either and the argument would stop a stock binary from starting at all.
7438/// `SUBC_MODULE_ID` is set on every path since an unread variable is inert.
7439///
7440/// The plain-spawn form, kept for the tests that assert its plan; spawns go
7441/// through [`apply_wire_spawn_args_for_role`].
7442#[cfg(test)]
7443fn apply_wire_spawn_args(
7444    command: &mut Command,
7445    spec: &ModuleSpec,
7446    connection_file_path: Option<&std::path::Path>,
7447    handle: Option<&SupervisorHandle>,
7448) -> Result<Option<NonceHandoff>, SuperviseError> {
7449    apply_wire_spawn_args_for_role(
7450        command,
7451        spec,
7452        connection_file_path,
7453        handle,
7454        SpawnRole::Plain,
7455    )
7456}
7457
7458/// The read end of a spawn's launch-nonce pipe, prepared by
7459/// [`apply_wire_spawn_args_for_role`] and installed as the child's descriptor 3
7460/// by the last pre-exec step, just before `spawn()`. Windows prepares a named
7461/// server before spawn and authenticates its client after the child exists.
7462#[cfg(unix)]
7463type NonceHandoff = subc_os::LaunchNonceHandoff;
7464#[cfg(windows)]
7465type NonceHandoff = subc_os::LaunchNoncePipeHandoff;
7466#[cfg(not(any(unix, windows)))]
7467type NonceHandoff = std::convert::Infallible;
7468
7469/// Create the launch nonce that a new module process must present when it
7470/// registers or opens a consumer connection, including a replacement process
7471/// started alongside a still-serving module.
7472///
7473/// A plain spawn replaces the module's recorded nonce. A swap candidate records
7474/// a separate candidate token so the still-serving incumbent and its consumers
7475/// keep their nonce. Both records are installed before the process exists, so
7476/// the child's initial HELLO registration cannot arrive ahead of its nonce.
7477///
7478/// On Unix the nonce is delivered only through a pipe. It is written into
7479/// a pipe whose read end the child gets as descriptor 3, named by
7480/// `SUBC_LAUNCH_NONCE_FD=3:<pipe inode>`: unlike the environment, another
7481/// process of the same user cannot read it with `ps eww`. That handoff is
7482/// returned rather than installed here, because installing it replaces
7483/// whatever the child has at descriptor 3 and so must be the last pre-exec
7484/// step. Later in `spawn_child_in_slot`, `apply_cgroup_placement` first registers
7485/// the Linux callback that writes the process into its cgroup. Only after that
7486/// does `handoff.install_last` register the descriptor-3 replacement, so it
7487/// cannot close a descriptor the cgroup callback still needs.
7488/// Windows keeps SUBC_LAUNCH_NONCE as well until every module reads the named pipe.
7489fn apply_wire_spawn_args_for_role(
7490    command: &mut Command,
7491    spec: &ModuleSpec,
7492    connection_file_path: Option<&std::path::Path>,
7493    handle: Option<&SupervisorHandle>,
7494    role: SpawnRole,
7495) -> Result<Option<NonceHandoff>, SuperviseError> {
7496    command.env(SUBC_MODULE_ID_ENV, &spec.module_id);
7497    // SUBC_LAUNCH_NONCE_FD is removed for every child, `protocol: "none"`
7498    // included: a daemon started from a module's process tree inherits it,
7499    // and passing it on would point the child at a descriptor it does not
7500    // have.
7501    command.env_remove(subc_os::LAUNCH_NONCE_FD_ENV);
7502    #[cfg(windows)]
7503    {
7504        command.env_remove(subc_os::LAUNCH_NONCE_PIPE_ENV);
7505        command.env_remove(subc_os::LAUNCH_NONCE_PIPE_FALLBACK_ENV);
7506    }
7507    // Remove inherited or configured copies too: withholding must mean absent.
7508    command.env_remove(SUBC_LAUNCH_NONCE_ENV);
7509    if spec.protocol == ModuleProtocol::None {
7510        return Ok(None);
7511    }
7512    if let Some(connection_file_path) = connection_file_path {
7513        command.arg(SUBC_ARG).arg(connection_file_path);
7514    }
7515
7516    // Every subc-wire spawn receives a fresh one-time launch nonce for consumer
7517    // route.open attestation. Reserved modules additionally use the same nonce
7518    // for HELLO id-squatting protection. A respawn rotates both records.
7519    let nonce = generate_launch_nonce()?;
7520    if let Some(handle) = handle {
7521        match role {
7522            SpawnRole::Plain => {
7523                handle.set_spawn_nonce(&spec.module_id, nonce.clone());
7524                if spec.reserved {
7525                    handle.set_reserved_nonce(&spec.module_id, nonce.clone());
7526                }
7527            }
7528            SpawnRole::SwapCandidate => handle.open_swap(&spec.module_id, nonce.clone()),
7529        }
7530    }
7531    #[cfg(unix)]
7532    let handoff = {
7533        let handoff =
7534            subc_os::LaunchNonceHandoff::new(&nonce).map_err(|source| SuperviseError::Spawn {
7535                program: spec.program.clone(),
7536                source,
7537                cgroup_path: None,
7538            })?;
7539        command.env(subc_os::LAUNCH_NONCE_FD_ENV, handoff.fd_env_value());
7540        Some(handoff)
7541    };
7542    #[cfg(windows)]
7543    let handoff = prepare_windows_nonce_pipe(command, spec, &nonce);
7544    #[cfg(not(any(unix, windows)))]
7545    let handoff = None;
7546    // Older modules still read SUBC_LAUNCH_NONCE to register. Keep that copy
7547    // until the daemon's per-spawn delivery records show that every running
7548    // Windows module has read its secret from the named pipe.
7549    #[cfg(not(unix))]
7550    command.env(SUBC_LAUNCH_NONCE_ENV, nonce);
7551    Ok(handoff)
7552}
7553
7554fn apply_child_env(command: &mut Command, spec: &ModuleSpec) {
7555    command.env_remove(CK_LOG_ENV);
7556    // The spawn role is the supervisor's to set, and only on a swap candidate
7557    // (see `apply_spawn_role`). Removing it here, rather than just not setting
7558    // it, is what makes it absent on a plain spawn: the daemon's own
7559    // environment could carry it, and so could a spec built outside daemon
7560    // config (config refuses it as an `env` key). A module reading it on a
7561    // plain restart would pick the long swap budget and leave callers waiting.
7562    command.env_remove(SUBC_SPAWN_ROLE_ENV);
7563    for (key, value) in &spec.env {
7564        // cortexkit-log currently exposes retention only as a Rust struct, not
7565        // environment names. These values are daemon-private sink metadata and
7566        // must never become a public child-process contract by being inherited.
7567        if matches!(
7568            key.as_str(),
7569            CAPTURE_MAX_FILE_MB_ENV | CAPTURE_KEEP_ENV | CAPTURE_MAX_AGE_DAYS_ENV
7570        ) || key == SUBC_SPAWN_ROLE_ENV
7571        {
7572            continue;
7573        }
7574        command.env(key, value);
7575    }
7576}
7577
7578#[cfg(windows)]
7579fn prepare_windows_nonce_pipe(
7580    command: &mut Command,
7581    spec: &ModuleSpec,
7582    nonce: &str,
7583) -> Option<NonceHandoff> {
7584    match subc_os::LaunchNoncePipeHandoff::new(nonce) {
7585        Ok(handoff) => {
7586            command.env(subc_os::LAUNCH_NONCE_PIPE_ENV, handoff.name());
7587            // Let a failed pipe read use SUBC_LAUNCH_NONCE so the process can
7588            // still register until every module reads the pipe. A reader without
7589            // this variable set to env returns the pipe error instead of using
7590            // an inherited or configured environment copy.
7591            command.env(subc_os::LAUNCH_NONCE_PIPE_FALLBACK_ENV, "env");
7592            Some(handoff)
7593        }
7594        Err(error) => {
7595            warn!(module_id = %spec.module_id, %error, "could not prepare launch nonce pipe; retaining the environment handoff");
7596            None
7597        }
7598    }
7599}
7600
7601#[cfg(windows)]
7602fn start_windows_nonce_pipe(
7603    handoff: Option<NonceHandoff>,
7604    pid: u32,
7605    spec: &ModuleSpec,
7606) -> Option<subc_os::LaunchNoncePipeDelivery> {
7607    // Close an unread pipe after REGISTRY_RELEASE_TIMEOUT (one second), the
7608    // same maximum wait used for registration during a reload. Older modules
7609    // may register using SUBC_LAUNCH_NONCE without ever connecting to the pipe.
7610    handoff.and_then(|handoff| match handoff.serve(pid, REGISTRY_RELEASE_TIMEOUT) {
7611        Ok(delivery) => Some(delivery),
7612        Err(error) => {
7613            warn!(module_id = %spec.module_id, %error, "could not serve launch nonce pipe; retaining the environment handoff");
7614            None
7615        }
7616    })
7617}
7618
7619fn observed_launch_nonce_source(snapshot: &SupervisorSnapshot) -> Option<String> {
7620    #[cfg(windows)]
7621    if snapshot.process_alive && snapshot.spawned_protocol == Some(ModuleProtocol::Subc) {
7622        return Some(
7623            if snapshot
7624                .pipe_consumed
7625                .as_ref()
7626                .is_some_and(|consumed| consumed.get().is_some())
7627            {
7628                "pipe"
7629            } else {
7630                "env"
7631            }
7632            .to_owned(),
7633        );
7634    }
7635    let _ = snapshot;
7636    None
7637}
7638
7639/// Which slot a spawn fills: the module's ordinary one, or the candidate slot
7640/// of a blue/green swap.
7641#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7642enum SpawnRole {
7643    Plain,
7644    SwapCandidate,
7645}
7646
7647/// Set the spawn role for a swap candidate. A plain spawn gets nothing here;
7648/// `apply_child_env` has already removed the variable for every spawn.
7649fn apply_spawn_role(command: &mut Command, role: SpawnRole) {
7650    if role == SpawnRole::SwapCandidate {
7651        command.env(SUBC_SPAWN_ROLE_ENV, SPAWN_ROLE_SWAP_CANDIDATE);
7652    }
7653}
7654
7655fn spawn_child(
7656    spec: &ModuleSpec,
7657    connection_file_path: Option<&std::path::Path>,
7658    handle: Option<&SupervisorHandle>,
7659    ring: &Arc<Mutex<StderrRing>>,
7660    capture_logs_dir: Option<&std::path::Path>,
7661    roster: &ChildRoster,
7662    #[cfg(target_os = "linux")] cgroup_placement: Option<&subc_cgroup::Placement>,
7663) -> Result<SupervisedChild, SuperviseError> {
7664    spawn_child_in_slot(
7665        spec,
7666        connection_file_path,
7667        handle,
7668        ring,
7669        capture_logs_dir,
7670        roster,
7671        #[cfg(target_os = "linux")]
7672        cgroup_placement,
7673        SpawnRole::Plain,
7674        false,
7675    )
7676}
7677
7678/// Spawn one process of `spec` into a slot.
7679///
7680/// `alternate_slot` picks the process's cgroup label on Linux (see `swap::cgroup_name`).
7681/// A swap candidate needs a different cgroup from the process it is replacing,
7682/// which is still alive: in the same cgroup the two would be one kill domain,
7683/// and killing a failed candidate could take the incumbent with it.
7684///
7685/// The stderr capture file is `<module_id>.stderr.log` for every process of
7686/// the module, whichever slot it is in, because that is the one file
7687/// `ck module logs` reads. During a swap's overlap both processes append to it;
7688/// the daemon writes whole lines, so the two interleave by line, which is also
7689/// the merged view an operator wants while a swap runs.
7690#[allow(clippy::too_many_arguments)]
7691fn spawn_child_in_slot(
7692    spec: &ModuleSpec,
7693    connection_file_path: Option<&std::path::Path>,
7694    handle: Option<&SupervisorHandle>,
7695    ring: &Arc<Mutex<StderrRing>>,
7696    capture_logs_dir: Option<&std::path::Path>,
7697    roster: &ChildRoster,
7698    #[cfg(target_os = "linux")] cgroup_placement: Option<&subc_cgroup::Placement>,
7699    role: SpawnRole,
7700    alternate_slot: bool,
7701) -> Result<SupervisedChild, SuperviseError> {
7702    if roster.is_closed() {
7703        return Err(SuperviseError::Spawn {
7704            program: spec.program.clone(),
7705            source: io::Error::other("the daemon is shutting down; not starting a new process"),
7706            cgroup_path: None,
7707        });
7708    }
7709    #[cfg(target_os = "linux")]
7710    let cgroup_name = {
7711        // Slot names alone are not kill domains: a retired incumbent may still
7712        // be draining when a later enable/restart spawns into the same slot.
7713        // Decimal entropy keeps the suffix unambiguous; Placement performs
7714        // the module-id escaping and constructs the filesystem path.
7715        if cgroup_placement.is_none() {
7716            swap::cgroup_name(&spec.module_id, alternate_slot)
7717        } else {
7718            let nonce = generate_launch_nonce()?;
7719            let suffix = u128::from_str_radix(&nonce[..32], 16).expect("hex launch nonce");
7720            // Leave room for byte escaping and the suffix under NAME_MAX. The
7721            // label is only for humans; the nonce identifies the kill domain.
7722            let mut end = spec.module_id.len().min(64);
7723            while !spec.module_id.is_char_boundary(end) {
7724                end -= 1;
7725            }
7726            format!(
7727                "{}_{suffix}",
7728                swap::cgroup_name(&spec.module_id[..end], alternate_slot)
7729            )
7730        }
7731    };
7732    #[cfg(not(target_os = "linux"))]
7733    let _ = alternate_slot;
7734    #[cfg(target_os = "macos")]
7735    let (mut command, privacy_exec, exec_ack) = privacy_command(spec, roster)?;
7736    #[cfg(windows)]
7737    let image_capture = windows_spawn_capture(spec).map_err(|source| SuperviseError::Spawn {
7738        program: spec.program.clone(),
7739        source,
7740        cgroup_path: None,
7741    })?;
7742    #[cfg(windows)]
7743    let mut command = Command::new(image_capture.path());
7744    #[cfg(not(any(target_os = "macos", windows)))]
7745    let mut command = Command::new(&spec.program);
7746    command.args(&spec.args);
7747    // AMBIENT `CK_LOG` MUST NOT LEAK INTO AN OTHERWISE UNCONFIGURED MODULE — but
7748    // that is the whole of the intent, so remove that one key rather than the
7749    // environment.
7750    //
7751    // This was `env_clear()` from 0.17.41 until 0.18.3, which achieved the goal
7752    // and took the POSIX environment with it. Modules spawned that way had no
7753    // HOME, XDG_RUNTIME_DIR, TMPDIR or USER, and the consequences ran past
7754    // logging:
7755    //
7756    //   * `connection_file::discover` reads XDG_RUNTIME_DIR and HOME, so with
7757    //     both unset it fell back to the temp dir alone and `ck` could not find
7758    //     a daemon running on the same machine from inside any module's process
7759    //     tree — reporting a path the file has never lived at, which reads as
7760    //     "the daemon did not write its file".
7761    //   * `default_data_home()` with HOME and XDG_DATA_HOME both unset returns
7762    //     the RELATIVE `.local/share`, so a module deriving its own store path
7763    //     resolved it against its own CWD. That is the store-fragmentation
7764    //     defect the daemon already refuses in config (`parse_doc` rejects a
7765    //     relative `storage.data_home`) arriving by derivation instead.
7766    //   * anything a module spawns inherited it: git without ~/.gitconfig,
7767    //     cargo without CARGO_HOME, ssh, python user dirs — all degrading
7768    //     quietly rather than erroring.
7769    //
7770    // Reported by iceteaSA as #104 after deploying 0.18.2, where `ck daemon`
7771    // offered one candidate under /tmp while the file sat in /run/user/1000.
7772    //
7773    // A configured module is unaffected either way: `module_spec()` puts the
7774    // resolved CK_LOG into `spec.env`, which is applied below and therefore
7775    // wins over anything ambient.
7776    apply_child_env(&mut command, spec);
7777    apply_spawn_role(&mut command, role);
7778    let nonce_handoff =
7779        apply_wire_spawn_args_for_role(&mut command, spec, connection_file_path, handle, role)?;
7780
7781    #[cfg(target_os = "linux")]
7782    let cgroup_path = cgroup_placement
7783        .map(|placement| placement.module_path(&cgroup_name))
7784        .transpose()
7785        .map_err(|source| SuperviseError::Cgroup {
7786            module_id: spec.module_id.clone(),
7787            source,
7788        })?;
7789    #[cfg(not(target_os = "linux"))]
7790    let cgroup_path: Option<PathBuf> = None;
7791    #[cfg(target_os = "linux")]
7792    if let Some(path) = &cgroup_path {
7793        if let Err(error) = apply_cgroup_placement(&mut command, spec, path) {
7794            if let Some(placement) = cgroup_placement {
7795                remove_module_cgroup(placement, &cgroup_name);
7796            }
7797            return Err(error);
7798        }
7799    }
7800
7801    let output_sink = if let Some(logs_dir) = capture_logs_dir {
7802        let path = logs_dir.join(format!("{}.stderr.log", spec.module_id));
7803        match ChildOutputSink::open(&path, capture_retention(spec)) {
7804            Ok(sink) => sink,
7805            Err(error) => {
7806                warn!(
7807                    module_id = %spec.module_id,
7808                    path = %path.display(),
7809                    error = %error,
7810                    "could not open child output capture file; forwarding to stderr"
7811                );
7812                ChildOutputSink::Stderr
7813            }
7814        }
7815    } else {
7816        ChildOutputSink::Stderr
7817    };
7818
7819    command.stdout(Stdio::piped());
7820    command.stderr(Stdio::piped());
7821    command.kill_on_drop(true);
7822    // EACH MODULE LEADS ITS OWN PROCESS GROUP (the child calls setpgid(0, 0)
7823    // before exec). In the daemon's group, a service manager that kills the
7824    // job's process group when the daemon exits (launchd's default) killed
7825    // every module at the same moment its control connection closed, so no
7826    // module ever ran its EOF teardown on a daemon stop. Outside that group a
7827    // module is reached only by the daemon: the EOF it sees when its
7828    // connection closes, and the bounded stop in `child_roster` for anything
7829    // still running after that. On Linux this composes with the cgroup
7830    // placement above: that is a pre_exec write to cgroup.procs, std performs
7831    // setpgid in the child before running pre_exec callbacks, and the two
7832    // change independent process attributes.
7833    //
7834    // stdin is /dev/null because a process outside the terminal's foreground
7835    // group is stopped (SIGTTIN) if it reads the terminal, which a daemon run
7836    // by hand would otherwise hand down. Under a service manager stdin is
7837    // already /dev/null.
7838    #[cfg(unix)]
7839    command.process_group(0);
7840    command.stdin(Stdio::null());
7841    // The LAST pre-exec step, after the cgroup placement above: installing the
7842    // nonce at descriptor 3 replaces whatever the child had there, which could
7843    // be the descriptor an earlier step writes through.
7844    #[cfg(unix)]
7845    if let Some(handoff) = nonce_handoff {
7846        handoff.install_last(command.as_std_mut());
7847    }
7848    #[cfg(not(any(unix, windows)))]
7849    let _ = nonce_handoff;
7850
7851    // Containment, step 1 of 3 (issue #109): create the child suspended so it
7852    // cannot run a single instruction -- and therefore cannot spawn a
7853    // grandchild -- before it is in the job. See `contain_spawned_child` for the
7854    // other two steps and why the window matters.
7855    #[cfg(windows)]
7856    subc_jobobject::suspend_on_create_async(&mut command);
7857    let mut child = match command.spawn() {
7858        Ok(child) => child,
7859        Err(source) => {
7860            #[cfg(target_os = "linux")]
7861            if let Some(placement) = cgroup_placement {
7862                remove_module_cgroup(placement, &cgroup_name);
7863            }
7864            return Err(SuperviseError::Spawn {
7865                program: spec.program.clone(),
7866                source,
7867                cgroup_path,
7868            });
7869        }
7870    };
7871    // The parent must close its writer now: the acknowledgement pipe reports EOF
7872    // only when every writer is gone, and the child's copy closes when the
7873    // trampoline replaces itself with the module. Command holds only an integer
7874    // in its pre_exec callback, not another writer.
7875    #[cfg(target_os = "macos")]
7876    drop(exec_ack);
7877
7878    // Bind while the child is still suspended, before job assignment resumes it.
7879    // Keeping the executable handle open across CreateProcessW denies rename
7880    // and deletion, so the file cannot be exchanged during spawn. Bind that
7881    // file ID to the process handle returned by spawn, never a reopened PID.
7882    #[cfg(windows)]
7883    let windows_image =
7884        Arc::new(
7885            image_capture
7886                .bind_tokio(&child)
7887                .map_err(|source| SuperviseError::Spawn {
7888                    program: spec.program.clone(),
7889                    source,
7890                    cgroup_path: None,
7891                })?,
7892        );
7893    // Containment, steps 2 and 3: assign while suspended, then resume.
7894    #[cfg(windows)]
7895    let job = contain_spawned_child(&child, spec)?;
7896    let spawned_at_ms = unix_ms_now();
7897    #[cfg(not(windows))]
7898    let spawned_from = spec.program.clone();
7899    #[cfg(windows)]
7900    let spawned_from = windows_image.path().to_path_buf();
7901    #[cfg(not(windows))]
7902    let spawned_file_identity = spawned_file_identity(&spawned_from);
7903    #[cfg(windows)]
7904    let spawned_file_identity = Some(SpawnedFileIdentity {
7905        device: 0,
7906        inode: 0,
7907        windows_image: Some(Arc::clone(&windows_image)),
7908    });
7909    let pid = child.id().ok_or_else(|| SuperviseError::Spawn {
7910        program: spec.program.clone(),
7911        source: io::Error::other("spawned child exposed no live pid"),
7912        cgroup_path: cgroup_path.clone(),
7913    })?;
7914    #[cfg(windows)]
7915    let nonce_delivery = start_windows_nonce_pipe(nonce_handoff, pid, spec);
7916    #[cfg(not(windows))]
7917    let process_start_time = crate::provenance::process_start_time(pid);
7918    #[cfg(windows)]
7919    let process_start_time = Some(windows_image.start_time());
7920    let process_identity = process_start_time.map(|start_time| ProcessIdentity { pid, start_time });
7921    #[cfg(all(test, target_os = "macos"))]
7922    privacy_exec_boundary_tests::before_image_sample(spec, pid);
7923    // Unix spawn returns after exec's error pipe closes. The kernel image is
7924    // therefore the executable to compare during a future orphan sweep: PATH
7925    // lookup and shebang interpretation may select a different file from the
7926    // configured program. Keep the literal program's identity for provenance,
7927    // but never use it as proof that a recorded pid may be signalled.
7928    #[cfg(not(windows))]
7929    let recorded_image = observe_spawned_image(pid);
7930    // spawn() confirms only the first exec, into the trampoline. Never persist
7931    // the trampoline image; the asynchronous acknowledgement publishes the
7932    // module image once the trampoline has replaced itself with the module.
7933    #[cfg(target_os = "macos")]
7934    let recorded_image = if privacy_exec.is_some() {
7935        None
7936    } else {
7937        recorded_image
7938    };
7939    #[cfg(target_os = "linux")]
7940    let recorded_cgroup_name = cgroup_path.as_ref().map(|_| cgroup_name.clone());
7941    #[cfg(not(target_os = "linux"))]
7942    let recorded_cgroup_name = None;
7943    let roster_guard = roster.admit(
7944        spec.module_id.clone(),
7945        pid,
7946        spec.protocol,
7947        process_start_time,
7948        crate::child_roster::RecordedIdentity {
7949            #[cfg(not(windows))]
7950            start_time: recorded_image.map(|image| image.start_time),
7951            #[cfg(windows)]
7952            start_time: process_start_time,
7953            #[cfg(not(windows))]
7954            executable: recorded_image
7955                .and_then(|image| image.executable)
7956                .map(crate::live_children::ExecutableIdentity::from),
7957            #[cfg(windows)]
7958            executable: Some(crate::live_children::ExecutableIdentity::from(
7959                windows_image.identity(),
7960            )),
7961            cgroup_name: recorded_cgroup_name,
7962            #[cfg(target_os = "linux")]
7963            cgroup_placement: cgroup_placement.cloned(),
7964        },
7965    );
7966    // The check at the top of this function can pass just before daemon
7967    // shutdown begins, and the process is only in the roster from here on.
7968    // The shutdown stop returns as soon as it finds the roster empty, so a
7969    // process admitted after that look would outlive the daemon. The roster
7970    // is closed before the stop first reads it and admission happens under
7971    // the roster's lock, so either the stop sees this process or this check
7972    // sees the roster closed: end the process now rather than start a module
7973    // the daemon is about to stop.
7974    if roster.is_closed() {
7975        // This child was never admitted, so there is no module protocol shutdown to wait for.
7976        #[cfg(target_os = "linux")]
7977        kill_module_cgroup(cgroup_placement, &cgroup_name);
7978        if let Err(error) = child.start_kill() {
7979            debug!(module_id = %spec.module_id, pid, %error, "kill of a process spawned during daemon shutdown failed; it may already have exited");
7980        }
7981        #[cfg(target_os = "linux")]
7982        if let Some(placement) = cgroup_placement {
7983            // This spawn was never admitted, so shutdown has no roster entry
7984            // to await. Do not detach its cleanup: the runtime could exit
7985            // before that task reaps the rejected child and removes its group.
7986            while matches!(child.try_wait(), Ok(None)) {
7987                std::thread::yield_now();
7988            }
7989            if matches!(
7990                subc_cgroup::kill_module(Some(placement), &cgroup_name),
7991                subc_cgroup::KillOutcome::Killed
7992            ) {
7993                if let Ok(path) = placement.module_path(&cgroup_name) {
7994                    while std::fs::read_to_string(path.join("cgroup.events"))
7995                        .ok()
7996                        .is_some_and(|events| events.lines().any(|line| line == "populated 1"))
7997                    {
7998                        std::thread::yield_now();
7999                    }
8000                }
8001            }
8002            remove_module_cgroup(placement, &cgroup_name);
8003        }
8004        drop(roster_guard);
8005        return Err(SuperviseError::Spawn {
8006            program: spec.program.clone(),
8007            source: io::Error::other(
8008                "the daemon began shutting down while this process was starting; ended it",
8009            ),
8010            cgroup_path,
8011        });
8012    }
8013
8014    let stdout_pump = match child.stdout.take() {
8015        Some(stdout) => Some(tokio::spawn(pump_stdout_to(stdout, output_sink.clone()))),
8016        None => {
8017            warn!(
8018                module_id = %spec.module_id,
8019                "spawned child exposed no stdout pipe; file capture will be incomplete"
8020            );
8021            None
8022        }
8023    };
8024    let stderr_pump = match child.stderr.take() {
8025        Some(stderr) => {
8026            let generation = ring
8027                .lock()
8028                .unwrap_or_else(|poisoned| poisoned.into_inner())
8029                .begin_process();
8030            Some(StderrPump {
8031                task: tokio::spawn(pump_stderr_to(
8032                    stderr,
8033                    Arc::clone(ring),
8034                    generation,
8035                    output_sink,
8036                )),
8037                generation,
8038            })
8039        }
8040        None => {
8041            // Spawning succeeded but the pipe did not materialise. Recording it as
8042            // uncaptured keeps the tail honest: the alternative is an empty tail
8043            // that reads as a module which printed nothing.
8044            ring.lock()
8045                .unwrap_or_else(|poisoned| poisoned.into_inner())
8046                .mark_not_captured("stderr pipe was not available on spawn");
8047            warn!(
8048                module_id = %spec.module_id,
8049                "spawned child exposed no stderr pipe; tail will be unavailable"
8050            );
8051            None
8052        }
8053    };
8054
8055    Ok(SupervisedChild {
8056        child,
8057        #[cfg(windows)]
8058        nonce_delivery,
8059        protocol: spec.protocol,
8060        #[cfg(target_os = "linux")]
8061        module_id: cgroup_name,
8062        #[cfg(target_os = "linux")]
8063        cgroup_placement: cgroup_placement.cloned(),
8064        #[cfg(windows)]
8065        job,
8066        stdout_pump,
8067        stderr_pump,
8068        stderr_ring: Arc::clone(ring),
8069        spawned_at_ms,
8070        spawned_from,
8071        spawned_file_identity,
8072        process_start_time,
8073        process_identity,
8074        pid,
8075        roster_guard: Some(roster_guard),
8076        #[cfg(target_os = "macos")]
8077        privacy_exec,
8078        #[cfg(target_os = "macos")]
8079        report_ready: Arc::new(OnceLock::new()),
8080        spawn_failure: None,
8081    })
8082}
8083
8084#[cfg(target_os = "linux")]
8085pub(crate) fn kill_module_cgroup(placement: Option<&subc_cgroup::Placement>, module_id: &str) {
8086    use subc_cgroup::KillOutcome;
8087    match subc_cgroup::kill_module(placement, module_id) {
8088        KillOutcome::Killed => {}
8089        KillOutcome::NotPlaced | KillOutcome::Unsupported => {
8090            debug!(
8091                module_id,
8092                "cgroup tree kill unavailable; using direct-child kill"
8093            );
8094        }
8095        KillOutcome::IoError { path, error } => {
8096            warn!(module_id, path = %path.display(), %error, "cgroup tree kill failed; using direct-child kill");
8097        }
8098    }
8099}
8100
8101/// Contain a freshly spawned Windows child and start it.
8102///
8103/// Steps 2 and 3 of the suspended-create contract: the job is created and the
8104/// child assigned **while it is still suspended** (step 1 is
8105/// `suspend_on_create_async` at the spawn site), then the child is resumed.
8106///
8107/// A child that is never resumed hangs forever holding a pid, so a resume
8108/// failure kills the child and fails the spawn rather than returning a
8109/// `SupervisedChild` that can never run.
8110///
8111/// An assignment failure is NOT fatal: an uncontained module behaves exactly as
8112/// it did before this existed, whereas refusing to start one would be a new
8113/// outage. It is logged at warn because it means a helper process could leak.
8114#[cfg(windows)]
8115fn contain_spawned_child(
8116    child: &Child,
8117    spec: &ModuleSpec,
8118) -> Result<Option<subc_jobobject::JobObject>, SuperviseError> {
8119    let module_id = spec.module_id.as_str();
8120    let Some(pid) = child.id() else {
8121        // The child exited between spawn and here. Its tree, if it made one,
8122        // needs no containment: nothing is left to contain.
8123        warn!(
8124            module_id,
8125            "spawned child had already exited before containment; no job object attached"
8126        );
8127        return Ok(None);
8128    };
8129
8130    let job = match subc_jobobject::JobObject::new() {
8131        Ok(job) => job,
8132        Err(source) => {
8133            warn!(
8134                module_id,
8135                error = %source,
8136                "could not create a job object; this module's helper processes will not be \
8137                 reaped on teardown"
8138            );
8139            // Resume regardless: leaving the child suspended would turn a
8140            // containment gap into a hung module.
8141            resume_suspended_child(pid, spec)?;
8142            return Ok(None);
8143        }
8144    };
8145
8146    if let Err(source) = job.assign(child) {
8147        warn!(
8148            module_id,
8149            error = %source,
8150            "could not assign the child to its job object; this module's helper processes \
8151             will not be reaped on teardown"
8152        );
8153        resume_suspended_child(pid, spec)?;
8154        return Ok(None);
8155    }
8156
8157    resume_suspended_child(pid, spec)?;
8158    Ok(Some(job))
8159}
8160
8161/// Resume a suspended child, killing it if it cannot be started.
8162///
8163/// A suspended process holds a pid and does nothing, so there is no useful
8164/// state to return: the caller gets an error and the spawn fails.
8165#[cfg(windows)]
8166fn resume_suspended_child(pid: u32, spec: &ModuleSpec) -> Result<(), SuperviseError> {
8167    if let Err(source) = subc_jobobject::resume_main_thread(pid) {
8168        // Kill it here rather than leaving a suspended process for the caller
8169        // to notice; `kill_on_drop` would eventually do this, but the module
8170        // would have been reported as running in between.
8171        let _ = std::process::Command::new("taskkill.exe")
8172            .args(["/PID", &pid.to_string(), "/T", "/F"])
8173            .stdin(Stdio::null())
8174            .stdout(Stdio::null())
8175            .stderr(Stdio::null())
8176            .status();
8177        return Err(SuperviseError::Spawn {
8178            program: spec.program.clone(),
8179            source,
8180            cgroup_path: None,
8181        });
8182    }
8183    Ok(())
8184}
8185
8186#[cfg(target_os = "linux")]
8187fn remove_module_cgroup(placement: &subc_cgroup::Placement, module_id: &str) {
8188    match placement.remove_module(module_id) {
8189        Ok(()) => debug!(module_id, "removed module cgroup after process exit"),
8190        Err(error) => warn!(
8191            module_id,
8192            error = %error,
8193            "could not remove module cgroup after process exit; continuing teardown"
8194        ),
8195    }
8196}
8197
8198#[cfg(target_os = "linux")]
8199async fn cleanup_reaped_cgroup(placement: &subc_cgroup::Placement, module_id: &str) {
8200    // Reaping the direct child is not proof its descendants exited. End the
8201    // residual tree and wait for the kernel's population fact before rmdir;
8202    // otherwise a successful parent wait leaks a directory on each restart.
8203    if matches!(
8204        subc_cgroup::kill_module(Some(placement), module_id),
8205        subc_cgroup::KillOutcome::Killed
8206    ) {
8207        if let Ok(path) = placement.module_path(module_id) {
8208            while std::fs::read_to_string(path.join("cgroup.events"))
8209                .ok()
8210                .is_some_and(|events| events.lines().any(|line| line == "populated 1"))
8211            {
8212                sleep(Duration::from_millis(1)).await;
8213            }
8214        }
8215    }
8216    remove_module_cgroup(placement, module_id);
8217}
8218
8219#[cfg(target_os = "linux")]
8220fn apply_cgroup_placement(
8221    command: &mut Command,
8222    spec: &ModuleSpec,
8223    path: &std::path::Path,
8224) -> Result<(), SuperviseError> {
8225    subc_cgroup::apply(command, path).map_err(|source| SuperviseError::Cgroup {
8226        module_id: spec.module_id.clone(),
8227        source,
8228    })
8229}
8230
8231fn capture_retention(spec: &ModuleSpec) -> Retention {
8232    let defaults = Retention::default();
8233    let value = |name: &str| {
8234        spec.env
8235            .iter()
8236            .rev()
8237            .find_map(|(key, value)| (key == name).then_some(value.as_str()))
8238    };
8239    Retention {
8240        max_file_mb: value(CAPTURE_MAX_FILE_MB_ENV)
8241            .and_then(|value| value.parse().ok())
8242            .unwrap_or(defaults.max_file_mb),
8243        keep: value(CAPTURE_KEEP_ENV)
8244            .and_then(|value| value.parse().ok())
8245            .unwrap_or(defaults.keep),
8246        max_age_days: value(CAPTURE_MAX_AGE_DAYS_ENV)
8247            .and_then(|value| value.parse().ok())
8248            .unwrap_or(defaults.max_age_days),
8249    }
8250}
8251
8252/// A fresh 256-bit CSPRNG launch nonce, lowercase hex. Used to bind a reserved
8253/// module's registration to the exact process the supervisor spawned.
8254fn generate_launch_nonce() -> Result<String, SuperviseError> {
8255    let mut bytes = [0u8; 32];
8256    getrandom::getrandom(&mut bytes).map_err(|source| SuperviseError::LaunchNonce {
8257        reason: source.to_string(),
8258    })?;
8259    let mut hex = String::with_capacity(64);
8260    for b in bytes {
8261        use std::fmt::Write;
8262        let _ = write!(hex, "{b:02x}");
8263    }
8264    Ok(hex)
8265}
8266
8267/// Constant-time byte comparison so a reserved-nonce mismatch leaks no timing
8268/// signal about how many leading bytes matched.
8269fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
8270    if a.len() != b.len() {
8271        return false;
8272    }
8273    let mut diff = 0u8;
8274    for (x, y) in a.iter().zip(b.iter()) {
8275        diff |= x ^ y;
8276    }
8277    diff == 0
8278}
8279
8280/// The kernel's image after an acknowledged exec, shared by ordinary launches
8281/// and privacy trampolines. PATH and shebang interpretation are kernel facts,
8282/// not identities inferred from a configured pathname.
8283#[cfg(not(windows))]
8284fn observe_spawned_image(pid: u32) -> Option<subc_os::Observation> {
8285    subc_os::Process::open(pid)
8286        .ok()
8287        .flatten()
8288        .and_then(|process| process.observe())
8289}
8290
8291/// Resolve once and spawn that exact path, so PATH lookup cannot choose a
8292/// different executable after the no-delete capture was taken.
8293#[cfg(windows)]
8294fn windows_spawn_capture(spec: &ModuleSpec) -> io::Result<subc_os::ExecutableCapture> {
8295    let mut program = spec.program.clone();
8296    if program.is_absolute() || program.components().count() > 1 {
8297        // An explicit path can name a PE image with no extension. Try that
8298        // exact file first, as Command does, before falling back to .exe.
8299        match subc_os::ExecutableCapture::open(&program) {
8300            Ok(capture) => return Ok(capture),
8301            Err(error)
8302                if error.kind() == io::ErrorKind::NotFound && program.extension().is_none() => {}
8303            Err(error) => return Err(error),
8304        }
8305        program.set_extension("exe");
8306        return subc_os::ExecutableCapture::open(&program);
8307    }
8308    if program.extension().is_none() {
8309        program.set_extension("exe");
8310    }
8311    let custom_path = spec
8312        .env
8313        .iter()
8314        .rev()
8315        .find(|(key, _)| key.eq_ignore_ascii_case("PATH"));
8316    let mut directories = Vec::new();
8317    // These are Windows' default executable search locations. A configured
8318    // PATH still overrides the inherited PATH, not the system directories.
8319    if let Some(parent) = std::env::current_exe()?.parent() {
8320        directories.push(parent.to_path_buf());
8321    }
8322    directories.push(std::env::current_dir()?);
8323    if let Some(windows) = std::env::var_os("SystemRoot") {
8324        let windows = PathBuf::from(windows);
8325        directories.push(windows.join("System32"));
8326        directories.push(windows.clone());
8327    }
8328    let path = custom_path
8329        .map(|(_, value)| std::ffi::OsString::from(value))
8330        .or_else(|| std::env::var_os("PATH"));
8331    if let Some(path) = path {
8332        directories.extend(std::env::split_paths(&path));
8333    }
8334    for directory in directories {
8335        match subc_os::ExecutableCapture::open(&directory.join(&program)) {
8336            Ok(capture) => return Ok(capture),
8337            Err(error) if error.kind() == io::ErrorKind::NotFound => continue,
8338            Err(error) => return Err(error),
8339        }
8340    }
8341    Err(io::Error::new(
8342        io::ErrorKind::NotFound,
8343        "resolved executable not found",
8344    ))
8345}
8346
8347fn spawn_and_mark_running(
8348    spec: &ModuleSpec,
8349    runtime: &SupervisorRuntimeConfig,
8350    snapshot: &SharedSnapshot,
8351) -> Result<SupervisedChild, SuperviseError> {
8352    let child = spawn_child(
8353        spec,
8354        runtime.connection_file_path.as_deref(),
8355        runtime.supervisor_handle.as_ref(),
8356        &runtime.stderr_ring,
8357        runtime.capture_logs_dir.as_deref(),
8358        &runtime.child_roster,
8359        #[cfg(target_os = "linux")]
8360        runtime.cgroup_placement.as_ref(),
8361    )?;
8362    set_running(snapshot, &child, &spec.module_id, &runtime.spawn_events)?;
8363    Ok(child)
8364}
8365
8366enum RegistrationWaitOutcome {
8367    Registered,
8368    Exited(ExitReport),
8369    TimedOut,
8370}
8371
8372struct ReloadRegistrationFailure {
8373    exit_report: ExitReport,
8374    reason: String,
8375}
8376
8377#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8378enum BusyGaugeObservation {
8379    Quiescent,
8380    Busy,
8381    Omitted,
8382}
8383
8384fn busy_gauge_observation(metrics: Option<&Value>, gauges: &[String]) -> BusyGaugeObservation {
8385    let Some(metrics) = metrics.and_then(Value::as_object) else {
8386        return BusyGaugeObservation::Omitted;
8387    };
8388    let mut sum = 0u128;
8389    for gauge in gauges {
8390        let Some(value) = metrics.get(gauge) else {
8391            return BusyGaugeObservation::Omitted;
8392        };
8393        let Some(value) = value.as_u64() else {
8394            return BusyGaugeObservation::Busy;
8395        };
8396        sum = sum.saturating_add(u128::from(value));
8397    }
8398    if sum == 0 {
8399        BusyGaugeObservation::Quiescent
8400    } else {
8401        BusyGaugeObservation::Busy
8402    }
8403}
8404
8405fn declared_busy_gauges(
8406    registry: &Registry,
8407    module_id: &str,
8408) -> Result<Vec<String>, SuperviseError> {
8409    busy_gauges_of(
8410        registry
8411            .get_module(module_id)
8412            .map_err(SuperviseError::Registry)?,
8413    )
8414}
8415
8416/// [`declared_busy_gauges`] for the registration a connection holds, in any
8417/// slot: after cutover the incumbent is no longer the id's active
8418/// registration, and its own manifest is the one that names its gauges.
8419fn declared_busy_gauges_for_connection(
8420    registry: &Registry,
8421    connection_id: ConnectionId,
8422) -> Result<Vec<String>, SuperviseError> {
8423    busy_gauges_of(
8424        registry
8425            .get_module_by_connection(connection_id)
8426            .map_err(SuperviseError::Registry)?,
8427    )
8428}
8429
8430fn busy_gauges_of(
8431    registration: Option<crate::registry::ModuleRegistration>,
8432) -> Result<Vec<String>, SuperviseError> {
8433    let Some(registration) = registration else {
8434        return Ok(Vec::new());
8435    };
8436    let Some(self_signals) = registration.manifest.self_signals else {
8437        return Ok(Vec::new());
8438    };
8439
8440    let mut gauges = Vec::new();
8441    for declaration in self_signals {
8442        if declaration.kind != SelfSignalKind::Busy {
8443            continue;
8444        }
8445        match declaration.anchored_to {
8446            SignalAnchor::HealthGauges { gauges: declared } if !declared.is_empty() => {
8447                gauges.extend(declared)
8448            }
8449            _ => {
8450                // An invalid Busy anchor is fail-safe: the empty name cannot be
8451                // present in a conforming health report, so this drain stays busy.
8452                gauges.push(String::new());
8453            }
8454        }
8455    }
8456    Ok(gauges)
8457}
8458
8459/// Wait for `endpoint` to have nothing in flight and, when the module declares
8460/// busy gauges, for a health probe to report them quiet. The probe is addressed
8461/// by `scope`: a swap's superseded incumbent must be asked about its own
8462/// gauges, and by module id the probe would reach the promoted candidate.
8463async fn wait_for_forwarding_quiescence(
8464    forwarding: &ForwardingTable,
8465    module_id: &str,
8466    runtime: &SupervisorRuntimeConfig,
8467    endpoint: crate::ModuleEndpointId,
8468    deadline: Instant,
8469    busy_gauges: &[String],
8470    scope: DrainScope,
8471) -> Result<bool, SuperviseError> {
8472    let mut gauges_quiescent = busy_gauges.is_empty();
8473    let mut next_probe_at = Instant::now();
8474    let mut omission_counted = false;
8475
8476    loop {
8477        let now = Instant::now();
8478        if !busy_gauges.is_empty() && now >= next_probe_at && now < deadline {
8479            let report = match scope {
8480                DrainScope::Active => probe_module_health(module_id, runtime, Some(deadline)).await,
8481                DrainScope::Endpoint(endpoint) => {
8482                    probe_endpoint_health(endpoint, runtime, Some(deadline)).await
8483                }
8484            };
8485            gauges_quiescent = match report {
8486                Ok(report) => match busy_gauge_observation(report.metrics.as_ref(), busy_gauges) {
8487                    BusyGaugeObservation::Quiescent => true,
8488                    BusyGaugeObservation::Busy => false,
8489                    BusyGaugeObservation::Omitted => {
8490                        if !omission_counted {
8491                            forwarding
8492                                .counters()
8493                                .increment_drains_with_undeclared_gauge();
8494                            omission_counted = true;
8495                        }
8496                        false
8497                    }
8498                },
8499                Err(err) => {
8500                    warn!(
8501                        module_id,
8502                        error = %err,
8503                        "drain health.check did not produce declared busy gauges; treating module as busy"
8504                    );
8505                    false
8506                }
8507            };
8508            next_probe_at = Instant::now() + runtime.health.cadence.max(REGISTRY_RELEASE_POLL);
8509        }
8510
8511        let in_flight = forwarding
8512            .endpoint_in_flight_count(endpoint)
8513            .map_err(SuperviseError::Forwarding)?;
8514        if in_flight == 0 && gauges_quiescent {
8515            return Ok(true);
8516        }
8517
8518        let now = Instant::now();
8519        if now >= deadline {
8520            return Ok(false);
8521        }
8522        let mut wait = deadline
8523            .saturating_duration_since(now)
8524            .min(REGISTRY_RELEASE_POLL);
8525        if !busy_gauges.is_empty() {
8526            wait = wait.min(next_probe_at.saturating_duration_since(now));
8527        }
8528        sleep(wait).await;
8529    }
8530}
8531
8532/// The `route.closed` `drained` value implied by a quiescence-wait outcome.
8533///
8534/// `Ok` is always honest and passed straight through -- the wait actually measured
8535/// in-flight state. `Err` means the wait produced no measurement at all (the
8536/// forwarding table's lock was poisoned), so `false` is reported as the one honest
8537/// constant: the drain did not complete. Never recomputed from route state, never a
8538/// third "unknown" value -- the caller must still send a well-formed `route.closed`.
8539fn drained_after_quiescence_wait(wait_result: &Result<bool, SuperviseError>) -> bool {
8540    match wait_result {
8541        Ok(drained) => *drained,
8542        Err(_) => false,
8543    }
8544}
8545
8546fn send_route_goodbyes(forwarding: &ForwardingTable, released_routes: Vec<GoodbyeTarget>) {
8547    for released in released_routes {
8548        let frame = match Frame::build_with_version(
8549            released.negotiated_ver,
8550            FrameType::Goodbye,
8551            control_flags(),
8552            released.channel,
8553            released.epoch,
8554            0,
8555            Vec::new(),
8556        ) {
8557            Ok(frame) => frame,
8558            Err(err) => {
8559                warn!(
8560                    route_channel = released.channel,
8561                    error = %err,
8562                    "failed to build supervisor drain route GOODBYE frame"
8563                );
8564                continue;
8565            }
8566        };
8567        if !released.close_on_delivery_failure() {
8568            crate::forwarding::send_module_route_goodbye(
8569                &forwarding.counters(),
8570                &released.sink,
8571                frame,
8572                released.module_id.as_deref(),
8573                "supervisor drain",
8574            );
8575            continue;
8576        }
8577        if let Err(err) = released.sink.try_send(frame) {
8578            warn!(
8579                target_connection_id = released.connection_id.get(),
8580                route_channel = released.channel,
8581                error = %err,
8582                "supervisor drain route GOODBYE was not delivered to client; closing target connection"
8583            );
8584            let _ = forwarding.escalate_client_delivery_failure(
8585                released.connection_id,
8586                released.channel,
8587                released.epoch,
8588                CloseReason::new(
8589                    "route_goodbye_delivery_failed",
8590                    format!(
8591                        "failed to enqueue supervisor drain route GOODBYE for channel {}: {err}",
8592                        released.channel
8593                    ),
8594                ),
8595                crate::forwarding::UndeliveredFrame {
8596                    module_id: released.module_id.as_deref(),
8597                    sink: &released.sink,
8598                },
8599            );
8600        }
8601    }
8602}
8603
8604fn send_module_draining(
8605    module_id: &str,
8606    reason: RouteCloseReason,
8607    deadline_ms: u64,
8608    target: &ModuleDrainTarget,
8609) {
8610    let body = match serde_json::to_vec(&ModuleControlCommand::Draining {
8611        reason,
8612        deadline_ms,
8613    }) {
8614        Ok(body) => body,
8615        Err(err) => {
8616            warn!(
8617                module_id,
8618                error = %err,
8619                "failed to encode module draining command"
8620            );
8621            return;
8622        }
8623    };
8624    let frame = match Frame::build_with_version(
8625        target.negotiated_ver,
8626        FrameType::Push,
8627        control_flags(),
8628        0,
8629        0,
8630        0,
8631        body,
8632    ) {
8633        Ok(frame) => frame,
8634        Err(err) => {
8635            warn!(
8636                module_id,
8637                error = %err,
8638                "failed to build module draining command frame"
8639            );
8640            return;
8641        }
8642    };
8643    if let Err(err) = target.sink.try_send(frame) {
8644        warn!(
8645            module_id,
8646            target_connection_id = target.endpoint.connection_id.get(),
8647            error = %err,
8648            "module draining command was not delivered to peer"
8649        );
8650    }
8651}
8652
8653/// The channel-0 GOODBYE that tells a module its stop is planned.
8654fn module_goodbye_frame(module_id: &str, negotiated_ver: u8) -> Option<Frame> {
8655    match Frame::build_with_version(
8656        negotiated_ver,
8657        FrameType::Goodbye,
8658        control_flags(),
8659        0,
8660        0,
8661        0,
8662        Vec::new(),
8663    ) {
8664        Ok(frame) => Some(frame),
8665        Err(err) => {
8666            warn!(
8667                module_id,
8668                error = %err,
8669                "failed to build module GOODBYE frame"
8670            );
8671            None
8672        }
8673    }
8674}
8675
8676/// Send every registered module connection its module GOODBYE at daemon
8677/// shutdown, then request that connection's close.
8678///
8679/// A module tells a planned stop from a lost daemon by whether a GOODBYE came
8680/// before EOF, so the GOODBYE must reach the socket before the close. A close
8681/// request does not wait for the connection's queued frames: its writer gets a
8682/// bounded grace after the close, is aborted if it overruns it, and the daemon
8683/// process may exit before that grace ends. So with `wait_for_flush`, each
8684/// connection is closed only after its writer has acknowledged writing the
8685/// GOODBYE, or once a short shared budget runs out, so one module that is not
8686/// reading cannot hold up the others or the shutdown. Without it the GOODBYEs
8687/// are only queued, for a shutdown the operator has told to stop waiting.
8688/// A connection that is already gone is skipped.
8689#[cfg(unix)]
8690async fn send_module_goodbyes_for_daemon_shutdown(
8691    forwarding: &Arc<ForwardingTable>,
8692    reason: &CloseReason,
8693    wait_for_flush: bool,
8694) {
8695    const GOODBYE_BUDGET: Duration = Duration::from_millis(500);
8696    let targets = match forwarding.module_connections() {
8697        Ok(targets) => targets,
8698        Err(err) => {
8699            warn!(error = %err, "could not list module connections for shutdown GOODBYE");
8700            return;
8701        }
8702    };
8703    let deadline = Instant::now() + GOODBYE_BUDGET;
8704    let mut sends = tokio::task::JoinSet::new();
8705    for target in targets {
8706        let Some(frame) = module_goodbye_frame(&target.module_id, target.negotiated_ver) else {
8707            continue;
8708        };
8709        if !wait_for_flush {
8710            if let Err(err) = target.sink.try_send(frame) {
8711                debug!(
8712                    module_id = %target.module_id,
8713                    error = %err,
8714                    "shutdown module GOODBYE was not queued"
8715                );
8716            }
8717            continue;
8718        }
8719        let forwarding = Arc::clone(forwarding);
8720        let reason = reason.clone();
8721        sends.spawn(async move {
8722            match timeout_at(deadline, target.sink.send_flushed(frame)).await {
8723                Ok(Ok(())) => {}
8724                Ok(Err(err)) => debug!(
8725                    module_id = %target.module_id,
8726                    error = %err,
8727                    "module connection closed before its shutdown GOODBYE was written"
8728                ),
8729                Err(_) => warn!(
8730                    module_id = %target.module_id,
8731                    budget = ?GOODBYE_BUDGET,
8732                    "shutdown module GOODBYE was not written within its budget; closing anyway"
8733                ),
8734            }
8735            forwarding.request_connection_close(target.endpoint.connection_id, reason);
8736        });
8737    }
8738    // Every task ends by the shared deadline, so this wait is bounded too.
8739    while sends.join_next().await.is_some() {}
8740}
8741
8742fn send_module_goodbye(module_id: &str, forwarding: &ForwardingTable, target: &ModuleDrainTarget) {
8743    let Some(frame) = module_goodbye_frame(module_id, target.negotiated_ver) else {
8744        return;
8745    };
8746    if let Err(err) = target.sink.try_send(frame) {
8747        warn!(
8748            module_id,
8749            target_connection_id = target.endpoint.connection_id.get(),
8750            error = %err,
8751            "supervisor drain module GOODBYE was not delivered to peer; closing module connection"
8752        );
8753        forwarding.request_connection_close(
8754            target.endpoint.connection_id,
8755            CloseReason::new(
8756                "module_goodbye_delivery_failed",
8757                format!("failed to enqueue supervisor drain module GOODBYE for module '{module_id}': {err}"),
8758            ),
8759        );
8760    }
8761}
8762
8763#[derive(Clone, Copy)]
8764struct ForwardingDrainContext<'a> {
8765    spec: &'a ModuleSpec,
8766    runtime: &'a SupervisorRuntimeConfig,
8767    registry: &'a Registry,
8768    scope: DrainScope,
8769}
8770
8771/// Which process a forwarding drain addresses.
8772#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8773enum DrainScope {
8774    /// Whatever endpoint is active for the module id: every plain stop,
8775    /// restart and reload. Also moves the module's state to `Draining`.
8776    Active,
8777    /// One specific endpoint: a swap's incumbent after cutover. Draining it by
8778    /// module id would resolve to the promoted candidate and leave neither
8779    /// process routable. The module's state is left alone, since the promoted
8780    /// candidate is what it describes and that process is running.
8781    Endpoint(crate::ModuleEndpointId),
8782}
8783
8784/// Whether a child being drained has already been asked to stop by the time
8785/// its drain wait starts.
8786///
8787/// The drain wait is the same budget whatever this says. What it decides is
8788/// whether the supervisor must ask by signal before that wait begins: a child
8789/// that nobody asked will sit out the whole budget and then be SIGKILLed,
8790/// healthy or not.
8791#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8792enum StopNotice {
8793    /// The module was sent `module.draining` and a module GOODBYE over its own
8794    /// registered connection, and stops itself.
8795    SentOverConnection,
8796    /// The forwarding drain found no registered connection for the module: a
8797    /// subc child spawned moments ago that has not sent HELLO yet, or a
8798    /// `protocol: "none"` child, which never registers.
8799    NoConnection,
8800    /// This path sends nothing over the module's connection: the supervisor has
8801    /// no forwarding table, or the caller stops the child without a forwarding
8802    /// drain.
8803    NotSent,
8804}
8805
8806async fn begin_forwarding_drain(
8807    spec: &ModuleSpec,
8808    runtime: &SupervisorRuntimeConfig,
8809    registry: &Registry,
8810    snapshot: &SharedSnapshot,
8811    enabled: Option<bool>,
8812    reason: RouteCloseReason,
8813) -> Result<StopNotice, SuperviseError> {
8814    let Some(forwarding) = runtime.forwarding.as_ref() else {
8815        return Err(SuperviseError::ReloadUnavailable {
8816            module_id: spec.module_id.clone(),
8817            reason: "supervisor was not configured with a forwarding table".to_string(),
8818        });
8819    };
8820
8821    begin_forwarding_drain_with(
8822        forwarding,
8823        ForwardingDrainContext {
8824            spec,
8825            runtime,
8826            registry,
8827            scope: DrainScope::Active,
8828        },
8829        snapshot,
8830        enabled,
8831        reason,
8832        runtime.drain_timeout,
8833    )
8834    .await
8835}
8836
8837async fn begin_forwarding_drain_if_configured(
8838    spec: &ModuleSpec,
8839    runtime: &SupervisorRuntimeConfig,
8840    registry: &Registry,
8841    snapshot: &SharedSnapshot,
8842    enabled: Option<bool>,
8843    reason: RouteCloseReason,
8844) -> Result<StopNotice, SuperviseError> {
8845    begin_forwarding_drain_with_timeout(
8846        spec,
8847        runtime,
8848        registry,
8849        snapshot,
8850        enabled,
8851        reason,
8852        runtime.drain_timeout,
8853    )
8854    .await
8855}
8856
8857/// Like [`begin_forwarding_drain_if_configured`] but with an explicit drain
8858/// budget, for paths where the operator overrides the module's configured one
8859/// (`supervisor.restart{drain_timeout_ms}`).
8860async fn begin_forwarding_drain_with_timeout(
8861    spec: &ModuleSpec,
8862    runtime: &SupervisorRuntimeConfig,
8863    registry: &Registry,
8864    snapshot: &SharedSnapshot,
8865    enabled: Option<bool>,
8866    reason: RouteCloseReason,
8867    drain_timeout: Duration,
8868) -> Result<StopNotice, SuperviseError> {
8869    let Some(forwarding) = runtime.forwarding.as_ref() else {
8870        return Ok(StopNotice::NotSent);
8871    };
8872
8873    begin_forwarding_drain_with(
8874        forwarding,
8875        ForwardingDrainContext {
8876            spec,
8877            runtime,
8878            registry,
8879            scope: DrainScope::Active,
8880        },
8881        snapshot,
8882        enabled,
8883        reason,
8884        drain_timeout,
8885    )
8886    .await
8887}
8888
8889async fn begin_forwarding_drain_with(
8890    forwarding: &ForwardingTable,
8891    context: ForwardingDrainContext<'_>,
8892    snapshot: &SharedSnapshot,
8893    enabled: Option<bool>,
8894    reason: RouteCloseReason,
8895    drain_timeout: Duration,
8896) -> Result<StopNotice, SuperviseError> {
8897    let ForwardingDrainContext {
8898        spec,
8899        runtime,
8900        registry,
8901        scope,
8902    } = context;
8903    debug_assert_ne!(reason, RouteCloseReason::Crash);
8904    let terminal = matches!(reason, RouteCloseReason::Disable);
8905    let drain_started_at = Instant::now();
8906    let drain_deadline = drain_started_at + drain_timeout;
8907    let deadline_ms =
8908        unix_ms_now().saturating_add(u64::try_from(drain_timeout.as_millis()).unwrap_or(u64::MAX));
8909    let busy_gauges = match scope {
8910        DrainScope::Active => declared_busy_gauges(registry, &spec.module_id)?,
8911        DrainScope::Endpoint(endpoint) => {
8912            declared_busy_gauges_for_connection(registry, endpoint.connection_id)?
8913        }
8914    };
8915
8916    // Admission gate first: route.open/commit and route REQUEST admission are closed
8917    // before the first quiescence check, so the outstanding count can only fall.
8918    let gate_started = Instant::now();
8919    let drain_target = match scope {
8920        DrainScope::Active => forwarding.begin_module_drain(&spec.module_id, reason),
8921        DrainScope::Endpoint(endpoint) => forwarding.begin_endpoint_drain(endpoint, reason),
8922    }
8923    .map_err(SuperviseError::Forwarding)?;
8924    // The instant admission closed, and how long taking the forwarding write
8925    // lock to close it took. The timeout line reports only the quiescence
8926    // wait, so without this a drain that started late looked like one that
8927    // started on time.
8928    info!(
8929        module_id = %spec.module_id,
8930        ?reason,
8931        gate_ms = u64::try_from(gate_started.elapsed().as_millis()).unwrap_or(u64::MAX),
8932        connected = drain_target.is_some(),
8933        "module drain began; route admission closed"
8934    );
8935    if scope == DrainScope::Active {
8936        update_snapshot(snapshot, Some(&spec.module_id), |state| {
8937            state.state = ModuleState::Draining;
8938            state.draining_to_replace =
8939                matches!(reason, RouteCloseReason::Restart | RouteCloseReason::Reload);
8940            if let Some(enabled) = enabled {
8941                state.enabled = enabled;
8942            }
8943        })?;
8944    }
8945
8946    let Some(target) = drain_target.as_ref() else {
8947        // Nothing was sent: the module has no registered connection to carry
8948        // `module.draining` or a GOODBYE. The caller must not assume the child
8949        // was asked to stop.
8950        return Ok(StopNotice::NoConnection);
8951    };
8952    {
8953        send_module_draining(&spec.module_id, reason, deadline_ms, target);
8954        let routes = forwarding
8955            .endpoint_routes(target.endpoint)
8956            .map_err(SuperviseError::Forwarding)?;
8957        let routes_notified = routes.len();
8958        crate::control::send_route_control_pushes(
8959            forwarding,
8960            routes.clone(),
8961            ClientControlPush::RouteClosing {
8962                module_id: spec.module_id.clone(),
8963                channels: Vec::new(),
8964                reason,
8965            },
8966        );
8967        send_route_goodbyes(forwarding, target.abandoned_bindings.clone());
8968
8969        // `route.closing` was just sent above: from here on every return path,
8970        // including an early one, MUST send `route.closed` before propagating
8971        // anything else. A client holds `closing` as a promise that a verdict is
8972        // coming; leaving early without `closed` strands it waiting forever, since
8973        // `closing` carries no timeout of its own.
8974        let wait_result = wait_for_forwarding_quiescence(
8975            forwarding,
8976            &spec.module_id,
8977            runtime,
8978            target.endpoint,
8979            drain_deadline,
8980            &busy_gauges,
8981            scope,
8982        )
8983        .await;
8984        let drained = drained_after_quiescence_wait(&wait_result);
8985        if let Err(err) = &wait_result {
8986            error!(
8987                module_id = %spec.module_id,
8988                ?reason,
8989                error = %err,
8990                "forwarding quiescence wait failed after route.closing; forcing route.closed(drained: false) so the client is not left waiting on an unfulfilled promise"
8991            );
8992        } else if !drained {
8993            // Name what the drain waited on. Without it the line says only that
8994            // something did not settle, and "one wedged call" and "every
8995            // session's held stream" read the same; the first is a module bug,
8996            // the second is a module that should end its streams on
8997            // module.draining. Read before teardown releases the routes.
8998            let holdouts = forwarding
8999                .endpoint_drain_holdouts(target.endpoint)
9000                .unwrap_or_default();
9001            warn!(
9002                module_id = %spec.module_id,
9003                waited = ?drain_timeout,
9004                ?reason,
9005                held_requests = holdouts.requests,
9006                held_routes = holdouts.routes,
9007                total_routes = holdouts.total_routes,
9008                top_connections = ?holdouts.top_connections,
9009                // `module_channel:corr`, so the module can find each held request
9010                // in its own log; capped, so `held_requests` is the full count.
9011                held = %holdouts
9012                    .held
9013                    .iter()
9014                    .map(|(channel, corr)| format!("{channel}:{corr}"))
9015                    .collect::<Vec<_>>()
9016                    .join(","),
9017                "route drain timed out before request quiescence; forcing teardown"
9018            );
9019        }
9020        crate::control::send_route_control_pushes(
9021            forwarding,
9022            routes,
9023            ClientControlPush::RouteClosed {
9024                module_id: spec.module_id.clone(),
9025                channels: Vec::new(),
9026                reason,
9027                drained,
9028                abandoned: target.abandoned_bindings.len() as u32,
9029                excluded_subscriptions: target.excluded_subscriptions,
9030                terminal: Some(terminal),
9031            },
9032        );
9033        wait_result?;
9034
9035        // `route.closed` has now been sent unconditionally above. From here the
9036        // remaining steps are cleanup (route + module GOODBYE) rather than a
9037        // promise the client is waiting on, but a lock-poisoned
9038        // `release_module_endpoint_routes` would otherwise skip the module
9039        // GOODBYE silently too -- send it before propagating the error.
9040        let released_routes = match forwarding.release_module_endpoint_routes(target.endpoint) {
9041            Ok(routes) => routes,
9042            Err(err) => {
9043                warn!(
9044                    module_id = %spec.module_id,
9045                    ?reason,
9046                    error = %err,
9047                    "failed to release module endpoint routes after route.closed; module GOODBYE will still be sent"
9048                );
9049                send_module_goodbye(&spec.module_id, forwarding, target);
9050                return Err(SuperviseError::Forwarding(err));
9051            }
9052        };
9053        let route_goodbye_count = released_routes.len();
9054        send_route_goodbyes(forwarding, released_routes);
9055        send_module_goodbye(&spec.module_id, forwarding, target);
9056
9057        // The drain's happy path was previously silent: every emission above is
9058        // best-effort with only its failure arm logged, so "were consumers told"
9059        // was unprovable from the daemon log (surfaced by a 30-minute consumer
9060        // hang where the open question was exactly whether teardown notice went
9061        // out). One summary line makes that class decidable in one grep.
9062        info!(
9063            module_id = %spec.module_id,
9064            ?reason,
9065            routes_notified,
9066            route_goodbyes = route_goodbye_count,
9067            abandoned_reservations = target.abandoned_bindings.len(),
9068            excluded_subscriptions = target.excluded_subscriptions,
9069            drained,
9070            "module drain complete; consumers notified via route.closing/route.closed pushes and per-route GOODBYE frames"
9071        );
9072    }
9073
9074    Ok(StopNotice::SentOverConnection)
9075}
9076
9077/// Wait for the freshly spawned child to take the ACTIVE slot for `module_id`,
9078/// the only slot a plain (non-swap) spawn can register into.
9079async fn wait_for_registration_after_reload(
9080    registry: &Registry,
9081    module_id: &str,
9082    snapshot: &SharedSnapshot,
9083    child: &mut SupervisedChild,
9084    wait: Duration,
9085) -> Result<RegistrationWaitOutcome, SuperviseError> {
9086    wait_for_slot_registration(
9087        registry,
9088        crate::registry::RegistrationSlot::Active(module_id),
9089        module_id,
9090        snapshot,
9091        child,
9092        wait,
9093    )
9094    .await
9095}
9096
9097/// Wait for `child` to register into `slot`, or to exit, or for `wait` to pass.
9098///
9099/// Keyed on the slot rather than the bare module id because during a swap the
9100/// id's active slot is already held by the incumbent: an id-keyed wait would
9101/// report the incumbent's registration as the candidate's and a candidate that
9102/// never registers would look registered. A swap candidate waits on
9103/// `crate::registry::RegistrationSlot::Candidate`.
9104async fn wait_for_slot_registration(
9105    registry: &Registry,
9106    slot: crate::registry::RegistrationSlot<'_>,
9107    module_id: &str,
9108    snapshot: &SharedSnapshot,
9109    child: &mut SupervisedChild,
9110    wait: Duration,
9111) -> Result<RegistrationWaitOutcome, SuperviseError> {
9112    let deadline = Instant::now() + wait;
9113    loop {
9114        if registry
9115            .registration(slot)
9116            .map_err(SuperviseError::Registry)?
9117            .is_some()
9118        {
9119            return Ok(RegistrationWaitOutcome::Registered);
9120        }
9121
9122        let now = Instant::now();
9123        if now >= deadline {
9124            return Ok(RegistrationWaitOutcome::TimedOut);
9125        }
9126        let remaining = deadline.saturating_duration_since(now);
9127        let poll = remaining.min(REGISTRY_RELEASE_POLL);
9128
9129        tokio::select! {
9130            wait_result = child.wait() => {
9131                let status = wait_result.map_err(|source| SuperviseError::Wait {
9132                    module_id: module_id.to_string(),
9133                    source,
9134                })?;
9135                return Ok(RegistrationWaitOutcome::Exited(classify_reaped_child_exit(
9136                    snapshot,
9137                    child,
9138                    &status,
9139                )));
9140            }
9141            _ = sleep(poll) => {}
9142        }
9143    }
9144}
9145
9146fn registration_failure_exit_report(mut exit_report: ExitReport) -> ExitReport {
9147    // A replacement process that exits before HELLO did not provide service, even
9148    // if it used status 0. Count it against the restart cap as a new-binary failure.
9149    if exit_report.kind != ExitKind::DeliberateSeverance {
9150        exit_report.kind = ExitKind::Crash;
9151    }
9152    exit_report
9153}
9154
9155async fn handle_reload_child_registration_failure(
9156    spec: &ModuleSpec,
9157    runtime: &SupervisorRuntimeConfig,
9158    registry: &Registry,
9159    process_liveness: &SupervisorProcessLiveness,
9160    snapshot: &SharedSnapshot,
9161    _child: &mut Option<SupervisedChild>,
9162    failure: ReloadRegistrationFailure,
9163) -> Result<(), SuperviseError> {
9164    let ReloadRegistrationFailure {
9165        exit_report,
9166        reason,
9167    } = failure;
9168    match on_child_exit(
9169        spec,
9170        runtime.restart_policy,
9171        registry,
9172        snapshot,
9173        &runtime.terminal_ring,
9174        &runtime.spawn_events,
9175        &runtime.child_roster,
9176        exit_report,
9177    )
9178    .await
9179    {
9180        NextAction::Stop {
9181            registration_released,
9182        } => {
9183            if registration_released {
9184                process_liveness.untrack_if_current(&spec.module_id, snapshot);
9185            }
9186        }
9187        NextAction::Restart { schedule } => {
9188            let delay = schedule.map_or(runtime.restart_policy.delay_for_restart(0), |schedule| {
9189                schedule.delay
9190            });
9191            if let Some(schedule) = schedule {
9192                log_crash_respawn(&spec.module_id, schedule);
9193            }
9194            schedule_respawn(
9195                runtime,
9196                snapshot,
9197                &spec.module_id,
9198                delay,
9199                RespawnKind::Spawn,
9200            )?;
9201        }
9202    }
9203    Err(SuperviseError::ReloadFailed {
9204        module_id: spec.module_id.clone(),
9205        reason,
9206    })
9207}
9208
9209async fn handle_reload_spawn_failure(
9210    spec: &ModuleSpec,
9211    runtime: &SupervisorRuntimeConfig,
9212    process_liveness: &SupervisorProcessLiveness,
9213    snapshot: &SharedSnapshot,
9214    _child: &mut Option<SupervisedChild>,
9215    reason: String,
9216) -> Result<(), SuperviseError> {
9217    let now = Instant::now();
9218    let mut schedule = None;
9219    update_snapshot(snapshot, Some(&spec.module_id), |state| {
9220        clear_current_process_facts(state);
9221        if state.enabled {
9222            schedule = state.next_crash_restart(&runtime.restart_policy, now);
9223            state.state = if schedule.is_some() {
9224                ModuleState::Restarting
9225            } else {
9226                ModuleState::Failed
9227            };
9228        } else {
9229            state.state = ModuleState::Disabled;
9230        }
9231    })?;
9232    if let Some(schedule) = schedule {
9233        schedule_respawn(
9234            runtime,
9235            snapshot,
9236            &spec.module_id,
9237            schedule.delay,
9238            RespawnKind::Spawn,
9239        )?;
9240    } else {
9241        process_liveness.untrack_if_current(&spec.module_id, snapshot);
9242    }
9243    Err(SuperviseError::ReloadFailed {
9244        module_id: spec.module_id.clone(),
9245        reason,
9246    })
9247}
9248
9249fn control_flags() -> Flags {
9250    Flags::new(false, Priority::Passive, false)
9251}
9252
9253#[allow(clippy::too_many_arguments)]
9254async fn drain_optional_child(
9255    module_id: &str,
9256    protocol: ModuleProtocol,
9257    stop_notice: StopNotice,
9258    registry: &Registry,
9259    forwarding: Option<&ForwardingTable>,
9260    snapshot: &SharedSnapshot,
9261    terminal_ring: &Arc<Mutex<TerminalRing>>,
9262    spawn_events: &SpawnEventFeed,
9263    child: &mut Option<SupervisedChild>,
9264    drain_timeout: Duration,
9265    final_state: ModuleState,
9266    enabled: Option<bool>,
9267) -> Result<(), SuperviseError> {
9268    if let Some(child) = child.take() {
9269        drain_child_to_state(
9270            module_id,
9271            protocol,
9272            stop_notice,
9273            registry,
9274            forwarding,
9275            snapshot,
9276            terminal_ring,
9277            spawn_events,
9278            child,
9279            drain_timeout,
9280            final_state,
9281            enabled,
9282        )
9283        .await
9284    } else {
9285        update_snapshot(snapshot, Some(module_id), |state| {
9286            state.state = final_state;
9287            if let Some(enabled) = enabled {
9288                state.enabled = enabled;
9289            }
9290            clear_current_process_facts(state);
9291        })?;
9292        release_dead_registration(registry, forwarding, snapshot, module_id).await
9293    }
9294}
9295
9296#[allow(clippy::too_many_arguments)]
9297async fn drain_child_to_state(
9298    module_id: &str,
9299    _protocol: ModuleProtocol,
9300    stop_notice: StopNotice,
9301    registry: &Registry,
9302    forwarding: Option<&ForwardingTable>,
9303    snapshot: &SharedSnapshot,
9304    terminal_ring: &Arc<Mutex<TerminalRing>>,
9305    spawn_events: &SpawnEventFeed,
9306    mut child: SupervisedChild,
9307    drain_timeout: Duration,
9308    final_state: ModuleState,
9309    enabled: Option<bool>,
9310) -> Result<(), SuperviseError> {
9311    let protocol = child.protocol;
9312    update_snapshot(snapshot, Some(module_id), |state| {
9313        state.state = ModuleState::Draining;
9314        state.draining_to_replace = final_state == ModuleState::Restarting;
9315        if let Some(enabled) = enabled {
9316            state.enabled = enabled;
9317        }
9318    })?;
9319
9320    // The wait below is the same budget in every case; what differs is
9321    // whether anything has ASKED the child to stop before it starts. Only a
9322    // forwarding drain that reached the module's registered connection has
9323    // (`module.draining`, then a module GOODBYE). Every other child was told
9324    // nothing: a `protocol: "none"` module, which never registers; a subc
9325    // module spawned moments ago that has not sent HELLO yet; or a stop that
9326    // runs no forwarding drain. Without a signal the budget is only a delay
9327    // in front of SIGKILL -- and the not-yet-registered child is the worst
9328    // case, because it registers into a module that is already draining,
9329    // is never told, and is killed while healthy.
9330    if stop_notice != StopNotice::SentOverConnection {
9331        if protocol == ModuleProtocol::Subc && stop_notice == StopNotice::NoConnection {
9332            info!(
9333                module_id,
9334                pid = child.pid,
9335                budget_ms = u64::try_from(drain_timeout.as_millis()).unwrap_or(u64::MAX),
9336                "module has no connection yet; requesting stop by signal"
9337            );
9338        }
9339        request_graceful_stop(module_id, &child);
9340    }
9341
9342    let exit_report = match timeout(drain_timeout, child.wait()).await {
9343        Ok(Ok(status)) => classify_reaped_child_exit(snapshot, &child, &status),
9344        Ok(Err(source)) => {
9345            fail_snapshot(snapshot, Some(module_id), None);
9346            return Err(SuperviseError::Wait {
9347                module_id: module_id.to_string(),
9348                source,
9349            });
9350        }
9351        Err(_) => {
9352            // Mirror the sibling arm above: state is already `Draining`, and an
9353            // error propagated from here would strand it there -- a state
9354            // `set_enabled(true)` cannot heal (`revive_terminal` matches only
9355            // `Failed | Stopped`), leaving an operator Restart as the only exit.
9356            // `Failed` before `?` keeps the module operator-visible and
9357            // revivable. Trigger is an ESRCH race (process exits between the
9358            // drain timeout firing and the kill) or a post-kill wait failure
9359            // (issue #34).
9360            //
9361            // Logged because the kill is otherwise visible only as signal 9 in
9362            // the terminal ring, and the budget it follows can be long enough
9363            // that consumers see a stretch of refusals with no stated cause.
9364            warn!(
9365                module_id,
9366                pid = child.pid,
9367                budget_ms = u64::try_from(drain_timeout.as_millis()).unwrap_or(u64::MAX),
9368                reason = ?final_state,
9369                ?stop_notice,
9370                "drain budget expired before the module exited; killing it"
9371            );
9372            child.start_kill().map_err(|source| {
9373                fail_snapshot(snapshot, Some(module_id), None);
9374                SuperviseError::Kill {
9375                    module_id: module_id.to_string(),
9376                    source,
9377                }
9378            })?;
9379            let status = child.wait().await.map_err(|source| {
9380                fail_snapshot(snapshot, Some(module_id), None);
9381                SuperviseError::Wait {
9382                    module_id: module_id.to_string(),
9383                    source,
9384                }
9385            })?;
9386            classify_reaped_child_exit(snapshot, &child, &status)
9387        }
9388    };
9389
9390    update_snapshot(snapshot, Some(module_id), |state| {
9391        state.state = final_state;
9392        if let Some(enabled) = enabled {
9393            state.enabled = enabled;
9394        }
9395        clear_current_process_facts(state);
9396        state.last_exit = Some(exit_report.clone());
9397        if exit_report.kind == ExitKind::DeliberateSeverance {
9398            state.lifetime_restarts += 1;
9399        }
9400    })?;
9401    let detail = lock_snapshot(snapshot)?.drain_disposition_detail.take();
9402    record_terminal_with_detail(
9403        module_id,
9404        terminal_ring,
9405        spawn_events,
9406        &exit_report,
9407        terminal_disposition(final_state),
9408        detail,
9409    );
9410    child.drain_stderr(module_id).await;
9411
9412    release_dead_registration(registry, forwarding, snapshot, module_id).await
9413}
9414
9415/// Ask a child that nothing else has asked to stop, by signal.
9416///
9417/// A registered subc module is asked over its own connection: the drain sends
9418/// `route.closing`/`route.closed` to its consumers, a GOODBYE per route, then a
9419/// module GOODBYE, and the module stops itself. A module that speaks no subc
9420/// wire receives none of that, and neither does a subc module that has not
9421/// registered yet, so for them the drain budget would be pure delay in front of
9422/// a SIGKILL -- and for a process with a store to flush (JetStream is the
9423/// reason `protocol: "none"` exists) a SIGKILL turns every ordinary teardown
9424/// into a recovery on the next start.
9425///
9426/// NEVER CALLED FOR A MODULE THAT WAS TOLD OVER ITS CONNECTION, and that is a
9427/// rule rather than an optimisation: that module's graceful stop is already
9428/// running by the time its child is drained, and a signal would race it.
9429///
9430/// Best-effort by construction. A child that has already exited is the ordinary
9431/// case rather than an error (the kill lands on a reaped or exiting pid), so a
9432/// failure is logged at debug and the wait-then-kill below still decides the
9433/// outcome.
9434#[cfg(unix)]
9435fn request_graceful_stop(module_id: &str, child: &SupervisedChild) {
9436    let Some(pid) = child
9437        .id()
9438        .and_then(|pid| i32::try_from(pid).ok())
9439        .and_then(rustix::process::Pid::from_raw)
9440    else {
9441        debug!(
9442            module_id,
9443            "no pid to signal for teardown; falling through to the drain wait"
9444        );
9445        return;
9446    };
9447    match rustix::process::kill_process(pid, rustix::process::Signal::TERM) {
9448        Ok(()) => debug!(
9449            module_id,
9450            "sent SIGTERM to a module nothing else asked to stop"
9451        ),
9452        Err(err) => debug!(
9453            module_id,
9454            error = %err,
9455            "SIGTERM to module failed; the drain wait and kill still apply"
9456        ),
9457    }
9458}
9459
9460/// Windows has no SIGTERM and no portable stand-in for one. The graceful stops
9461/// Windows does offer need cooperation this supervisor cannot assume: a console
9462/// control event requires sharing a console with the child, and `WM_CLOSE`
9463/// requires the child to pump a message loop. A supervised server process does
9464/// neither, so there is nothing to send and teardown is the wait followed by the
9465/// kill. Emulating a signal here would mean inventing a stop protocol, which is
9466/// the thing `protocol: "none"` exists to avoid.
9467#[cfg(not(unix))]
9468fn request_graceful_stop(module_id: &str, _child: &SupervisedChild) {
9469    debug!(
9470        module_id,
9471        "no graceful stop signal exists on this platform; teardown of a module nothing asked to stop waits, then kills"
9472    );
9473}
9474
9475fn terminal_disposition(final_state: ModuleState) -> TerminalDisposition {
9476    match final_state {
9477        ModuleState::Stopped => TerminalDisposition::Stopped,
9478        ModuleState::Disabled => TerminalDisposition::Disabled,
9479        ModuleState::Restarting => TerminalDisposition::Restarting,
9480        ModuleState::Failed => TerminalDisposition::Failed,
9481        ModuleState::Starting
9482        | ModuleState::Running
9483        | ModuleState::Unresponsive
9484        | ModuleState::Draining => {
9485            unreachable!("terminal exits only finish in terminal or restarting states")
9486        }
9487    }
9488}
9489
9490/// Release a reaped child's registration before allowing another spawn.
9491///
9492/// EOF is not a process-lifetime signal: an inherited socket can stay open
9493/// indefinitely, and serial frame dispatch can be waiting on egress instead of
9494/// reading EOF. After the normal release grace, request connection close (which
9495/// cancels both reads and dispatch), then allow one more release grace for the
9496/// connection guard's forwarding cleanup. Never evict a different connection.
9497async fn release_dead_registration(
9498    registry: &Registry,
9499    forwarding: Option<&ForwardingTable>,
9500    snapshot: &SharedSnapshot,
9501    module_id: &str,
9502) -> Result<(), SuperviseError> {
9503    let result = async {
9504        let registration = registry
9505            .get_module(module_id)
9506            .map_err(SuperviseError::Registry)?;
9507        match wait_for_registration_release(registry, module_id, REGISTRY_RELEASE_TIMEOUT).await {
9508            Ok(()) => return Ok(()),
9509            Err(SuperviseError::RegistrationStillActive { .. }) => {}
9510            Err(err) => return Err(err),
9511        }
9512        let pid = lock_snapshot(snapshot)?.reaped_pid;
9513        if let (Some(registration), Some(forwarding), Some(pid)) = (registration, forwarding, pid) {
9514            warn!(
9515                module_id,
9516                pid,
9517                connection_id = registration.connection_id.get(),
9518                "reaped module registration outlived release grace; closing dead connection"
9519            );
9520            forwarding.request_connection_close(
9521                registration.connection_id,
9522                CloseReason::new(
9523                    "supervised_process_reaped",
9524                    format!("module '{module_id}' pid {pid} exited"),
9525                ),
9526            );
9527            wait_for_slot_registration_release(
9528                registry,
9529                crate::registry::RegistrationSlot::Connection(registration.connection_id),
9530                REGISTRY_RELEASE_TIMEOUT,
9531            )
9532            .await?;
9533        }
9534        wait_for_registration_release(registry, module_id, Duration::ZERO).await
9535    }
9536    .await;
9537    if let Err(err) = &result {
9538        fail_snapshot(snapshot, Some(module_id), None);
9539        error!(module_id, error = %err, "registration release failed after child exit; module is failed and start can retry");
9540    }
9541    result
9542}
9543
9544/// Wait for the ACTIVE registration of `module_id` to go away, which is what a
9545/// plain stop or restart waits for before it spawns a replacement.
9546async fn wait_for_registration_release(
9547    registry: &Registry,
9548    module_id: &str,
9549    wait: Duration,
9550) -> Result<(), SuperviseError> {
9551    wait_for_slot_registration_release(
9552        registry,
9553        crate::registry::RegistrationSlot::Active(module_id),
9554        wait,
9555    )
9556    .await
9557}
9558
9559/// Wait for the registration in `slot` to go away.
9560///
9561/// Keyed on the slot rather than the bare module id because a successful swap
9562/// never empties the id's active slot (the promoted candidate is in it), so an
9563/// id-keyed wait for the incumbent's release would always time out. Draining a
9564/// swap's incumbent waits on `crate::registry::RegistrationSlot::Connection` with the
9565/// incumbent's connection instead.
9566async fn wait_for_slot_registration_release(
9567    registry: &Registry,
9568    slot: crate::registry::RegistrationSlot<'_>,
9569    wait: Duration,
9570) -> Result<(), SuperviseError> {
9571    let deadline = Instant::now() + wait;
9572    let mut release_events = registration_release_events().subscribe();
9573    let still_active = |registration: &crate::registry::ModuleRegistration| {
9574        SuperviseError::RegistrationStillActive {
9575            module_id: registration.manifest.module_id.clone(),
9576            waited: wait,
9577        }
9578    };
9579    loop {
9580        let _observed_generation = *release_events.borrow_and_update();
9581        let Some(registration) = registry
9582            .registration(slot)
9583            .map_err(SuperviseError::Registry)?
9584        else {
9585            return Ok(());
9586        };
9587
9588        let now = Instant::now();
9589        if now >= deadline {
9590            return Err(still_active(&registration));
9591        }
9592
9593        let remaining = deadline.saturating_duration_since(now);
9594        match timeout(remaining, release_events.changed()).await {
9595            Ok(Ok(())) | Ok(Err(_)) => {}
9596            Err(_) => return Err(still_active(&registration)),
9597        }
9598    }
9599}
9600
9601#[cfg(test)]
9602mod slot_registration_wait_tests {
9603    use super::*;
9604    use crate::registry::{ConnectionId, RegistrationSlot};
9605    use subc_protocol::manifest::ModuleManifest;
9606
9607    #[tokio::test]
9608    async fn enable_release_failure_is_failed_and_a_second_enable_retries() {
9609        let registry = Arc::new(Registry::default());
9610        let supervisor = Supervisor::new_for_test(Arc::clone(&registry), RestartPolicy::default());
9611        let runtime = supervisor.runtime_config();
9612        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::disabled()));
9613        let spec = ModuleSpec {
9614            module_id: "enable-stale-registration".to_string(),
9615            program: PathBuf::from("/missing/enable-retry-test"),
9616            args: Vec::new(),
9617            env: Vec::new(),
9618            reserved: false,
9619            reserved_prefixes: Vec::new(),
9620            protocol: ModuleProtocol::Subc,
9621            overlap: Default::default(),
9622        };
9623        let connection = ConnectionId::new(90);
9624        registry
9625            .register_with_control_ops(
9626                ModuleManifest::builder(&spec.module_id, "0.1.0").build(),
9627                1,
9628                connection,
9629                Vec::new(),
9630            )
9631            .unwrap();
9632        let mut child = None;
9633        let err = set_child_enabled(
9634            &spec,
9635            &runtime,
9636            &registry,
9637            &supervisor.process_liveness,
9638            &snapshot,
9639            &mut child,
9640            true,
9641        )
9642        .await
9643        .unwrap_err();
9644        assert!(matches!(
9645            err,
9646            SuperviseError::RegistrationStillActive { .. }
9647        ));
9648        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
9649        assert!(child.is_none());
9650        registry.deregister_connection(connection).unwrap();
9651        let err = set_child_enabled(
9652            &spec,
9653            &runtime,
9654            &registry,
9655            &supervisor.process_liveness,
9656            &snapshot,
9657            &mut child,
9658            true,
9659        )
9660        .await
9661        .unwrap_err();
9662        assert!(
9663            matches!(err, SuperviseError::Spawn { .. }),
9664            "second enable must attempt a spawn: {err}"
9665        );
9666        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
9667    }
9668
9669    const INCUMBENT: u64 = 1;
9670    const CANDIDATE: u64 = 2;
9671
9672    fn swapped_registry() -> Arc<Registry> {
9673        let registry = Arc::new(Registry::default());
9674        let manifest = ModuleManifest::builder("m", "0.1.0").build();
9675        registry
9676            .register_with_control_ops(
9677                manifest.clone(),
9678                1,
9679                ConnectionId::new(INCUMBENT),
9680                Vec::new(),
9681            )
9682            .unwrap();
9683        registry
9684            .register_candidate_with_control_ops(
9685                manifest,
9686                1,
9687                ConnectionId::new(CANDIDATE),
9688                Vec::new(),
9689            )
9690            .unwrap();
9691        registry
9692    }
9693
9694    /// After a promotion the id's active slot is held by the new process, so an
9695    /// id-keyed wait for the incumbent's release can never succeed; the
9696    /// connection-keyed wait completes as soon as the incumbent deregisters.
9697    #[tokio::test]
9698    async fn incumbent_release_is_awaited_by_connection_not_by_module_id() {
9699        let registry = swapped_registry();
9700        registry.promote_candidate("m").unwrap().unwrap();
9701
9702        assert!(matches!(
9703            wait_for_registration_release(&registry, "m", Duration::from_millis(50)).await,
9704            Err(SuperviseError::RegistrationStillActive { .. })
9705        ));
9706
9707        // Still held while the incumbent's connection has not deregistered.
9708        assert!(matches!(
9709            wait_for_slot_registration_release(
9710                &registry,
9711                RegistrationSlot::Connection(ConnectionId::new(INCUMBENT)),
9712                Duration::from_millis(50),
9713            )
9714            .await,
9715            Err(SuperviseError::RegistrationStillActive { .. })
9716        ));
9717
9718        let releaser = Arc::clone(&registry);
9719        let release = tokio::spawn(async move {
9720            sleep(Duration::from_millis(20)).await;
9721            releaser
9722                .deregister_connection(ConnectionId::new(INCUMBENT))
9723                .unwrap();
9724            notify_registration_release();
9725        });
9726        wait_for_slot_registration_release(
9727            &registry,
9728            RegistrationSlot::Connection(ConnectionId::new(INCUMBENT)),
9729            Duration::from_secs(5),
9730        )
9731        .await
9732        .expect("the incumbent's own registration is released");
9733        release.await.unwrap();
9734        assert!(registry.get_module("m").unwrap().is_some());
9735    }
9736
9737    /// The candidate slot is waited on separately from the active slot: the
9738    /// incumbent's registration neither holds up nor stands in for it.
9739    #[tokio::test]
9740    async fn candidate_slot_wait_ignores_the_incumbents_registration() {
9741        let registry = swapped_registry();
9742        assert!(matches!(
9743            wait_for_slot_registration_release(
9744                &registry,
9745                RegistrationSlot::Candidate("m"),
9746                Duration::from_millis(50),
9747            )
9748            .await,
9749            Err(SuperviseError::RegistrationStillActive { .. })
9750        ));
9751        registry
9752            .deregister_connection(ConnectionId::new(CANDIDATE))
9753            .unwrap();
9754        wait_for_slot_registration_release(
9755            &registry,
9756            RegistrationSlot::Candidate("m"),
9757            Duration::from_millis(50),
9758        )
9759        .await
9760        .expect("a candidate slot with no candidate is released");
9761        assert!(registry
9762            .registration(RegistrationSlot::Active("m"))
9763            .unwrap()
9764            .is_some());
9765    }
9766}
9767
9768fn classify_exit(status: &ExitStatus) -> ExitReport {
9769    ExitReport {
9770        kind: if status.success() {
9771            ExitKind::Clean
9772        } else {
9773            ExitKind::Crash
9774        },
9775        code: status.code(),
9776        signal: exit_signal(status),
9777        at_ms: unix_ms_now(),
9778    }
9779}
9780
9781/// The terminal record for a module whose `wait()` call itself errored (e.g. the
9782/// child was already reaped out-of-band). There is no `ExitStatus` to read a code
9783/// or signal from -- `None`/`None` is the honest shape, not a guess -- but the
9784/// disposition still must be `Failed` so the terminal ring is not silently missing
9785/// an entry, matching what `fail_snapshot` records for this same arm.
9786fn wait_error_exit_report() -> ExitReport {
9787    ExitReport {
9788        kind: ExitKind::Crash,
9789        code: None,
9790        signal: None,
9791        at_ms: unix_ms_now(),
9792    }
9793}
9794
9795#[cfg(unix)]
9796fn exit_signal(status: &ExitStatus) -> Option<i32> {
9797    use std::os::unix::process::ExitStatusExt;
9798
9799    status.signal()
9800}
9801
9802#[cfg(not(unix))]
9803fn exit_signal(_status: &ExitStatus) -> Option<i32> {
9804    None
9805}
9806
9807/// Give an operator-touched module its full crash budget back.
9808///
9809/// Named for the counter it used to zero; it now empties the in-window ring,
9810/// which is the same act. `lifetime_restarts` is untouched on purpose -- the
9811/// ledger of what happened survives every operator action.
9812fn reset_restart_count(snapshot: &SharedSnapshot, module_id: &str) -> Result<(), SuperviseError> {
9813    update_snapshot(snapshot, Some(module_id), |state| {
9814        state.clear_crash_restarts();
9815    })
9816}
9817
9818fn set_running(
9819    snapshot: &SharedSnapshot,
9820    child: &SupervisedChild,
9821    module_id: &str,
9822    spawn_events: &SpawnEventFeed,
9823) -> Result<(), SuperviseError> {
9824    let mut state = snapshot.lock().map_err(|_| SuperviseError::StatePoisoned {
9825        module_id: Some(module_id.to_string()),
9826    })?;
9827    state.spawn_generation = spawn_events.emit_spawned(module_id, child.pid, child.spawned_at_ms);
9828    if std::mem::take(&mut state.coalesced_restart_pending) {
9829        let generation = state.spawn_generation;
9830        info!(module_id, "restart already satisfied by generation {generation}; coalesced pending request completed");
9831    }
9832    state.drain_disposition_detail = None;
9833    state.spawn_failure = None;
9834    // Every caller of this is a plain spawn, which always uses the primary key;
9835    // a promoted swap candidate sets the flag itself after this returns.
9836    state.in_alternate_slot = false;
9837    state.configuration_updated_since_spawn = false;
9838    state.spawned_protocol = Some(child.protocol);
9839    state.state = ModuleState::Running;
9840    state.enabled = true;
9841    state.process_alive = true;
9842    state.pid = child.id();
9843    #[cfg(windows)]
9844    {
9845        state.pipe_consumed = child
9846            .nonce_delivery
9847            .as_ref()
9848            .map(|delivery| delivery.consumed());
9849    }
9850    #[cfg(target_os = "macos")]
9851    {
9852        state.report_ready = Some(Arc::clone(&child.report_ready));
9853    }
9854    state.spawned_at_ms = Some(child.spawned_at_ms);
9855    state.spawned_from = Some(child.spawned_from.clone());
9856    state.spawned_file_identity = child.spawned_file_identity.clone();
9857    state.process_start_time = child.process_start_time;
9858    Ok(())
9859}
9860
9861fn clear_current_process_facts(state: &mut SupervisorSnapshot) {
9862    state.process_alive = false;
9863    state.spawned_protocol = None;
9864    state.pid = None;
9865    #[cfg(windows)]
9866    {
9867        state.pipe_consumed = None;
9868    }
9869    #[cfg(target_os = "macos")]
9870    {
9871        state.report_ready = None;
9872    }
9873    state.spawned_at_ms = None;
9874    state.spawned_from = None;
9875    state.spawned_file_identity = None;
9876    state.process_start_time = None;
9877    state.deliberate_severance = None;
9878}
9879
9880#[cfg(test)]
9881fn record_deliberate_severance(
9882    snapshot: &SharedSnapshot,
9883    identity: ProcessIdentity,
9884) -> Result<(), SuperviseError> {
9885    update_snapshot(snapshot, None, |state| {
9886        state.deliberate_severance = Some(identity);
9887    })
9888}
9889
9890fn apply_deliberate_severance_marker(
9891    snapshot: &SharedSnapshot,
9892    exited_identity: Option<ProcessIdentity>,
9893    mut exit_report: ExitReport,
9894) -> ExitReport {
9895    let marker = lock_snapshot(snapshot)
9896        .ok()
9897        .and_then(|mut state| state.deliberate_severance.take());
9898    if marker.is_some() && marker == exited_identity {
9899        exit_report.kind = ExitKind::DeliberateSeverance;
9900    }
9901    exit_report
9902}
9903
9904fn classify_reaped_child_exit(
9905    snapshot: &SharedSnapshot,
9906    child: &SupervisedChild,
9907    status: &ExitStatus,
9908) -> ExitReport {
9909    let _ = update_snapshot(snapshot, None, |state| {
9910        state.reaped_pid = Some(child.pid);
9911        state.spawn_failure = child.spawn_failure.clone();
9912    });
9913    apply_deliberate_severance_marker(snapshot, child.process_identity(), classify_exit(status))
9914}
9915
9916fn fail_snapshot(
9917    snapshot: &SharedSnapshot,
9918    module_id: Option<&str>,
9919    last_exit: Option<ExitReport>,
9920) {
9921    if let Err(err) = update_snapshot(snapshot, module_id, |state| {
9922        state.state = ModuleState::Failed;
9923        clear_current_process_facts(state);
9924        if let Some(last_exit) = last_exit {
9925            state.last_exit = Some(last_exit);
9926        }
9927    }) {
9928        error!(error = %err, "failed to mark supervisor state failed");
9929    }
9930}
9931
9932fn update_snapshot(
9933    snapshot: &SharedSnapshot,
9934    module_id: Option<&str>,
9935    update: impl FnOnce(&mut SupervisorSnapshot),
9936) -> Result<(), SuperviseError> {
9937    let mut state = snapshot.lock().map_err(|_| SuperviseError::StatePoisoned {
9938        module_id: module_id.map(ToOwned::to_owned),
9939    })?;
9940    update(&mut state);
9941    Ok(())
9942}
9943
9944const SLOW_SNAPSHOT_LOCK_THRESHOLD: Duration = Duration::from_millis(250);
9945
9946fn lock_snapshot_for_control<'a>(
9947    snapshot: &'a SharedSnapshot,
9948    module_id: &str,
9949    caller: &'static str,
9950) -> Result<std::sync::MutexGuard<'a, SupervisorSnapshot>, SuperviseError> {
9951    let started_at = Instant::now();
9952    let guard = lock_snapshot(snapshot)?;
9953    let waited = started_at.elapsed();
9954    if waited >= SLOW_SNAPSHOT_LOCK_THRESHOLD {
9955        warn!(
9956            module_id = %module_id,
9957            waited_ms = waited.as_millis() as u64,
9958            caller = %caller,
9959            "slow snapshot lock"
9960        );
9961    }
9962    Ok(guard)
9963}
9964
9965fn lock_snapshot(
9966    snapshot: &SharedSnapshot,
9967) -> Result<std::sync::MutexGuard<'_, SupervisorSnapshot>, SuperviseError> {
9968    snapshot
9969        .lock()
9970        .map_err(|_| SuperviseError::StatePoisoned { module_id: None })
9971}
9972
9973#[cfg(test)]
9974mod terminal_history_tests {
9975    use std::{
9976        path::PathBuf,
9977        sync::Arc,
9978        time::{Duration, Instant},
9979    };
9980
9981    use tokio::{sync::mpsc, time::sleep};
9982
9983    use super::{
9984        apply_deliberate_severance_marker, daemon_will_restart, drain_child_to_state,
9985        drained_after_quiescence_wait, handle_reload_spawn_failure, health_restart_child,
9986        lock_snapshot, on_child_exit, record_deliberate_severance, record_wait_error_terminal,
9987        reset_restart_count, spawn_and_mark_running, update_snapshot, wait_error_exit_report,
9988        ExitKind, ExitReport, ModuleProtocol, ModuleSpec, ModuleState, NextAction, ProcessIdentity,
9989        RestartPolicy, SpawnEventKind, StopNotice, SuperviseError, SupervisedConfiguration,
9990        SupervisedModule, SupervisedModuleInner, Supervisor, SupervisorHandle,
9991        SupervisorHealthStatus, SupervisorSnapshot,
9992    };
9993    // The supervisor's clock, distinct from the `std::time::Instant` these tests
9994    // use for their own wall-clock deadlines: crash-restart instants must be on
9995    // the same clock the production code stamps them with, which is tokio's (and
9996    // is what `start_paused` tests can move).
9997    use super::Instant as ClockInstant;
9998    use crate::{
9999        registry::Registry,
10000        terminal_ring::{TerminalRing, TerminalRingConfig},
10001    };
10002    use std::sync::Mutex;
10003    use subc_control::TerminalDisposition;
10004
10005    /// See the twin in `control.rs` for why this derives the path from
10006    /// `current_exe()` and why the existence check is here: `--lib` alone does
10007    /// not build `[[bin]]` targets, and a bare spawn then fails with a raw
10008    /// `NotFound` that reads as a broken test rather than an unbuilt dependency.
10009    pub(super) fn fake_aft_stub_path() -> PathBuf {
10010        let mut path = std::env::current_exe().expect("current_exe available in tests");
10011        path.pop();
10012        path.pop();
10013        path.push(if cfg!(windows) {
10014            "fake-aft-stub.exe"
10015        } else {
10016            "fake-aft-stub"
10017        });
10018        assert!(
10019            path.exists(),
10020            "fake-aft-stub not built at {}: run `cargo test -p subc-core` (which builds \
10021             [[bin]] targets) rather than `cargo test -p subc-core --lib` (which does not)",
10022            path.display()
10023        );
10024        path
10025    }
10026
10027    #[test]
10028    fn reserved_never_spawned_refuses_every_hello() {
10029        // The canary hole: a reserved id whose module has never spawned had NO
10030        // gate entry and admitted anyone -- the reservation protected the nonce
10031        // holder, not the NAME. Now the entry is present with no legitimate
10032        // holder and refuses all comers.
10033        let supervisor = SupervisorHandle::default();
10034        supervisor.apply_identity_configuration(&ModuleSpec {
10035            module_id: "never-spawned".to_string(),
10036            program: PathBuf::from("/usr/bin/false"),
10037            args: Vec::new(),
10038            env: Vec::new(),
10039            reserved: true,
10040            reserved_prefixes: Vec::new(),
10041            protocol: ModuleProtocol::Subc,
10042            overlap: Default::default(),
10043        });
10044        assert!(
10045            supervisor
10046                .reserved_hello_rejection("never-spawned", Some("any-forged-nonce"))
10047                .is_some(),
10048            "forged nonce must refuse on a reserved never-spawned id"
10049        );
10050        assert!(
10051            supervisor
10052                .reserved_hello_rejection("never-spawned", None)
10053                .is_some(),
10054            "absent nonce must refuse on a reserved never-spawned id"
10055        );
10056        // And a real spawn nonce minted later admits exactly that nonce.
10057        supervisor.set_spawn_nonce("never-spawned", "minted".to_string());
10058        supervisor.apply_identity_configuration(&ModuleSpec {
10059            module_id: "never-spawned".to_string(),
10060            program: PathBuf::from("/usr/bin/false"),
10061            args: Vec::new(),
10062            env: Vec::new(),
10063            reserved: true,
10064            reserved_prefixes: Vec::new(),
10065            protocol: ModuleProtocol::Subc,
10066            overlap: Default::default(),
10067        });
10068        assert!(supervisor
10069            .reserved_hello_rejection("never-spawned", Some("minted"))
10070            .is_none());
10071        assert!(supervisor
10072            .reserved_hello_rejection("never-spawned", Some("forged"))
10073            .is_some());
10074    }
10075
10076    /// Put `count` crash restarts on a snapshot's ring as if they had all just
10077    /// happened, which is what "spent budget" looks like to every reader.
10078    fn seed_crash_restarts(state: &mut SupervisorSnapshot, count: u32) {
10079        let now = ClockInstant::now();
10080        for _ in 0..count {
10081            state.crash_restarts.push_back(now);
10082        }
10083    }
10084
10085    /// Age the oldest recorded restart out of `window`, standing in for the hours
10086    /// that would otherwise have to pass. Injecting the instant is the point: a
10087    /// test that slept a real window would take ten minutes and still prove less.
10088    fn age_oldest_crash_restart_out_of_window(state: &mut SupervisorSnapshot, window: Duration) {
10089        let aged = state
10090            .crash_restarts
10091            .front()
10092            .expect("a crash restart must be recorded before it can be aged")
10093            .checked_sub(window + Duration::from_secs(1))
10094            .expect("the test clock is far enough from its origin to age an instant");
10095        state.crash_restarts[0] = aged;
10096    }
10097
10098    fn snapshot_with_restarts(enabled: bool, count: u32) -> SupervisorSnapshot {
10099        let mut state = SupervisorSnapshot::new(ModuleState::Running, enabled);
10100        seed_crash_restarts(&mut state, count);
10101        state
10102    }
10103
10104    #[test]
10105    fn daemon_owned_recovery_predicate_uses_the_pre_increment_budget() {
10106        let policy = RestartPolicy::new(3, Duration::ZERO);
10107        let now = ClockInstant::now();
10108        assert!(daemon_will_restart(
10109            &mut snapshot_with_restarts(true, 2),
10110            &policy,
10111            now
10112        ));
10113        assert!(!daemon_will_restart(
10114            &mut snapshot_with_restarts(true, 3),
10115            &policy,
10116            now
10117        ));
10118        assert!(!daemon_will_restart(
10119            &mut snapshot_with_restarts(false, 0),
10120            &policy,
10121            now
10122        ));
10123    }
10124
10125    #[test]
10126    fn crash_restart_backoff_escalates_with_in_window_count() {
10127        let policy = RestartPolicy::new(4, Duration::from_millis(100))
10128            .with_max_backoff(Duration::from_secs(30));
10129        let now = ClockInstant::now();
10130        let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
10131        let schedules = (0..4)
10132            .map(|_| {
10133                state
10134                    .next_crash_restart(&policy, now)
10135                    .expect("the test policy allows four crash restarts")
10136            })
10137            .collect::<Vec<_>>();
10138
10139        assert_eq!(
10140            schedules
10141                .iter()
10142                .map(|schedule| schedule.restart_in_window)
10143                .collect::<Vec<_>>(),
10144            vec![0, 1, 2, 3]
10145        );
10146        assert_eq!(
10147            schedules
10148                .iter()
10149                .map(|schedule| schedule.delay)
10150                .collect::<Vec<_>>(),
10151            vec![
10152                Duration::from_millis(100),
10153                Duration::from_secs(1),
10154                Duration::from_secs(10),
10155                Duration::from_secs(30),
10156            ]
10157        );
10158    }
10159
10160    #[test]
10161    fn crash_restart_backoff_resets_after_ring_clear() {
10162        let policy = RestartPolicy::new(3, Duration::from_millis(100));
10163        let now = ClockInstant::now();
10164        let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
10165        assert_eq!(
10166            state.next_crash_restart(&policy, now).unwrap().delay,
10167            Duration::from_millis(100)
10168        );
10169        assert_eq!(
10170            state.next_crash_restart(&policy, now).unwrap().delay,
10171            Duration::from_secs(1)
10172        );
10173
10174        state.clear_crash_restarts();
10175        let schedule = state
10176            .next_crash_restart(&policy, now)
10177            .expect("a cleared ring must allow another restart");
10178        assert_eq!(schedule.restart_in_window, 0);
10179        assert_eq!(schedule.delay, Duration::from_millis(100));
10180    }
10181
10182    #[test]
10183    fn crash_restart_backoff_ignores_aged_restarts() {
10184        let policy = RestartPolicy::new(3, Duration::from_millis(100));
10185        let now = ClockInstant::now();
10186        let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
10187        state
10188            .next_crash_restart(&policy, now)
10189            .expect("the first restart is allowed");
10190        state
10191            .next_crash_restart(&policy, now)
10192            .expect("the second restart is allowed");
10193        state.crash_restarts[0] = now
10194            .checked_sub(policy.window + Duration::from_secs(1))
10195            .expect("the fake clock can age a restart past the window");
10196
10197        let schedule = state
10198            .next_crash_restart(&policy, now)
10199            .expect("an aged restart must release its slot");
10200        assert_eq!(schedule.restart_in_window, 1);
10201        assert_eq!(schedule.delay, Duration::from_secs(1));
10202        assert_eq!(state.crash_restarts.len(), 2);
10203    }
10204
10205    /// The budget is a rate: the same three spent restarts refuse a respawn
10206    /// while they are recent and allow one once they have aged past the window.
10207    /// Nothing about the module changed in between, which is the whole point.
10208    #[test]
10209    fn a_budget_spent_before_the_window_no_longer_refuses() {
10210        let policy = RestartPolicy::new(3, Duration::ZERO);
10211        let mut state = snapshot_with_restarts(true, 3);
10212        let now = ClockInstant::now();
10213        assert!(!daemon_will_restart(&mut state, &policy, now));
10214
10215        assert!(daemon_will_restart(
10216            &mut state,
10217            &policy,
10218            now + policy.window + Duration::from_secs(1)
10219        ));
10220        assert!(
10221            state.crash_restarts.is_empty(),
10222            "reading the budget must drop the instants that left the window"
10223        );
10224    }
10225
10226    fn module_with_recovery_snapshot(
10227        state: ModuleState,
10228        enabled: bool,
10229        restart_count: u32,
10230    ) -> SupervisedModule {
10231        let registry = Arc::new(Registry::default());
10232        let supervisor =
10233            Supervisor::new_for_test(Arc::clone(&registry), RestartPolicy::new(3, Duration::ZERO));
10234        let runtime = supervisor.runtime_config();
10235        let spec = ModuleSpec {
10236            module_id: "recovery-snapshot".to_string(),
10237            program: fake_aft_stub_path(),
10238            args: Vec::new(),
10239            env: Vec::new(),
10240            reserved: false,
10241            reserved_prefixes: Vec::new(),
10242            protocol: ModuleProtocol::Subc,
10243            overlap: Default::default(),
10244        };
10245        let mut snapshot = SupervisorSnapshot::new(state, enabled);
10246        seed_crash_restarts(&mut snapshot, restart_count);
10247        // These tests read synthetic snapshots. A real child and monitor would
10248        // race those reads by replacing the requested state during startup.
10249        let (commands, _rx) = mpsc::channel(4);
10250        SupervisedModule {
10251            inner: Arc::new(SupervisedModuleInner {
10252                module_id: spec.module_id.clone(),
10253                registry,
10254                snapshot: Arc::new(Mutex::new(snapshot)),
10255                configuration: Arc::new(Mutex::new(SupervisedConfiguration {
10256                    spec,
10257                    health: runtime.health,
10258                })),
10259                stderr_ring: runtime.stderr_ring,
10260                terminal_ring: runtime.terminal_ring,
10261                commands,
10262                monitor: Mutex::new(None),
10263                restart_policy: runtime.restart_policy,
10264                effective_drain_timeout: runtime.effective_drain_timeout,
10265                provenance_probe: supervisor.provenance_probe.clone(),
10266            }),
10267        }
10268    }
10269
10270    #[cfg(target_os = "linux")]
10271    #[tokio::test]
10272    async fn no_cgroup_placement_does_not_block_fake_aft_stub_spawn() {
10273        let supervisor =
10274            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
10275                .with_cgroup_placement(None);
10276        let result = supervisor.spawn(ModuleSpec {
10277            module_id: "no-cgroup-placement".to_string(),
10278            program: fake_aft_stub_path(),
10279            args: Vec::new(),
10280            env: Vec::new(),
10281            reserved: false,
10282            reserved_prefixes: Vec::new(),
10283            protocol: ModuleProtocol::Subc,
10284            overlap: Default::default(),
10285        });
10286
10287        assert!(
10288            result.is_ok(),
10289            "no delegation must not turn an otherwise valid spawn into a failure: {result:?}"
10290        );
10291    }
10292
10293    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10294    async fn undecided_snapshot_uses_shared_restart_predicate() {
10295        assert!(module_with_recovery_snapshot(ModuleState::Running, true, 2)
10296            .will_recover_after_connection_loss()
10297            .unwrap());
10298        assert!(
10299            !module_with_recovery_snapshot(ModuleState::Running, true, 3)
10300                .will_recover_after_connection_loss()
10301                .unwrap()
10302        );
10303    }
10304
10305    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10306    async fn restarting_snapshot_at_exhausted_budget_is_non_terminal() {
10307        assert!(
10308            module_with_recovery_snapshot(ModuleState::Restarting, true, 3)
10309                .will_recover_after_connection_loss()
10310                .unwrap()
10311        );
10312    }
10313
10314    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10315    async fn terminal_phase_snapshots_are_terminal_before_budget_exhaustion() {
10316        assert!(!module_with_recovery_snapshot(ModuleState::Failed, true, 0)
10317            .will_recover_after_connection_loss()
10318            .unwrap());
10319        assert!(
10320            !module_with_recovery_snapshot(ModuleState::Disabled, true, 0)
10321                .will_recover_after_connection_loss()
10322                .unwrap()
10323        );
10324    }
10325
10326    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10327    async fn warming_snapshot_is_limited_to_startup_phases() {
10328        for state in [
10329            ModuleState::Starting,
10330            ModuleState::Running,
10331            ModuleState::Restarting,
10332        ] {
10333            assert!(
10334                module_with_recovery_snapshot(state, true, 0)
10335                    .is_warming()
10336                    .unwrap(),
10337                "{state:?} should be warming"
10338            );
10339        }
10340        for state in [
10341            ModuleState::Unresponsive,
10342            ModuleState::Draining,
10343            ModuleState::Stopped,
10344            ModuleState::Failed,
10345            ModuleState::Disabled,
10346        ] {
10347            assert!(
10348                !module_with_recovery_snapshot(state, true, 0)
10349                    .is_warming()
10350                    .unwrap(),
10351                "{state:?} should not be warming"
10352            );
10353        }
10354    }
10355
10356    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10357    async fn terminal_history_survives_respawn_and_keeps_both_crashes_in_order() {
10358        let registry = Arc::new(Registry::default());
10359        let supervisor =
10360            Supervisor::new_for_test(Arc::clone(&registry), RestartPolicy::new(1, Duration::ZERO));
10361        let module = supervisor
10362            .spawn(ModuleSpec {
10363                module_id: "terminal-history".to_string(),
10364                program: fake_aft_stub_path(),
10365                args: Vec::new(),
10366                env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
10367                reserved: false,
10368                reserved_prefixes: Vec::new(),
10369                protocol: ModuleProtocol::Subc,
10370                overlap: Default::default(),
10371            })
10372            .unwrap();
10373
10374        let deadline = Instant::now() + Duration::from_secs(5);
10375        loop {
10376            let history = module.terminal_history();
10377            if history.entries.len() == 2 {
10378                assert_eq!(module.status().unwrap().state, ModuleState::Failed);
10379                assert_eq!(history.dropped, 0);
10380                assert_eq!(
10381                    history
10382                        .entries
10383                        .iter()
10384                        .map(|entry| entry.exit_code)
10385                        .collect::<Vec<_>>(),
10386                    vec![Some(23), Some(23)]
10387                );
10388                assert!(history.entries[0].at_ms <= history.entries[1].at_ms);
10389                return;
10390            }
10391            assert!(
10392                Instant::now() < deadline,
10393                "module did not retain two terminal exits: {history:?}"
10394            );
10395            sleep(Duration::from_millis(10)).await;
10396        }
10397    }
10398
10399    /// A disable issued while a crash respawn is still backing off must preempt
10400    /// that respawn: the operator's stop wins, the disable must not queue behind
10401    /// the backoff, and the module must never come back up afterwards.
10402    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10403    async fn disable_during_crash_backoff_cancels_pending_respawn() {
10404        let backoff = Duration::from_secs(2);
10405        let supervisor = Supervisor::new_for_test(
10406            Arc::new(Registry::default()),
10407            RestartPolicy::new(10, backoff),
10408        );
10409        let module = supervisor
10410            .spawn(ModuleSpec {
10411                module_id: "disable-during-backoff".to_string(),
10412                program: fake_aft_stub_path(),
10413                args: Vec::new(),
10414                env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
10415                reserved: false,
10416                reserved_prefixes: Vec::new(),
10417                protocol: ModuleProtocol::Subc,
10418                overlap: Default::default(),
10419            })
10420            .unwrap();
10421
10422        // Wait for the first crash to put the module into its backoff window.
10423        let deadline = Instant::now() + Duration::from_secs(5);
10424        loop {
10425            if module.status().unwrap().state == ModuleState::Restarting {
10426                break;
10427            }
10428            assert!(
10429                Instant::now() < deadline,
10430                "module never entered the crash backoff"
10431            );
10432            sleep(Duration::from_millis(10)).await;
10433        }
10434
10435        let started = Instant::now();
10436        module.set_enabled(false).await.unwrap();
10437        let waited = started.elapsed();
10438
10439        assert!(
10440            waited < backoff / 2,
10441            "disable waited {waited:?} behind the {backoff:?} crash backoff; the operator command must preempt the pending respawn"
10442        );
10443        assert_eq!(module.status().unwrap().state, ModuleState::Disabled);
10444
10445        // Outlast the backoff: the respawn it was counting down to must never run.
10446        sleep(backoff + Duration::from_millis(500)).await;
10447        let status = module.status().unwrap();
10448        assert_eq!(status.state, ModuleState::Disabled);
10449        assert_eq!(
10450            status.spawn_generation, 1,
10451            "module respawned after the operator disabled it"
10452        );
10453    }
10454
10455    /// A `protocol: "none"` child that stays parked and exits 0 on SIGTERM:
10456    /// the shape of nats-server, the program this rule exists for.
10457    #[cfg(unix)]
10458    fn protocol_none_sigterm_exits_clean_spec(
10459        module_id: &str,
10460        dir: &std::path::Path,
10461    ) -> (ModuleSpec, PathBuf, PathBuf) {
10462        let ready = dir.join("ready");
10463        let marker = dir.join("sigterm");
10464        let spec = ModuleSpec {
10465            module_id: module_id.to_string(),
10466            program: fake_aft_stub_path(),
10467            args: Vec::new(),
10468            env: vec![
10469                ("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string()),
10470                (
10471                    "FAKE_AFT_SIGTERM_MARKER_PATH".to_string(),
10472                    marker.display().to_string(),
10473                ),
10474                (
10475                    "FAKE_AFT_NEVER_CONNECT_READY_PATH".to_string(),
10476                    ready.display().to_string(),
10477                ),
10478            ],
10479            reserved: false,
10480            reserved_prefixes: Vec::new(),
10481            protocol: ModuleProtocol::None,
10482            overlap: Default::default(),
10483        };
10484        (spec, ready, marker)
10485    }
10486
10487    /// Wait for a file the child writes, so a signal is never sent before the
10488    /// child's SIGTERM handler is installed (the default disposition would
10489    /// kill it by signal and the exit would not be clean).
10490    #[cfg(unix)]
10491    async fn wait_for_file(path: &std::path::Path) {
10492        let deadline = Instant::now() + Duration::from_secs(10);
10493        while !path.exists() {
10494            assert!(
10495                Instant::now() < deadline,
10496                "{} never appeared",
10497                path.display()
10498            );
10499            sleep(Duration::from_millis(10)).await;
10500        }
10501    }
10502
10503    /// A protocol-none module that exits 0 because something OUTSIDE the
10504    /// supervisor sent it SIGTERM is respawned, and the terminal record carries
10505    /// the crash-path disposition rather than `stopped`.
10506    #[cfg(unix)]
10507    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10508    async fn protocol_none_unrequested_clean_exit_restarts_as_a_crash() {
10509        let dir = cortexkit_test_support::ScratchDir::new("none-unrequested-clean-exit");
10510        let (spec, ready, marker) =
10511            protocol_none_sigterm_exits_clean_spec("none-unrequested-clean-exit", dir.path());
10512        let supervisor = Supervisor::new_for_test(
10513            Arc::new(Registry::default()),
10514            RestartPolicy::new(3, Duration::ZERO),
10515        );
10516        let module = supervisor.spawn(spec).unwrap();
10517        wait_for_file(&ready).await;
10518        // The ready file proves the child installed its SIGTERM handler, not
10519        // that the supervisor has processed the privacy trampoline's exec
10520        // acknowledgement. On macOS status withholds the pid until then.
10521        let deadline = Instant::now() + Duration::from_secs(10);
10522        let first_pid = loop {
10523            let status = module.status().unwrap();
10524            if status.state == ModuleState::Running {
10525                if let Some(pid) = status.pid {
10526                    break pid;
10527                }
10528            }
10529            assert!(
10530                Instant::now() < deadline,
10531                "a running module must report its pid after exec confirmation: {status:?}"
10532            );
10533            sleep(Duration::from_millis(10)).await;
10534        };
10535
10536        rustix::process::kill_process(
10537            rustix::process::Pid::from_raw(i32::try_from(first_pid).unwrap()).unwrap(),
10538            rustix::process::Signal::TERM,
10539        )
10540        .unwrap();
10541
10542        let deadline = Instant::now() + Duration::from_secs(10);
10543        let respawned = loop {
10544            let status = module.status().unwrap();
10545            if status.state == ModuleState::Running
10546                && status.pid.is_some_and(|pid| pid != first_pid)
10547            {
10548                break status;
10549            }
10550            assert!(
10551                Instant::now() < deadline,
10552                "protocol-none module was not respawned after an unrequested clean exit: {status:?}"
10553            );
10554            sleep(Duration::from_millis(10)).await;
10555        };
10556        assert_eq!(respawned.spawn_generation, 2);
10557        assert!(
10558            marker.exists(),
10559            "the child must have exited through its SIGTERM handler (exit 0), or this proves nothing about clean exits"
10560        );
10561
10562        let history = module.terminal_history();
10563        assert_eq!(history.entries.len(), 1, "{history:?}");
10564        let entry = &history.entries[0];
10565        assert_eq!(entry.exit_code, Some(0));
10566        assert_eq!(entry.exit_kind, subc_control::TerminalExitKind::Clean);
10567        assert_eq!(entry.disposition, TerminalDisposition::Restarting);
10568
10569        module.stop().await.unwrap();
10570    }
10571
10572    /// Repeated unrequested clean exits of a protocol-none module spend the
10573    /// restart budget exactly as crashes do, and the module ends `failed` with
10574    /// the budget named.
10575    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10576    async fn protocol_none_repeated_clean_exits_exhaust_the_restart_budget() {
10577        let supervisor = Supervisor::new_for_test(
10578            Arc::new(Registry::default()),
10579            RestartPolicy::new(1, Duration::ZERO),
10580        );
10581        let module = supervisor
10582            .spawn(ModuleSpec {
10583                module_id: "none-clean-exit-budget".to_string(),
10584                program: fake_aft_stub_path(),
10585                args: Vec::new(),
10586                env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "0".to_string())],
10587                reserved: false,
10588                reserved_prefixes: Vec::new(),
10589                protocol: ModuleProtocol::None,
10590                overlap: Default::default(),
10591            })
10592            .unwrap();
10593
10594        // Failed follows the terminal write; this deadline only bounds a hang,
10595        // not an assumed duration for the two launches or their exit recording.
10596        let deadline = Instant::now() + Duration::from_secs(10);
10597        loop {
10598            let status = module.status().unwrap();
10599            if status.state == ModuleState::Failed {
10600                break;
10601            }
10602            assert!(
10603                Instant::now() < deadline,
10604                "module never exhausted its budget: {status:?} {:?}",
10605                module.terminal_history()
10606            );
10607            sleep(Duration::from_millis(10)).await;
10608        }
10609        let history = module.terminal_history();
10610        assert_eq!(
10611            history
10612                .entries
10613                .iter()
10614                .map(|entry| (entry.exit_code, entry.disposition.clone()))
10615                .collect::<Vec<_>>(),
10616            vec![
10617                (Some(0), TerminalDisposition::Restarting),
10618                (Some(0), TerminalDisposition::Failed),
10619            ]
10620        );
10621        let detail = history.entries[1]
10622            .disposition_detail
10623            .as_deref()
10624            .expect("a budget failure names the budget");
10625        assert!(detail.contains("max_restarts=1"), "{detail}");
10626        assert_eq!(module.status().unwrap().spawn_generation, 2);
10627    }
10628
10629    #[test]
10630    fn restart_budget_failure_is_published_after_its_terminal_record() {
10631        let supervisor = Supervisor::new_for_test(
10632            Arc::new(Registry::default()),
10633            RestartPolicy::new(0, Duration::ZERO),
10634        );
10635        let runtime = supervisor.runtime_config();
10636        let spec = ModuleSpec {
10637            protocol: ModuleProtocol::None,
10638            ..windowed_crash_spec("budget-publication-order")
10639        };
10640        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(
10641            ModuleState::Running,
10642            true,
10643        )));
10644        let reaped_snapshot = snapshot.clone();
10645        let events = runtime.spawn_events.clone();
10646        let history = runtime.terminal_ring.clone();
10647
10648        // Exit recording takes the event-feed lock before the history lock.
10649        // Holding it pauses the writer after choosing a disposition but before
10650        // recording history, without assuming anything about scheduler timing.
10651        let before_record = events.0.lock().unwrap();
10652        let reap = std::thread::spawn(move || {
10653            tokio::runtime::Builder::new_current_thread()
10654                .enable_all()
10655                .build()
10656                .unwrap()
10657                .block_on(on_child_exit(
10658                    &spec,
10659                    runtime.restart_policy,
10660                    &supervisor.registry,
10661                    &reaped_snapshot,
10662                    &runtime.terminal_ring,
10663                    &runtime.spawn_events,
10664                    &runtime.child_roster,
10665                    ExitReport {
10666                        kind: ExitKind::Clean,
10667                        code: Some(0),
10668                        signal: None,
10669                        at_ms: 1,
10670                    },
10671                ))
10672        });
10673        // The deadline bounds a hung writer only; last_exit is the handshake.
10674        let deadline = Instant::now() + Duration::from_secs(10);
10675        let before_state = loop {
10676            let state = lock_snapshot(&snapshot).unwrap();
10677            if state.last_exit.is_some() {
10678                break state.state;
10679            }
10680            drop(state);
10681            assert!(Instant::now() < deadline, "exit decision was not reached");
10682            std::thread::yield_now();
10683        };
10684        let before_history = history.lock().unwrap().snapshot();
10685        drop(before_record);
10686        assert!(matches!(reap.join().unwrap(), NextAction::Stop { .. }));
10687        assert!(before_history.entries.is_empty());
10688        assert_ne!(
10689            before_state,
10690            ModuleState::Failed,
10691            "Failed was visible before its terminal record could be written"
10692        );
10693        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
10694        let history = history.lock().unwrap().snapshot();
10695        assert_eq!(history.entries.len(), 1);
10696        assert_eq!(history.entries[0].exit_code, Some(0));
10697        assert_eq!(history.entries[0].disposition, TerminalDisposition::Failed);
10698    }
10699
10700    #[tokio::test]
10701    async fn restart_budget_failure_remains_failed_when_journal_append_fails() {
10702        let dir = cortexkit_test_support::ScratchDir::new("budget-journal-failure");
10703        let path = dir.join("terminals.jsonl");
10704        std::fs::create_dir(&path).unwrap();
10705        let supervisor = Supervisor::new_for_test(
10706            Arc::new(Registry::default()),
10707            RestartPolicy::new(0, Duration::ZERO),
10708        )
10709        .with_terminal_journal(path, "budget-journal-failure".into());
10710        let runtime = supervisor.runtime_config();
10711        let spec = windowed_crash_spec("budget-journal-failure");
10712        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
10713        assert!(matches!(
10714            on_child_exit(
10715                &spec,
10716                runtime.restart_policy,
10717                &supervisor.registry,
10718                &snapshot,
10719                &runtime.terminal_ring,
10720                &runtime.spawn_events,
10721                &runtime.child_roster,
10722                crash_exit_report(1),
10723            )
10724            .await,
10725            NextAction::Stop { .. }
10726        ));
10727        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
10728        let history = runtime
10729            .terminal_ring
10730            .lock()
10731            .unwrap()
10732            .durable_history(&spec.module_id);
10733        assert!(history.journal_write_failures > 0);
10734        assert_eq!(history.entries.len(), 1);
10735        assert_eq!(history.entries[0].disposition, TerminalDisposition::Failed);
10736    }
10737
10738    #[test]
10739    fn restart_budget_failure_remains_failed_when_exit_recording_panics() {
10740        let supervisor = Supervisor::new_for_test(
10741            Arc::new(Registry::default()),
10742            RestartPolicy::new(0, Duration::ZERO),
10743        );
10744        let runtime = supervisor.runtime_config();
10745        let spec = windowed_crash_spec("budget-recording-panic");
10746        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
10747        runtime.spawn_events.emit_spawned(&spec.module_id, 1, 1);
10748        // Exhausting the event sequence makes emit_exited panic before the
10749        // terminal write, exercising publication on the recording unwind.
10750        runtime.spawn_events.0.lock().unwrap().seq = u64::MAX;
10751        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
10752            tokio::runtime::Builder::new_current_thread()
10753                .enable_all()
10754                .build()
10755                .unwrap()
10756                .block_on(on_child_exit(
10757                    &spec,
10758                    runtime.restart_policy,
10759                    &supervisor.registry,
10760                    &snapshot,
10761                    &runtime.terminal_ring,
10762                    &runtime.spawn_events,
10763                    &runtime.child_roster,
10764                    crash_exit_report(1),
10765                ))
10766        }));
10767        let panic = result.err().expect("recording must still unwind");
10768        assert_eq!(
10769            panic.downcast_ref::<String>().map(String::as_str),
10770            Some("spawn event sequence exhausted")
10771        );
10772        assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
10773    }
10774
10775    /// A stop the supervisor itself requests still stops a protocol-none
10776    /// module, even though the child answers the SIGTERM with exit 0.
10777    #[cfg(unix)]
10778    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10779    async fn protocol_none_requested_stop_and_disable_do_not_respawn() {
10780        for disable in [false, true] {
10781            let label = if disable {
10782                "none-requested-disable"
10783            } else {
10784                "none-requested-stop"
10785            };
10786            let dir = cortexkit_test_support::ScratchDir::new(label);
10787            let (spec, ready, marker) = protocol_none_sigterm_exits_clean_spec(label, dir.path());
10788            let supervisor = Supervisor::new_for_test(
10789                Arc::new(Registry::default()),
10790                RestartPolicy::new(3, Duration::ZERO),
10791            );
10792            let module = supervisor.spawn(spec).unwrap();
10793            wait_for_file(&ready).await;
10794
10795            if disable {
10796                module.set_enabled(false).await.unwrap();
10797            } else {
10798                module.stop().await.unwrap();
10799            }
10800            assert!(
10801                marker.exists(),
10802                "{label}: the child must have left through its SIGTERM handler with exit 0"
10803            );
10804
10805            // Long enough for a zero-backoff respawn to have happened if the
10806            // exit had been treated as a crash.
10807            sleep(Duration::from_millis(500)).await;
10808            let status = module.status().unwrap();
10809            let expected = if disable {
10810                ModuleState::Disabled
10811            } else {
10812                ModuleState::Stopped
10813            };
10814            assert_eq!(status.state, expected, "{label}");
10815            assert_eq!(
10816                status.spawn_generation, 1,
10817                "{label}: respawned after a requested stop"
10818            );
10819            let history = module.terminal_history();
10820            assert_eq!(history.entries.len(), 1, "{label}: {history:?}");
10821            assert_eq!(history.entries[0].exit_code, Some(0), "{label}");
10822            assert_ne!(
10823                history.entries[0].disposition,
10824                TerminalDisposition::Restarting,
10825                "{label}"
10826            );
10827        }
10828    }
10829
10830    /// A subc-wire module that exits 0 on its own is still a stop: the
10831    /// protocol-none rule must not reach it.
10832    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10833    async fn subc_wire_clean_exit_is_still_a_stop() {
10834        let supervisor = Supervisor::new_for_test(
10835            Arc::new(Registry::default()),
10836            RestartPolicy::new(3, Duration::ZERO),
10837        );
10838        let module = supervisor
10839            .spawn(ModuleSpec {
10840                module_id: "wire-clean-exit".to_string(),
10841                program: fake_aft_stub_path(),
10842                args: Vec::new(),
10843                env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "0".to_string())],
10844                reserved: false,
10845                reserved_prefixes: Vec::new(),
10846                protocol: ModuleProtocol::Subc,
10847                overlap: Default::default(),
10848            })
10849            .unwrap();
10850
10851        let deadline = Instant::now() + Duration::from_secs(10);
10852        while module.terminal_history().entries.is_empty() {
10853            assert!(Instant::now() < deadline, "module never exited");
10854            sleep(Duration::from_millis(10)).await;
10855        }
10856        // Long enough for a zero-backoff respawn to have happened.
10857        sleep(Duration::from_millis(500)).await;
10858        let status = module.status().unwrap();
10859        assert_eq!(status.state, ModuleState::Stopped);
10860        assert_eq!(status.spawn_generation, 1);
10861        let history = module.terminal_history();
10862        assert_eq!(history.entries.len(), 1, "{history:?}");
10863        assert_eq!(history.entries[0].exit_code, Some(0));
10864        assert_eq!(history.entries[0].disposition, TerminalDisposition::Stopped);
10865    }
10866
10867    #[cfg(unix)]
10868    #[tokio::test]
10869    async fn reload_exit_keeps_roster_until_terminal_is_recorded_during_shutdown() {
10870        let dir = cortexkit_test_support::ScratchDir::new("reload-roster-terminal-order");
10871        let record = dir.join("live-children.json");
10872        let supervisor = Supervisor::new_for_test(
10873            Arc::new(Registry::default()),
10874            RestartPolicy::new(0, Duration::ZERO),
10875        );
10876        let mut runtime = supervisor.runtime_config();
10877        runtime.child_roster.record_to(record.clone());
10878        let gate = Arc::new(super::ReloadExitRecordGate::default());
10879        runtime.test_reload_exit_record_gate = Some(Arc::clone(&gate));
10880        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
10881        let spec = ModuleSpec {
10882            module_id: "reload-exit-roster".into(),
10883            program: fake_aft_stub_path(),
10884            args: Vec::new(),
10885            env: vec![("FAKE_AFT_EXIT_CODE".into(), "23".into())],
10886            reserved: false,
10887            reserved_prefixes: Vec::new(),
10888            protocol: ModuleProtocol::Subc,
10889            overlap: Default::default(),
10890        };
10891        let mut child = None;
10892        let reload = super::finish_reload_child(
10893            &spec,
10894            &runtime,
10895            &supervisor.registry,
10896            &supervisor.process_liveness,
10897            &snapshot,
10898            &mut child,
10899        );
10900        tokio::pin!(reload);
10901        tokio::select! {
10902            result = &mut reload => panic!("reload missed the before-record gate: {result:?}"),
10903            _ = gate.reached.notified() => {}
10904        }
10905        assert!(runtime
10906            .terminal_ring
10907            .lock()
10908            .unwrap()
10909            .snapshot()
10910            .entries
10911            .is_empty());
10912        assert_eq!(
10913            crate::live_children::read_record(&record).unwrap().len(),
10914            1,
10915            "shutdown must still wait for the reaped child until its terminal record exists"
10916        );
10917        runtime.child_roster.close();
10918        gate.resume.notify_one();
10919        assert!(reload.await.is_err());
10920        assert!(crate::live_children::read_record(&record)
10921            .unwrap()
10922            .is_empty());
10923        let history = runtime.terminal_ring.lock().unwrap().snapshot();
10924        assert_eq!(history.entries.len(), 1);
10925        assert_eq!(
10926            history.entries[0].disposition,
10927            TerminalDisposition::DaemonShutdown
10928        );
10929    }
10930
10931    /// Each restart-producing arm has its own state transition. Keeping their
10932    /// lifetime count assertions adjacent prevents a later new arm from silently
10933    /// spending budget without recording the historical restart.
10934    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10935    async fn every_restart_increment_path_advances_lifetime_count() {
10936        let supervisor = Supervisor::new_for_test(
10937            Arc::new(Registry::default()),
10938            RestartPolicy::new(1, Duration::ZERO),
10939        );
10940        let runtime = supervisor.runtime_config();
10941        let spec = ModuleSpec {
10942            module_id: "lifetime-increment-path".to_string(),
10943            program: PathBuf::from("/unused/lifetime-increment-path"),
10944            args: Vec::new(),
10945            env: Vec::new(),
10946            reserved: false,
10947            reserved_prefixes: Vec::new(),
10948            protocol: ModuleProtocol::Subc,
10949            overlap: Default::default(),
10950        };
10951
10952        let crash_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
10953        assert!(matches!(
10954            on_child_exit(
10955                &spec,
10956                runtime.restart_policy,
10957                &supervisor.registry,
10958                &crash_snapshot,
10959                &runtime.terminal_ring,
10960                &runtime.spawn_events,
10961                &runtime.child_roster,
10962                ExitReport {
10963                    kind: ExitKind::Crash,
10964                    code: Some(1),
10965                    signal: None,
10966                    at_ms: 1,
10967                },
10968            )
10969            .await,
10970            NextAction::Restart { schedule: _ }
10971        ));
10972        let (crash_restarts, crash_lifetime) = {
10973            let state = lock_snapshot(&crash_snapshot).unwrap();
10974            (state.crash_restarts.len(), state.lifetime_restarts)
10975        };
10976        assert_eq!(crash_restarts, 1);
10977        assert_eq!(crash_lifetime, 1);
10978
10979        let health_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
10980        let mut health_child = None;
10981        assert!(matches!(
10982            health_restart_child(
10983                &spec,
10984                &runtime,
10985                &supervisor.registry,
10986                &supervisor.process_liveness,
10987                &health_snapshot,
10988                &mut health_child,
10989                SupervisorHealthStatus::Failing,
10990                None,
10991                2,
10992            )
10993            .await,
10994            Ok(())
10995        ));
10996        assert!(health_child.is_none());
10997        assert!(lock_snapshot(&health_snapshot).unwrap().respawn_pending);
10998        assert!(runtime.scheduled_respawn.lock().unwrap().take().is_some());
10999        let (health_restarts, health_lifetime) = {
11000            let state = lock_snapshot(&health_snapshot).unwrap();
11001            (state.crash_restarts.len(), state.lifetime_restarts)
11002        };
11003        assert_eq!(health_restarts, 1);
11004        assert_eq!(health_lifetime, 1);
11005
11006        let reload_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11007        let mut reload_child = None;
11008        assert!(matches!(
11009            handle_reload_spawn_failure(
11010                &spec,
11011                &runtime,
11012                &supervisor.process_liveness,
11013                &reload_snapshot,
11014                &mut reload_child,
11015                "forced reload spawn failure".to_string(),
11016            )
11017            .await,
11018            Err(SuperviseError::ReloadFailed { .. })
11019        ));
11020        let (reload_restarts, reload_lifetime) = {
11021            let state = lock_snapshot(&reload_snapshot).unwrap();
11022            (state.crash_restarts.len(), state.lifetime_restarts)
11023        };
11024        assert_eq!(reload_restarts, 1);
11025        assert_eq!(reload_lifetime, 1);
11026    }
11027
11028    #[tokio::test]
11029    async fn deliberately_severed_live_child_records_lifetime_without_spending_restart_budget() {
11030        let supervisor = Supervisor::new_for_test(
11031            Arc::new(Registry::default()),
11032            RestartPolicy::new(3, Duration::ZERO),
11033        );
11034        let runtime = supervisor.runtime_config();
11035        let spec = ModuleSpec {
11036            module_id: "deliberately-severed".to_string(),
11037            program: PathBuf::from("/unused/deliberately-severed"),
11038            args: Vec::new(),
11039            env: Vec::new(),
11040            reserved: false,
11041            reserved_prefixes: Vec::new(),
11042            protocol: ModuleProtocol::Subc,
11043            overlap: Default::default(),
11044        };
11045        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11046        let process = ProcessIdentity {
11047            pid: 41,
11048            start_time: 101,
11049        };
11050        record_deliberate_severance(&snapshot, process).unwrap();
11051        let exit_report = apply_deliberate_severance_marker(
11052            &snapshot,
11053            Some(process),
11054            ExitReport {
11055                kind: ExitKind::Crash,
11056                code: Some(1),
11057                signal: None,
11058                at_ms: 1,
11059            },
11060        );
11061        assert_eq!(exit_report.kind, ExitKind::DeliberateSeverance);
11062
11063        assert!(matches!(
11064            on_child_exit(
11065                &spec,
11066                runtime.restart_policy,
11067                &supervisor.registry,
11068                &snapshot,
11069                &runtime.terminal_ring,
11070                &runtime.spawn_events,
11071                &runtime.child_roster,
11072                exit_report,
11073            )
11074            .await,
11075            NextAction::Restart { schedule: _ }
11076        ));
11077        let state = lock_snapshot(&snapshot).unwrap();
11078        assert_eq!(state.lifetime_restarts, 1);
11079        assert_eq!(state.crash_restarts.len(), 0);
11080    }
11081
11082    #[tokio::test]
11083    async fn genuine_crash_spends_restart_budget_and_records_lifetime() {
11084        let supervisor = Supervisor::new_for_test(
11085            Arc::new(Registry::default()),
11086            RestartPolicy::new(3, Duration::ZERO),
11087        );
11088        let runtime = supervisor.runtime_config();
11089        let spec = ModuleSpec {
11090            module_id: "genuine-crash".to_string(),
11091            program: PathBuf::from("/unused/genuine-crash"),
11092            args: Vec::new(),
11093            env: Vec::new(),
11094            reserved: false,
11095            reserved_prefixes: Vec::new(),
11096            protocol: ModuleProtocol::Subc,
11097            overlap: Default::default(),
11098        };
11099        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11100
11101        assert!(matches!(
11102            on_child_exit(
11103                &spec,
11104                runtime.restart_policy,
11105                &supervisor.registry,
11106                &snapshot,
11107                &runtime.terminal_ring,
11108                &runtime.spawn_events,
11109                &runtime.child_roster,
11110                ExitReport {
11111                    kind: ExitKind::Crash,
11112                    code: Some(1),
11113                    signal: None,
11114                    at_ms: 1,
11115                },
11116            )
11117            .await,
11118            NextAction::Restart { schedule: _ }
11119        ));
11120        let state = lock_snapshot(&snapshot).unwrap();
11121        assert_eq!(state.lifetime_restarts, 1);
11122        assert_eq!(state.crash_restarts.len(), 1);
11123    }
11124
11125    fn crash_exit_report(at_ms: u64) -> ExitReport {
11126        ExitReport {
11127            kind: ExitKind::Crash,
11128            code: Some(1),
11129            signal: None,
11130            at_ms,
11131        }
11132    }
11133
11134    fn windowed_crash_spec(module_id: &str) -> ModuleSpec {
11135        ModuleSpec {
11136            module_id: module_id.to_string(),
11137            program: PathBuf::from("/unused").join(module_id),
11138            args: Vec::new(),
11139            env: Vec::new(),
11140            reserved: false,
11141            reserved_prefixes: Vec::new(),
11142            protocol: ModuleProtocol::Subc,
11143            overlap: Default::default(),
11144        }
11145    }
11146
11147    /// A real crash loop still stops. Three crashes with nothing aging out spend
11148    /// a budget of two and the third respawn is refused, and both surfaces an
11149    /// operator has -- the log line and the retained terminal record -- name the
11150    /// window rather than only the cap, because `max_restarts=2` alone is what
11151    /// this budget used to mean.
11152    #[tokio::test]
11153    async fn three_crashes_inside_the_window_stop_the_module_and_name_the_window() {
11154        let (logs, _guard) = crate::router::test_log::log_capture(tracing::Level::ERROR);
11155        let supervisor = Supervisor::new_for_test(
11156            Arc::new(Registry::default()),
11157            RestartPolicy::new(2, Duration::ZERO),
11158        );
11159        let runtime = supervisor.runtime_config();
11160        let spec = windowed_crash_spec("crash-loop-in-window");
11161        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11162
11163        for attempt in 1..=2 {
11164            assert!(
11165                matches!(
11166                    on_child_exit(
11167                        &spec,
11168                        runtime.restart_policy,
11169                        &supervisor.registry,
11170                        &snapshot,
11171                        &runtime.terminal_ring,
11172                        &runtime.spawn_events,
11173                        &runtime.child_roster,
11174                        crash_exit_report(attempt),
11175                    )
11176                    .await,
11177                    NextAction::Restart { schedule: _ }
11178                ),
11179                "crash {attempt} is inside the budget and must respawn"
11180            );
11181        }
11182
11183        assert!(matches!(
11184            on_child_exit(
11185                &spec,
11186                runtime.restart_policy,
11187                &supervisor.registry,
11188                &snapshot,
11189                &runtime.terminal_ring,
11190                &runtime.spawn_events,
11191                &runtime.child_roster,
11192                crash_exit_report(3),
11193            )
11194            .await,
11195            NextAction::Stop { .. }
11196        ));
11197
11198        {
11199            let state = lock_snapshot(&snapshot).unwrap();
11200            assert_eq!(state.state, ModuleState::Failed);
11201            assert_eq!(state.crash_restarts.len(), 2);
11202            assert_eq!(state.lifetime_restarts, 2);
11203        }
11204
11205        let history = runtime
11206            .terminal_ring
11207            .lock()
11208            .expect("terminal ring is not poisoned")
11209            .snapshot();
11210        let last = history
11211            .entries
11212            .last()
11213            .expect("the refused crash is retained");
11214        assert_eq!(last.disposition, TerminalDisposition::Failed);
11215        assert_eq!(
11216            last.disposition_detail.as_deref(),
11217            Some("crash budget exhausted: max_restarts=2 within window_secs=600")
11218        );
11219
11220        let captured = crate::router::test_log::captured_logs(&logs);
11221        assert!(
11222            captured.contains("crash budget exhausted: max_restarts=2 within window_secs=600"),
11223            "the stop must be logged with its window: {captured}"
11224        );
11225    }
11226
11227    /// The rate, stated as a test: three crashes where the first has aged past
11228    /// the window are two crashes as far as the budget is concerned, so the
11229    /// third respawn is allowed and the ring holds only the two recent ones.
11230    ///
11231    /// This is the case a lifetime counter got wrong -- and the case the daemon
11232    /// now hits routinely, since a module exits non-zero every time its
11233    /// connection to the daemon drops.
11234    #[tokio::test]
11235    async fn a_crash_older_than_the_window_frees_its_slot_for_a_later_crash() {
11236        let supervisor = Supervisor::new_for_test(
11237            Arc::new(Registry::default()),
11238            RestartPolicy::new(2, Duration::ZERO),
11239        );
11240        let runtime = supervisor.runtime_config();
11241        let spec = windowed_crash_spec("crash-across-windows");
11242        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11243
11244        for attempt in 1..=2 {
11245            assert!(matches!(
11246                on_child_exit(
11247                    &spec,
11248                    runtime.restart_policy,
11249                    &supervisor.registry,
11250                    &snapshot,
11251                    &runtime.terminal_ring,
11252                    &runtime.spawn_events,
11253                    &runtime.child_roster,
11254                    crash_exit_report(attempt),
11255                )
11256                .await,
11257                NextAction::Restart { schedule: _ }
11258            ));
11259        }
11260
11261        // The oldest crash moves out of the window; nothing else about the
11262        // module changes.
11263        update_snapshot(&snapshot, Some(&spec.module_id), |state| {
11264            age_oldest_crash_restart_out_of_window(state, runtime.restart_policy.window);
11265        })
11266        .unwrap();
11267
11268        assert!(
11269            matches!(
11270                on_child_exit(
11271                    &spec,
11272                    runtime.restart_policy,
11273                    &supervisor.registry,
11274                    &snapshot,
11275                    &runtime.terminal_ring,
11276                    &runtime.spawn_events,
11277                    &runtime.child_roster,
11278                    crash_exit_report(3),
11279                )
11280                .await,
11281                NextAction::Restart { schedule: _ }
11282            ),
11283            "a crash older than the window must not hold a budget slot"
11284        );
11285
11286        let state = lock_snapshot(&snapshot).unwrap();
11287        assert_eq!(state.state, ModuleState::Restarting);
11288        assert_eq!(
11289            state.crash_restarts.len(),
11290            2,
11291            "the aged instant is dropped and the new one takes its place"
11292        );
11293        assert_eq!(
11294            state.lifetime_restarts, 3,
11295            "the ledger counts every restart, including the ones the window forgot"
11296        );
11297    }
11298
11299    /// An operator restart hands the budget back whole, and the ledger keeps
11300    /// counting. Those are different questions -- "how close is this module to
11301    /// being stopped" and "how many times has it been replaced" -- and the
11302    /// operator action answers only the first.
11303    #[tokio::test]
11304    async fn an_operator_restart_clears_the_ring_and_leaves_the_ledger_alone() {
11305        let supervisor = Supervisor::new_for_test(
11306            Arc::new(Registry::default()),
11307            RestartPolicy::new(2, Duration::ZERO),
11308        );
11309        let runtime = supervisor.runtime_config();
11310        let spec = windowed_crash_spec("operator-cleared-budget");
11311        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11312
11313        for attempt in 1..=2 {
11314            assert!(matches!(
11315                on_child_exit(
11316                    &spec,
11317                    runtime.restart_policy,
11318                    &supervisor.registry,
11319                    &snapshot,
11320                    &runtime.terminal_ring,
11321                    &runtime.spawn_events,
11322                    &runtime.child_roster,
11323                    crash_exit_report(attempt),
11324                )
11325                .await,
11326                NextAction::Restart { schedule: _ }
11327            ));
11328        }
11329
11330        reset_restart_count(&snapshot, &spec.module_id).unwrap();
11331        {
11332            let state = lock_snapshot(&snapshot).unwrap();
11333            assert!(
11334                state.crash_restarts.is_empty(),
11335                "an operator restart returns the full budget"
11336            );
11337            assert_eq!(
11338                state.lifetime_restarts, 2,
11339                "clearing the budget must not unmake the crashes"
11340            );
11341        }
11342
11343        assert!(
11344            matches!(
11345                on_child_exit(
11346                    &spec,
11347                    runtime.restart_policy,
11348                    &supervisor.registry,
11349                    &snapshot,
11350                    &runtime.terminal_ring,
11351                    &runtime.spawn_events,
11352                    &runtime.child_roster,
11353                    crash_exit_report(3),
11354                )
11355                .await,
11356                NextAction::Restart { schedule: _ }
11357            ),
11358            "the cleared budget must be spendable again"
11359        );
11360        let state = lock_snapshot(&snapshot).unwrap();
11361        assert_eq!(state.crash_restarts.len(), 1);
11362        assert_eq!(state.lifetime_restarts, 3);
11363    }
11364
11365    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11366    async fn severance_marker_for_a_dead_child_does_not_label_its_successor() {
11367        let severed = ProcessIdentity {
11368            pid: 41,
11369            start_time: 101,
11370        };
11371        let successor = ProcessIdentity {
11372            pid: 41,
11373            start_time: 202,
11374        };
11375        let module = module_with_recovery_snapshot(ModuleState::Running, true, 0);
11376        update_snapshot(&module.inner.snapshot, Some("recovery-snapshot"), |state| {
11377            state.pid = Some(successor.pid);
11378            state.process_start_time = Some(successor.start_time);
11379        })
11380        .unwrap();
11381        assert!(!module.record_deliberate_severance(severed).unwrap());
11382
11383        let exit_report = apply_deliberate_severance_marker(
11384            &module.inner.snapshot,
11385            Some(successor),
11386            ExitReport {
11387                kind: ExitKind::Crash,
11388                code: Some(1),
11389                signal: None,
11390                at_ms: 1,
11391            },
11392        );
11393
11394        assert_eq!(exit_report.kind, ExitKind::Crash);
11395    }
11396
11397    #[tokio::test]
11398    async fn drain_reap_marks_deliberate_severance_and_records_lifetime_without_budget() {
11399        let registry = Registry::default();
11400        let supervisor = Supervisor::new_for_test(
11401            Arc::new(Registry::default()),
11402            RestartPolicy::new(3, Duration::ZERO),
11403        );
11404        let runtime = supervisor.runtime_config();
11405        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11406        let spec = ModuleSpec {
11407            module_id: "drain-deliberate-severance".to_string(),
11408            program: fake_aft_stub_path(),
11409            args: Vec::new(),
11410            env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
11411            reserved: false,
11412            reserved_prefixes: Vec::new(),
11413            protocol: ModuleProtocol::Subc,
11414            overlap: Default::default(),
11415        };
11416        let mut child = spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap();
11417        let process = ProcessIdentity {
11418            pid: 41,
11419            start_time: 101,
11420        };
11421        child.process_identity = Some(process);
11422        update_snapshot(&snapshot, Some(&spec.module_id), |state| {
11423            state.pid = Some(process.pid);
11424            state.process_start_time = Some(process.start_time);
11425        })
11426        .unwrap();
11427        record_deliberate_severance(&snapshot, process).unwrap();
11428
11429        drain_child_to_state(
11430            &spec.module_id,
11431            spec.protocol,
11432            // The child exits on its own; no signal may change the exit this
11433            // test classifies.
11434            StopNotice::SentOverConnection,
11435            &registry,
11436            None,
11437            &snapshot,
11438            &runtime.terminal_ring,
11439            &runtime.spawn_events,
11440            child,
11441            Duration::from_secs(1),
11442            ModuleState::Stopped,
11443            Some(false),
11444        )
11445        .await
11446        .unwrap();
11447
11448        let state = lock_snapshot(&snapshot).unwrap();
11449        assert_eq!(
11450            state.last_exit.as_ref().map(|exit| exit.kind),
11451            Some(ExitKind::DeliberateSeverance)
11452        );
11453        assert_eq!(state.lifetime_restarts, 1);
11454        assert_eq!(state.crash_restarts.len(), 0);
11455        drop(state);
11456        let history = runtime.terminal_ring.lock().unwrap().snapshot();
11457        assert_eq!(
11458            history.entries[0].exit_kind,
11459            subc_control::TerminalExitKind::DeliberateSeverance
11460        );
11461    }
11462
11463    #[tokio::test]
11464    async fn ordinary_drain_reap_does_not_record_a_lifetime_restart() {
11465        let registry = Registry::default();
11466        let supervisor = Supervisor::new_for_test(
11467            Arc::new(Registry::default()),
11468            RestartPolicy::new(3, Duration::ZERO),
11469        );
11470        let runtime = supervisor.runtime_config();
11471        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11472        let spec = ModuleSpec {
11473            module_id: "ordinary-drain".to_string(),
11474            program: fake_aft_stub_path(),
11475            args: Vec::new(),
11476            env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
11477            reserved: false,
11478            reserved_prefixes: Vec::new(),
11479            protocol: ModuleProtocol::Subc,
11480            overlap: Default::default(),
11481        };
11482        let child = spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap();
11483
11484        drain_child_to_state(
11485            &spec.module_id,
11486            spec.protocol,
11487            // The child exits on its own; no signal may change the exit this
11488            // test classifies.
11489            StopNotice::SentOverConnection,
11490            &registry,
11491            None,
11492            &snapshot,
11493            &runtime.terminal_ring,
11494            &runtime.spawn_events,
11495            child,
11496            Duration::from_secs(1),
11497            ModuleState::Stopped,
11498            Some(false),
11499        )
11500        .await
11501        .unwrap();
11502
11503        let state = lock_snapshot(&snapshot).unwrap();
11504        assert_eq!(
11505            state.last_exit.as_ref().map(|exit| exit.kind),
11506            Some(ExitKind::Crash)
11507        );
11508        assert_eq!(state.lifetime_restarts, 0);
11509        assert_eq!(state.crash_restarts.len(), 0);
11510    }
11511
11512    #[test]
11513    fn fatal_connection_teardown_cannot_arm_a_marker_for_a_surviving_process() {
11514        // The server's generic fatal-routing branch only knows that the
11515        // connection failed; it does not know that the daemon deliberately
11516        // initiated a process-killing severance. Keep this seam explicit so a
11517        // future connection error path cannot silently reintroduce the stale
11518        // exemption that mislabels a later genuine crash.
11519        assert!(!include_str!("server.rs")
11520            .contains("router.record_deliberate_connection_severance(ctx.connection_id)"));
11521    }
11522
11523    /// The `route.closed` `drained` value must be the quiescence wait's own
11524    /// measurement (`Ok`), never invented -- except on `Err`, where there is no
11525    /// measurement at all and `false` is the one honest constant. This is the exact
11526    /// logic `begin_forwarding_drain_with` now applies before sending `route.closed`
11527    /// on every return path, including the one that used to return early via `?`
11528    /// with `route.closing` already sent and no `route.closed` ever following.
11529    #[test]
11530    fn drained_after_quiescence_wait_passes_ok_through_and_forces_false_on_err() {
11531        assert!(drained_after_quiescence_wait(&Ok(true)));
11532        assert!(!drained_after_quiescence_wait(&Ok(false)));
11533        assert!(!drained_after_quiescence_wait(&Err(
11534            SuperviseError::StatePoisoned { module_id: None }
11535        )));
11536    }
11537
11538    /// `supervise_loop`'s `wait()`-error arm now calls `record_terminal` like every
11539    /// other exit path does, so a module whose child `wait()` itself errored (e.g.
11540    /// already reaped out-of-band) still leaves a terminal record rather than none
11541    /// at all. Triggering the real `wait()` I/O error from an integration test would
11542    /// need a genuine already-reaped-child race, which is OS-specific and not
11543    /// something this suite attempts elsewhere; this test instead verifies the
11544    /// record produced for that arm end-to-end through the real `TerminalRing`, and
11545    /// the call site itself is verified by inspection to sit in that exact arm.
11546    #[test]
11547    fn wait_error_exit_report_records_a_failed_terminal_with_no_code_or_signal() {
11548        let ring = Arc::new(Mutex::new(TerminalRing::new(
11549            TerminalRingConfig::default(),
11550            0,
11551        )));
11552        record_wait_error_terminal("wait-error", &ring, &super::SpawnEventFeed::default());
11553
11554        let snapshot = ring.lock().unwrap().snapshot();
11555        assert_eq!(snapshot.entries.len(), 1);
11556        let entry = &snapshot.entries[0];
11557        assert_eq!(entry.exit_code, None);
11558        assert_eq!(entry.exit_signal, None);
11559        assert_eq!(entry.disposition, TerminalDisposition::Failed);
11560    }
11561
11562    #[test]
11563    fn wait_error_exit_path_preserves_spawn_event_density() {
11564        let feed = super::SpawnEventFeed::default();
11565        feed.configure_incarnation("wait-error-density".to_string());
11566        feed.emit_spawned("wait-error", 41, 1);
11567        let ring = Arc::new(Mutex::new(TerminalRing::new(
11568            TerminalRingConfig::default(),
11569            0,
11570        )));
11571
11572        record_wait_error_terminal("wait-error", &ring, &feed);
11573        feed.emit_spawned("after-wait-error", 42, 2);
11574
11575        let state = feed.0.lock().unwrap();
11576        let sequences = state
11577            .events
11578            .iter()
11579            .map(|event| event.cursor.seq)
11580            .collect::<Vec<_>>();
11581        assert_eq!(sequences, vec![1, 2, 3]);
11582        assert_eq!(state.events[1].kind, SpawnEventKind::Exited);
11583        assert_eq!(state.events[1].exit_code, None);
11584        assert_eq!(state.events[1].exit_signal, None);
11585    }
11586
11587    /// Pins the report's `kind` too: the wait-error arm treats an unwaitable child
11588    /// as a crash (matching `fail_snapshot`'s `Failed` disposition for this arm),
11589    /// not a clean exit it never actually observed.
11590    #[test]
11591    fn wait_error_exit_report_is_classified_as_a_crash() {
11592        assert_eq!(wait_error_exit_report().kind, ExitKind::Crash);
11593    }
11594}
11595
11596#[cfg(all(test, unix))]
11597mod health_event_tests {
11598    use super::*;
11599    use cortexkit_test_support::ScratchDir;
11600    use subc_protocol::{manifest::Concurrency, session::ModuleControlResponse};
11601
11602    struct Actor {
11603        module: SupervisedModule,
11604        registry: Arc<Registry>,
11605        forwarding: Arc<ForwardingTable>,
11606        spec: ModuleSpec,
11607        health: HealthConfig,
11608        rx: mpsc::Receiver<crate::router::OutboundFrame>,
11609        _home: ScratchDir,
11610    }
11611
11612    async fn wait_for<T>(reason: &str, mut observe: impl FnMut() -> Option<T>) -> T {
11613        // OS exec and socket readiness are real I/O. Stay runnable while waiting
11614        // for an observed condition, rather than letting paused time auto-advance
11615        // deadlines or assuming a fixed number of yields finishes the work.
11616        let deadline = std::time::Instant::now() + Duration::from_secs(10);
11617        let tick = Instant::now();
11618        loop {
11619            if let Some(value) = observe() {
11620                assert_eq!(Instant::now(), tick, "{reason} must not wait for a timer");
11621                return value;
11622            }
11623            assert!(
11624                std::time::Instant::now() < deadline,
11625                "timed out waiting for {reason}"
11626            );
11627            tokio::task::yield_now().await;
11628        }
11629    }
11630
11631    impl Actor {
11632        async fn start(protocol: ModuleProtocol, cadence: Duration) -> Self {
11633            let home = ScratchDir::new("health-events");
11634            let registry = Arc::new(Registry::default());
11635            let forwarding = Arc::new(ForwardingTable::default());
11636            let health = HealthConfig {
11637                cadence,
11638                deadline: Duration::from_secs(3600),
11639                ..HealthConfig::default()
11640            };
11641            let supervisor =
11642                Supervisor::new_for_test(registry.clone(), RestartPolicy::new(3, Duration::ZERO))
11643                    .with_forwarding(forwarding.clone())
11644                    .with_health_config(health.clone());
11645            let spec = ModuleSpec {
11646                module_id: "event-health-child".into(),
11647                program: PathBuf::from("/bin/sh"),
11648                // The process stays alive but has no real bus peer. Registrations
11649                // below exercise the same registry writes as accepted HELLOs.
11650                args: vec!["-c".into(), "exec sleep 600".into()],
11651                env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
11652                    .into_iter()
11653                    .map(|key| (key.into(), home.path().display().to_string()))
11654                    .collect(),
11655                reserved: false,
11656                reserved_prefixes: vec![],
11657                protocol,
11658                overlap: Default::default(),
11659            };
11660            let module = supervisor.spawn(spec.clone()).unwrap();
11661            let (_, rx) = mpsc::channel(8);
11662            let actor = Self {
11663                module,
11664                registry,
11665                forwarding,
11666                spec,
11667                health,
11668                rx,
11669                _home: home,
11670            };
11671            actor
11672                .wait_for_select(|checkpoint| checkpoint.generation > 0)
11673                .await;
11674            actor
11675        }
11676
11677        fn turns(&self) -> u64 {
11678            lock_snapshot(&self.module.inner.snapshot)
11679                .unwrap()
11680                .actor_turns
11681        }
11682
11683        async fn wait_for_select(
11684            &self,
11685            expected: impl Fn(&ActorSelectCheckpoint) -> bool,
11686        ) -> ActorSelectCheckpoint {
11687            wait_for("actor select checkpoint", || {
11688                let state = lock_snapshot(&self.module.inner.snapshot).unwrap();
11689                state.actor_select.filter(|checkpoint| {
11690                    checkpoint.generation == state.spawn_generation
11691                        && state.state == ModuleState::Running
11692                        && state.process_alive
11693                        && state.reported_pid().is_some()
11694                        && expected(checkpoint)
11695                })
11696            })
11697            .await
11698        }
11699
11700        fn hello(&mut self, connection: u64, health: bool) {
11701            let manifest =
11702                subc_protocol::manifest::ModuleManifest::builder(&self.spec.module_id, "1.0.0")
11703                    .protocol_ver(subc_protocol::PROTOCOL_VERSION)
11704                    .build();
11705            let connection = ConnectionId::new(connection);
11706            let (tx, rx) = mpsc::channel(8);
11707            self.rx = rx;
11708            self.forwarding
11709                .register_module_connection(
11710                    connection,
11711                    self.spec.module_id.clone(),
11712                    subc_protocol::PROTOCOL_VERSION,
11713                    Concurrency::ModuleManaged,
11714                    FrameSink::new(tx),
11715                )
11716                .unwrap();
11717            self.registry
11718                .register_with_control_ops(
11719                    manifest,
11720                    subc_protocol::PROTOCOL_VERSION,
11721                    connection,
11722                    if health {
11723                        vec![MODULE_CONTROL_OP_HEALTH_CHECK.into()]
11724                    } else {
11725                        vec![]
11726                    },
11727                )
11728                .unwrap();
11729        }
11730
11731        async fn probe(&mut self) -> crate::router::OutboundFrame {
11732            let frame = wait_for("outbound health probe", || match self.rx.try_recv() {
11733                Ok(frame) => Some(frame),
11734                Err(mpsc::error::TryRecvError::Empty) => None,
11735                Err(mpsc::error::TryRecvError::Disconnected) => panic!("health peer disconnected"),
11736            })
11737            .await;
11738            let body: Value = serde_json::from_slice(&frame.body).unwrap();
11739            assert_eq!(body["op"], MODULE_CONTROL_OP_HEALTH_CHECK);
11740            frame
11741        }
11742
11743        fn no_probe(&mut self) {
11744            assert!(matches!(
11745                self.rx.try_recv(),
11746                Err(mpsc::error::TryRecvError::Empty)
11747            ));
11748        }
11749
11750        fn answer(&self, connection: u64, frame: crate::router::OutboundFrame) {
11751            self.forwarding
11752                .complete_module_control_rpc(
11753                    ConnectionId::new(connection),
11754                    frame.header.corr,
11755                    Some(MODULE_CONTROL_OP_HEALTH_CHECK),
11756                    ModuleControlRpcOutcome::Response(ModuleControlResponse::HealthCheck {
11757                        status: HealthStatus::Ok,
11758                        detail: None,
11759                        metrics: None,
11760                    }),
11761                )
11762                .unwrap();
11763        }
11764    }
11765
11766    #[tokio::test(start_paused = true)]
11767    async fn no_health_children_park_for_sixty_seconds() {
11768        let none = Actor::start(ModuleProtocol::None, Duration::from_secs(30)).await;
11769        let unregistered = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
11770        let mut unadvertised = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
11771        unadvertised.hello(1, false);
11772        // Include any one-off registration wake in the budget; receiving a
11773        // no-health HELLO must not turn a parked child into a polling child.
11774        // Advance in 10 ms steps so the old poll really executes ~6,000 turns;
11775        // a single 60 s jump would only observe one expired timer.
11776        for _ in 0..6000 {
11777            tokio::time::advance(Duration::from_millis(10)).await;
11778            tokio::task::yield_now().await;
11779        }
11780        for actor in [&none, &unregistered, &unadvertised] {
11781            assert!(
11782                actor.turns() <= 5,
11783                "no-health actor woke {} times",
11784                actor.turns()
11785            );
11786            assert_eq!(actor.module.state().unwrap(), ModuleState::Running);
11787        }
11788    }
11789
11790    #[tokio::test(start_paused = true)]
11791    async fn late_hello_starts_a_due_probe_in_the_notification_tick() {
11792        // With a zero cadence the first probe is due at once, so this measures
11793        // only how fast the registration notification starts health probing,
11794        // not the normal 30-33 s wait before a first probe (checked below).
11795        let mut actor = Actor::start(ModuleProtocol::Subc, Duration::ZERO).await;
11796        let tick = Instant::now();
11797        actor.hello(1, true);
11798        actor.probe().await;
11799        assert_eq!(Instant::now(), tick, "HELLO must not wait for a poll");
11800    }
11801
11802    #[tokio::test(start_paused = true)]
11803    async fn hello_keeps_the_jittered_cadence_and_catalog_updates_do_not_reset_it() {
11804        let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
11805        let hello_tick = Instant::now();
11806        actor.hello(1, true);
11807        let armed = actor
11808            .wait_for_select(|checkpoint| {
11809                checkpoint.registered_connection == Some(ConnectionId::new(1))
11810                    && checkpoint.next_probe_at.is_some()
11811            })
11812            .await;
11813        let delay = jittered_health_delay(&actor.spec.module_id, 0, actor.health.cadence);
11814        assert!((Duration::from_secs(30)..Duration::from_secs(33)).contains(&delay));
11815        let first_deadline = hello_tick + delay;
11816        assert_eq!(armed.next_probe_at, Some(first_deadline));
11817        tokio::time::advance(Duration::from_secs(29)).await;
11818        let turns = actor.turns();
11819        actor
11820            .registry
11821            .replace_catalog_for_connection(ConnectionId::new(1), vec![], None, Some(true))
11822            .unwrap();
11823        let updated = actor
11824            .wait_for_select(|checkpoint| checkpoint.turn > turns)
11825            .await;
11826        assert_eq!(
11827            updated.next_probe_at,
11828            Some(first_deadline),
11829            "catalog update must not reset cadence"
11830        );
11831        actor.no_probe();
11832        tokio::time::advance(first_deadline - Instant::now()).await;
11833        let frame = actor.probe().await;
11834        actor.answer(1, frame);
11835        let next = jittered_health_delay(&actor.spec.module_id, 1, actor.health.cadence);
11836        let next_deadline = Instant::now() + next;
11837        let rearmed = actor
11838            .wait_for_select(|checkpoint| {
11839                checkpoint
11840                    .next_probe_at
11841                    .is_some_and(|deadline| deadline > first_deadline)
11842            })
11843            .await;
11844        assert_eq!(rearmed.next_probe_at, Some(next_deadline));
11845        tokio::time::advance(next - Duration::from_nanos(1)).await;
11846        actor.no_probe();
11847        tokio::time::advance(Duration::from_nanos(1)).await;
11848        actor.probe().await;
11849    }
11850
11851    #[tokio::test(start_paused = true)]
11852    async fn disconnect_disarms_and_reconnect_rearms_health() {
11853        let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
11854        actor.hello(1, true);
11855        actor
11856            .wait_for_select(|checkpoint| {
11857                checkpoint.registered_connection == Some(ConnectionId::new(1))
11858                    && checkpoint.next_probe_at.is_some()
11859            })
11860            .await;
11861        let turns = actor.turns();
11862        let tick = Instant::now();
11863        actor
11864            .registry
11865            .deregister_connection(ConnectionId::new(1))
11866            .unwrap();
11867        let disarmed = actor
11868            .wait_for_select(|checkpoint| {
11869                checkpoint.turn > turns && checkpoint.registered_connection.is_none()
11870            })
11871            .await;
11872        assert_eq!(disarmed.next_probe_at, None);
11873        assert_eq!(disarmed.wake_after, None);
11874        assert_eq!(
11875            Instant::now(),
11876            tick,
11877            "disconnect must disarm in the notification tick"
11878        );
11879        tokio::time::advance(Duration::from_secs(60)).await;
11880        actor.no_probe();
11881        let reconnect_tick = Instant::now();
11882        actor.hello(2, true);
11883        let rearmed = actor
11884            .wait_for_select(|checkpoint| {
11885                checkpoint.registered_connection == Some(ConnectionId::new(2))
11886                    && checkpoint.next_probe_at.is_some()
11887            })
11888            .await;
11889        let delay = jittered_health_delay(&actor.spec.module_id, 0, actor.health.cadence);
11890        assert_eq!(rearmed.next_probe_at, Some(reconnect_tick + delay));
11891        tokio::time::advance(delay).await;
11892        actor.probe().await;
11893    }
11894
11895    #[tokio::test(start_paused = true)]
11896    async fn reregistration_without_health_disarms_the_old_schedule() {
11897        let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
11898        actor.hello(1, true);
11899        actor
11900            .wait_for_select(|checkpoint| {
11901                checkpoint.registered_connection == Some(ConnectionId::new(1))
11902                    && checkpoint.next_probe_at.is_some()
11903            })
11904            .await;
11905        actor
11906            .registry
11907            .deregister_connection(ConnectionId::new(1))
11908            .unwrap();
11909        actor.hello(2, false);
11910        let disarmed = actor
11911            .wait_for_select(|checkpoint| {
11912                checkpoint.registered_connection == Some(ConnectionId::new(2))
11913            })
11914            .await;
11915        assert_eq!(disarmed.next_probe_at, None);
11916        assert_eq!(disarmed.wake_after, None);
11917        tokio::time::advance(Duration::from_secs(60)).await;
11918        actor.no_probe();
11919    }
11920
11921    #[tokio::test(start_paused = true)]
11922    async fn restart_waits_for_its_own_hello_before_rearming_health() {
11923        let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
11924        actor.hello(1, true);
11925        let armed = actor
11926            .wait_for_select(|checkpoint| {
11927                checkpoint.registered_connection == Some(ConnectionId::new(1))
11928                    && checkpoint.next_probe_at.is_some()
11929            })
11930            .await;
11931        // Simulate the old process's connection closing before the restart
11932        // tears the process down. The replacement process has started but has
11933        // not sent its HELLO, so it is not registered.
11934        actor
11935            .registry
11936            .deregister_connection(ConnectionId::new(1))
11937            .unwrap();
11938        actor.module.restart(Some(0)).await.unwrap();
11939        let parked = actor
11940            .wait_for_select(|checkpoint| {
11941                checkpoint.generation > armed.generation
11942                    && checkpoint.registered_connection.is_none()
11943                    && checkpoint.next_probe_at.is_none()
11944            })
11945            .await;
11946        assert_eq!(parked.wake_after, None);
11947        actor.rx.close();
11948        // Drain the stop notice the restart queued for the old process's connection,
11949        // so it isn't mistaken for traffic to the replacement.
11950        while actor.rx.try_recv().is_ok() {}
11951        let turns = actor.turns();
11952        tokio::time::advance(Duration::from_secs(60)).await;
11953        let after = actor.turns();
11954        eprintln!(
11955            "restart park turns: before={turns} after={after} delta={}",
11956            after - turns
11957        );
11958        assert_eq!(after, turns, "replacement must park before HELLO");
11959        let hello_tick = Instant::now();
11960        actor.hello(2, true);
11961        let rearmed = actor
11962            .wait_for_select(|checkpoint| {
11963                checkpoint.registered_connection == Some(ConnectionId::new(2))
11964                    && checkpoint.next_probe_at.is_some()
11965            })
11966            .await;
11967        let delay = jittered_health_delay(&actor.spec.module_id, 0, actor.health.cadence);
11968        assert_eq!(rearmed.next_probe_at, Some(hello_tick + delay));
11969        tokio::time::advance(delay).await;
11970        actor.probe().await;
11971    }
11972
11973    #[tokio::test(start_paused = true)]
11974    async fn rescan_adds_and_removes_http_health_without_polling() {
11975        use tokio::{
11976            io::{AsyncReadExt, AsyncWriteExt},
11977            net::TcpListener,
11978        };
11979        let mut actor = Actor::start(ModuleProtocol::None, Duration::from_secs(30)).await;
11980        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
11981        actor.health.http = Some(format!("http://{}/health", listener.local_addr().unwrap()));
11982        // One millisecond separates successive probes without a polling delay.
11983        actor.health.cadence = Duration::from_millis(1);
11984        let (started_tx, mut started_rx) = mpsc::channel(8);
11985        let peer = tokio::spawn(async move {
11986            loop {
11987                let (mut socket, _) = listener.accept().await.unwrap();
11988                let mut request = [0; 1024];
11989                assert!(socket.read(&mut request).await.unwrap() > 0);
11990                started_tx.send(()).await.unwrap();
11991                socket
11992                    .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")
11993                    .await
11994                    .unwrap();
11995            }
11996        });
11997        let tick = Instant::now();
11998        let turns = actor.turns();
11999        actor
12000            .module
12001            .update_configuration(actor.spec.clone(), actor.health.clone(), None)
12002            .await
12003            .unwrap();
12004        let armed = actor
12005            .wait_for_select(|checkpoint| {
12006                checkpoint.turn > turns && checkpoint.next_probe_at.is_some()
12007            })
12008            .await;
12009        assert_eq!(armed.next_probe_at, Some(tick + Duration::from_millis(1)));
12010        assert_eq!(Instant::now(), tick);
12011        tokio::time::advance(Duration::from_millis(1)).await;
12012        let mut started = false;
12013        wait_for("successful HTTP probe", || {
12014            started |= started_rx.try_recv().is_ok();
12015            (started && actor.module.status().unwrap().health.status == SupervisorHealthStatus::Ok)
12016                .then_some(())
12017        })
12018        .await;
12019        assert!(started, "added HTTP check must start at its first deadline");
12020        assert_eq!(
12021            actor.module.status().unwrap().health.status,
12022            SupervisorHealthStatus::Ok
12023        );
12024        assert_eq!(Instant::now(), tick + Duration::from_millis(1));
12025        actor.health.http = None;
12026        let turns = actor.turns();
12027        actor
12028            .module
12029            .update_configuration(actor.spec.clone(), actor.health.clone(), None)
12030            .await
12031            .unwrap();
12032        let parked = actor
12033            .wait_for_select(|checkpoint| {
12034                checkpoint.turn > turns && checkpoint.next_probe_at.is_none()
12035            })
12036            .await;
12037        assert_eq!(parked.wake_after, None);
12038        let turns = actor.turns();
12039        tokio::time::advance(Duration::from_secs(60)).await;
12040        assert_eq!(
12041            actor.turns(),
12042            turns,
12043            "removed HTTP check must park the actor"
12044        );
12045        assert!(
12046            started_rx.try_recv().is_err(),
12047            "removed HTTP check must stay disarmed"
12048        );
12049        assert_eq!(
12050            actor.module.status().unwrap().health,
12051            ModuleHealthStatus::default()
12052        );
12053        peer.abort();
12054    }
12055}
12056
12057#[cfg(test)]
12058mod health_evidence_tests {
12059    use super::{HealthProbeError, HealthProbeEvidence};
12060    use std::collections::HashSet;
12061
12062    /// The evidential asymmetry, asserted rather than described.
12063    ///
12064    /// Exactly ONE observation is proof a module cannot serve, and the one that
12065    /// fires under CPU starvation is not it. Before the split, all fifteen
12066    /// construction sites collapsed into a single String, so a timeout carried the
12067    /// same weight as a dead lane -- which is how a healthy module was restarted
12068    /// three times in one day.
12069    #[test]
12070    fn only_a_dead_lane_is_proof_of_death() {
12071        assert!(HealthProbeError::lane_dead("gone").is_proof_of_death());
12072        // Three non-proof classes, each for a different reason: silence is
12073        // consistent with health, a bad answer proves the module ALIVE, and a
12074        // daemon-side fault never reached the module at all.
12075        assert!(!HealthProbeError::no_answer("timed out").is_proof_of_death());
12076        assert!(!HealthProbeError::bad_answer("garbage").is_proof_of_death());
12077        assert!(!HealthProbeError::misconfigured("no table").is_proof_of_death());
12078    }
12079
12080    /// Labels must be distinct, or the operator-facing distinction is cosmetic.
12081    ///
12082    /// A shared label renders two different observations identically in the line an
12083    /// operator reads after an unexplained restart -- the exact confusion this
12084    /// change removes.
12085    #[test]
12086    fn every_evidence_class_has_a_distinct_label() {
12087        let labels = [
12088            HealthProbeError::lane_dead("").label(),
12089            HealthProbeError::no_answer("").label(),
12090            HealthProbeError::bad_answer("").label(),
12091            HealthProbeError::misconfigured("").label(),
12092        ];
12093        let unique: HashSet<_> = labels.iter().collect();
12094        assert_eq!(unique.len(), labels.len(), "labels collided: {labels:?}");
12095    }
12096
12097    /// The class is additional information, not a replacement.
12098    ///
12099    /// An operator needs both "this was silence" and the specific text saying how
12100    /// long we waited; a classification that swallowed the message would trade one
12101    /// missing distinction for another.
12102    #[test]
12103    fn classification_preserves_the_original_message() {
12104        let err = HealthProbeError::no_answer("module did not answer within 5s");
12105        assert_eq!(err.to_string(), "module did not answer within 5s");
12106        assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
12107    }
12108}
12109
12110#[cfg(test)]
12111mod health_tombstone_tests {
12112    use std::{path::PathBuf, sync::Arc, time::Duration};
12113
12114    use subc_protocol::{
12115        manifest::Concurrency,
12116        session::{HealthStatus, ModuleControlResponse},
12117    };
12118    use tokio::sync::mpsc;
12119
12120    use super::{
12121        probe_module_health, HealthAction, HealthConfig, HealthProbeEvidence, ModuleProtocol,
12122        ModuleSpec, RestartPolicy, Supervisor, SupervisorRuntimeConfig,
12123    };
12124    use crate::{
12125        control::ControlHandler,
12126        forwarding::{ForwardingTable, ModuleControlRpcCompletion, ModuleControlRpcOutcome},
12127        registry::{ConnectionId, Registry},
12128        router::FrameSink,
12129    };
12130
12131    struct ProbeHarness {
12132        spec: ModuleSpec,
12133        runtime: SupervisorRuntimeConfig,
12134        forwarding: Arc<ForwardingTable>,
12135        module_connection: ConnectionId,
12136        module_rx: mpsc::Receiver<crate::router::OutboundFrame>,
12137        handler: ControlHandler,
12138        module: super::SupervisedModule,
12139    }
12140
12141    fn probe_harness() -> ProbeHarness {
12142        let registry = Arc::new(Registry::default());
12143        let forwarding = Arc::new(ForwardingTable::default());
12144        let supervisor_handle = super::SupervisorHandle::new();
12145        let health = HealthConfig {
12146            http: None,
12147            cadence: Duration::from_secs(30),
12148            deadline: Duration::from_secs(5),
12149            failure_threshold: 3,
12150            on_degraded: HealthAction::Report,
12151            on_failing: HealthAction::Report,
12152            critical: false,
12153        };
12154        let supervisor = Supervisor::new_for_test(Arc::clone(&registry), RestartPolicy::default())
12155            .with_forwarding(Arc::clone(&forwarding))
12156            .with_handle(supervisor_handle.clone())
12157            .with_health_config(health);
12158        let spec = ModuleSpec {
12159            module_id: "late-health-module".to_string(),
12160            program: PathBuf::from("disabled-module"),
12161            args: Vec::new(),
12162            env: Vec::new(),
12163            reserved: false,
12164            reserved_prefixes: Vec::new(),
12165            protocol: ModuleProtocol::Subc,
12166            overlap: Default::default(),
12167        };
12168        let module = supervisor
12169            .supervise_configured(spec.clone(), false)
12170            .unwrap();
12171        let runtime = supervisor.runtime_config();
12172        let handler = ControlHandler::with_forwarding(registry, Arc::clone(&forwarding))
12173            .with_supervisor(supervisor_handle);
12174        let module_connection = ConnectionId::new(700);
12175        let (module_tx, module_rx) = mpsc::channel(8);
12176        forwarding
12177            .register_module_connection(
12178                module_connection,
12179                spec.module_id.clone(),
12180                subc_protocol::PROTOCOL_VERSION,
12181                Concurrency::ModuleManaged,
12182                FrameSink::new(module_tx),
12183            )
12184            .unwrap();
12185
12186        ProbeHarness {
12187            spec,
12188            runtime,
12189            forwarding,
12190            module_connection,
12191            module_rx,
12192            handler,
12193            module,
12194        }
12195    }
12196
12197    async fn finish_after(
12198        harness: &mut ProbeHarness,
12199        stall: Duration,
12200    ) -> ModuleControlRpcCompletion {
12201        assert!(stall > harness.runtime.health.deadline);
12202        let deadline = harness.runtime.health.deadline;
12203        let probe = probe_module_health(&harness.spec.module_id, &harness.runtime, None);
12204        let answer = async {
12205            let frame = harness.module_rx.recv().await.expect("health.check frame");
12206            tokio::time::advance(deadline).await;
12207            tokio::task::yield_now().await;
12208            tokio::time::advance(stall - deadline).await;
12209            harness
12210                .forwarding
12211                .complete_module_control_rpc(
12212                    harness.module_connection,
12213                    frame.header.corr,
12214                    Some("health.check"),
12215                    ModuleControlRpcOutcome::Response(ModuleControlResponse::HealthCheck {
12216                        status: HealthStatus::Ok,
12217                        detail: None,
12218                        metrics: None,
12219                    }),
12220                )
12221                .unwrap()
12222        };
12223        let (probe_result, completion) = tokio::join!(probe, answer);
12224        let err = probe_result.expect_err("probe must miss its deadline");
12225        assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
12226        completion
12227    }
12228
12229    async fn time_out_without_answer(harness: &mut ProbeHarness) {
12230        let deadline = harness.runtime.health.deadline;
12231        let probe = probe_module_health(&harness.spec.module_id, &harness.runtime, None);
12232        let exhaust_deadline = async {
12233            let _frame = harness.module_rx.recv().await.expect("health.check frame");
12234            tokio::time::advance(deadline).await;
12235            tokio::task::yield_now().await;
12236        };
12237        let (probe_result, ()) = tokio::join!(probe, exhaust_deadline);
12238        let err = probe_result.expect_err("probe must miss its deadline");
12239        assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
12240    }
12241
12242    async fn run_probe_cycle(harness: &mut ProbeHarness, answer: bool) {
12243        let registry = Arc::clone(&harness.module.inner.registry);
12244        let snapshot = Arc::clone(&harness.module.inner.snapshot);
12245        let process_liveness = super::SupervisorProcessLiveness::default();
12246        let mut child = None;
12247        let cycle = super::run_health_probe_cycle(
12248            &harness.spec,
12249            &harness.runtime,
12250            &registry,
12251            &process_liveness,
12252            &snapshot,
12253            &mut child,
12254        );
12255        let peer = async {
12256            let frame = harness.module_rx.recv().await.expect("health.check frame");
12257            if answer {
12258                harness
12259                    .forwarding
12260                    .complete_module_control_rpc(
12261                        harness.module_connection,
12262                        frame.header.corr,
12263                        Some("health.check"),
12264                        ModuleControlRpcOutcome::Response(ModuleControlResponse::HealthCheck {
12265                            status: HealthStatus::Ok,
12266                            detail: None,
12267                            metrics: Some(serde_json::json!({"ready": true})),
12268                        }),
12269                    )
12270                    .unwrap();
12271            } else {
12272                tokio::time::advance(harness.runtime.health.deadline).await;
12273                tokio::task::yield_now().await;
12274            }
12275        };
12276        tokio::join!(cycle, peer);
12277    }
12278
12279    #[tokio::test(start_paused = true)]
12280    async fn unanswered_probe_is_unknown_until_threshold_and_ok_report_recovers() {
12281        let mut harness = probe_harness();
12282        // Drive the probe cycle directly with an in-memory wire peer. Stop the
12283        // disabled module's monitor so only this test owns lifecycle transitions;
12284        // no OS process is launched, and a restart is observed at scheduling.
12285        let monitor = harness.module.inner.monitor.lock().unwrap().take().unwrap();
12286        monitor.abort();
12287        let _ = monitor.await;
12288        super::update_snapshot(&harness.module.inner.snapshot, None, |state| {
12289            state.enabled = true;
12290            state.state = super::ModuleState::Running;
12291            state.process_alive = true;
12292        })
12293        .unwrap();
12294
12295        run_probe_cycle(&mut harness, true).await;
12296        assert_eq!(
12297            harness.module.status().unwrap().health.status,
12298            super::SupervisorHealthStatus::Ok
12299        );
12300
12301        for failures in 1..harness.runtime.health.failure_threshold {
12302            run_probe_cycle(&mut harness, false).await;
12303            let status = harness.module.status().unwrap();
12304            assert_eq!(status.health.status, super::SupervisorHealthStatus::Unknown);
12305            assert_eq!(status.health.consecutive_failures, failures);
12306            assert!(status.health.last_probe_ms.is_some());
12307            assert!(status.health.detail.unwrap().starts_with("[no-answer]"));
12308            assert!(status.health.metrics.is_none());
12309            assert_eq!(status.state, super::ModuleState::Running);
12310            assert!(status.process_alive);
12311            assert_eq!(status.restart_count, 0);
12312            assert_eq!(status.lifetime_restarts, 0);
12313            assert!(status.health.last_action.is_none());
12314            assert!(harness.runtime.scheduled_respawn.lock().unwrap().is_none());
12315        }
12316
12317        run_probe_cycle(&mut harness, true).await;
12318        let recovered = harness.module.status().unwrap();
12319        assert_eq!(recovered.health.status, super::SupervisorHealthStatus::Ok);
12320        assert_eq!(recovered.health.consecutive_failures, 0);
12321        assert!(recovered.health.detail.is_none());
12322        assert_eq!(
12323            recovered.health.metrics,
12324            Some(serde_json::json!({"ready": true}))
12325        );
12326        assert_eq!(recovered.lifetime_restarts, 0);
12327
12328        for failures in 1..=harness.runtime.health.failure_threshold {
12329            run_probe_cycle(&mut harness, false).await;
12330            let status = harness.module.status().unwrap();
12331            assert_eq!(status.health.consecutive_failures, failures);
12332            if failures < harness.runtime.health.failure_threshold {
12333                assert_eq!(status.health.status, super::SupervisorHealthStatus::Unknown);
12334                assert_eq!(status.state, super::ModuleState::Running);
12335                assert_eq!(status.lifetime_restarts, 0);
12336                assert!(harness.runtime.scheduled_respawn.lock().unwrap().is_none());
12337            } else {
12338                assert_eq!(
12339                    status.health.status,
12340                    super::SupervisorHealthStatus::Unresponsive
12341                );
12342                assert_eq!(status.state, super::ModuleState::Restarting);
12343                assert_eq!(status.restart_count, 1);
12344                assert_eq!(status.lifetime_restarts, 1);
12345                assert_eq!(status.health.last_action.as_deref(), Some("restart"));
12346                assert!(harness.runtime.scheduled_respawn.lock().unwrap().is_some());
12347            }
12348        }
12349    }
12350
12351    #[tokio::test(start_paused = true)]
12352    async fn late_health_answers_record_start_anchored_latency_for_two_stalls() {
12353        let mut harness = probe_harness();
12354
12355        let first = finish_after(&mut harness, Duration::from_secs(8)).await;
12356        let first_latency = match &first {
12357            ModuleControlRpcCompletion::LateHealthAnswer { latency, .. } => *latency,
12358            other => panic!("late answer was not retained: {other:?}"),
12359        };
12360        assert!(harness.handler.observe_module_control_completion(first));
12361
12362        let second = finish_after(&mut harness, Duration::from_secs(11)).await;
12363        let second_latency = match &second {
12364            ModuleControlRpcCompletion::LateHealthAnswer { latency, .. } => *latency,
12365            other => panic!("late answer was not retained: {other:?}"),
12366        };
12367        assert!(harness.handler.observe_module_control_completion(second));
12368
12369        assert_eq!(first_latency, Duration::from_secs(8));
12370        assert_eq!(
12371            second_latency - first_latency,
12372            Duration::from_secs(3),
12373            "latency must grow linearly with the additional stall"
12374        );
12375        let health = harness.module.status().unwrap().health;
12376        assert_eq!(health.late_answer_count, 2);
12377        assert_eq!(health.last_late_answer_latency_ms, Some(11_000));
12378    }
12379
12380    /// A module that answers every probe late must never march to the kill
12381    /// threshold: the late answer proves it is alive, so it must clear the miss
12382    /// streak the timeout recorded. Without the reset, a CPU-starved module
12383    /// that serves every probe seconds past the deadline accumulates
12384    /// `consecutive_failures` to the threshold and is killed — the exact
12385    /// sequence from the 2026-08-14 aft disable, where the daemon logged
12386    /// "proves the module is alive" five times while counting five misses.
12387    #[tokio::test(start_paused = true)]
12388    async fn late_answer_clears_the_consecutive_failure_streak() {
12389        let mut harness = probe_harness();
12390
12391        // Timeout recorded first: the probe path saw no answer in time.
12392        time_out_without_answer(&mut harness).await;
12393        harness
12394            .module
12395            .record_health_probe_failure_for_test("[no-answer] test miss")
12396            .unwrap();
12397        assert_eq!(
12398            harness.module.status().unwrap().health.consecutive_failures,
12399            1,
12400            "precondition: the miss must be on the streak before the late answer"
12401        );
12402
12403        // The stalled reply then lands: proof of life.
12404        let late = finish_after(&mut harness, Duration::from_secs(9)).await;
12405        assert!(matches!(
12406            late,
12407            ModuleControlRpcCompletion::LateHealthAnswer { .. }
12408        ));
12409        assert!(harness.handler.observe_module_control_completion(late));
12410
12411        let health = harness.module.status().unwrap().health;
12412        assert_eq!(
12413            health.consecutive_failures, 0,
12414            "a late answer is an answer: the streak must reset"
12415        );
12416        assert_eq!(health.late_answer_count, 1);
12417    }
12418
12419    #[tokio::test(start_paused = true)]
12420    async fn repeated_serial_probe_cycles_keep_one_tombstone_per_endpoint() {
12421        let mut harness = probe_harness();
12422
12423        for _ in 0..20 {
12424            time_out_without_answer(&mut harness).await;
12425            assert_eq!(
12426                harness.forwarding.health_probe_tombstone_count().unwrap(),
12427                1
12428            );
12429        }
12430    }
12431}
12432
12433#[cfg(test)]
12434mod child_env_tests {
12435    #[cfg(windows)]
12436    use super::{
12437        clear_current_process_facts, observed_launch_nonce_source, ModuleState, SupervisorSnapshot,
12438    };
12439    #[cfg(windows)]
12440    use std::sync::{Arc, OnceLock};
12441    #[cfg(windows)]
12442    #[test]
12443    fn windows_wire_spawn_prepares_pipe_and_keeps_phase_one_env() {
12444        let spec = spec(Vec::new());
12445        let mut command = Command::new("unused");
12446        let handoff = apply_wire_spawn_args(&mut command, &spec, None, None)
12447            .unwrap()
12448            .unwrap();
12449        let name = command
12450            .as_std()
12451            .get_envs()
12452            .find(|(key, _)| *key == OsStr::new(subc_os::launch_nonce::LAUNCH_NONCE_PIPE_ENV))
12453            .and_then(|(_, value)| value)
12454            .unwrap();
12455        assert_eq!(name, OsStr::new(handoff.name()));
12456        assert!(handoff.name().starts_with(r"\\.\pipe\subc-launch-"));
12457        assert!(command.as_std().get_envs().any(|(key, value)| key
12458            == OsStr::new(subc_os::LAUNCH_NONCE_PIPE_FALLBACK_ENV)
12459            && value == Some(OsStr::new("env"))));
12460        assert!(command
12461            .as_std()
12462            .get_envs()
12463            .any(|(key, value)| key == OsStr::new(SUBC_LAUNCH_NONCE_ENV) && value.is_some()));
12464    }
12465
12466    #[cfg(windows)]
12467    #[test]
12468    fn windows_source_reporting_tracks_consumption_and_resets_per_spawn() {
12469        let mut snapshot = SupervisorSnapshot::new(ModuleState::Running, true);
12470        snapshot.process_alive = true;
12471        snapshot.spawned_protocol = Some(ModuleProtocol::Subc);
12472        assert_eq!(
12473            observed_launch_nonce_source(&snapshot).as_deref(),
12474            Some("env")
12475        );
12476        let consumed = Arc::new(OnceLock::new());
12477        snapshot.pipe_consumed = Some(consumed.clone());
12478        assert_eq!(
12479            observed_launch_nonce_source(&snapshot).as_deref(),
12480            Some("env")
12481        );
12482        consumed.set(()).unwrap();
12483        assert_eq!(
12484            observed_launch_nonce_source(&snapshot).as_deref(),
12485            Some("pipe")
12486        );
12487        snapshot.pipe_consumed = Some(Arc::new(OnceLock::new()));
12488        assert_eq!(
12489            observed_launch_nonce_source(&snapshot).as_deref(),
12490            Some("env")
12491        );
12492        clear_current_process_facts(&mut snapshot);
12493        assert_eq!(observed_launch_nonce_source(&snapshot), None);
12494    }
12495    use super::{
12496        apply_child_env, apply_spawn_role, apply_wire_spawn_args, ModuleProtocol, ModuleSpec,
12497        SpawnRole, SupervisorHandle, SPAWN_ROLE_SWAP_CANDIDATE, SUBC_ARG, SUBC_LAUNCH_NONCE_ENV,
12498        SUBC_MODULE_ID_ENV, SUBC_SPAWN_ROLE_ENV,
12499    };
12500    use std::{ffi::OsStr, path::PathBuf};
12501    use tokio::process::Command;
12502
12503    fn spec(env: Vec<(String, String)>) -> ModuleSpec {
12504        ModuleSpec {
12505            module_id: "env-plan".to_string(),
12506            program: PathBuf::from("/nonexistent"),
12507            args: Vec::new(),
12508            env,
12509            reserved: false,
12510            reserved_prefixes: Vec::new(),
12511            protocol: ModuleProtocol::Subc,
12512            overlap: Default::default(),
12513        }
12514    }
12515
12516    /// Ambient `CK_LOG` is REMOVED for an unconfigured module, and a configured
12517    /// one still gets its own.
12518    ///
12519    /// This is the narrow goal `env_clear()` was reached for, and the reason the
12520    /// fix is `env_remove` rather than deleting the line: an operator's ambient
12521    /// filter silently becoming an unconfigured module's log level is a real
12522    /// defect, just a much smaller one than clearing the environment.
12523    ///
12524    /// Asserted on the command plan rather than a spawned child because proving
12525    /// the ABSENCE of an inherited variable needs the parent's environment
12526    /// mutated, and `forbid(unsafe_code)` refuses that. `get_envs()` reports a
12527    /// removal as `(key, None)`, which is exactly the distinction wanted: not
12528    /// "absent because nobody set it" but "explicitly unset for the child".
12529    #[test]
12530    fn ambient_ck_log_is_removed_and_a_configured_one_survives() {
12531        let mut command = Command::new("/nonexistent");
12532        apply_child_env(&mut command, &spec(Vec::new()));
12533        let removed = command
12534            .as_std()
12535            .get_envs()
12536            .any(|(key, value)| key == OsStr::new("CK_LOG") && value.is_none());
12537        assert!(
12538            removed,
12539            "ambient CK_LOG must be explicitly removed for an unconfigured module"
12540        );
12541
12542        let mut configured = Command::new("/nonexistent");
12543        apply_child_env(
12544            &mut configured,
12545            &spec(vec![("CK_LOG".to_string(), "debug".to_string())]),
12546        );
12547        let effective = configured
12548            .as_std()
12549            .get_envs()
12550            .filter(|(key, _)| *key == OsStr::new("CK_LOG"))
12551            .last()
12552            .map(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()));
12553        assert_eq!(
12554            effective,
12555            Some(Some("debug".to_string())),
12556            "a module's configured CK_LOG must survive the ambient removal"
12557        );
12558    }
12559
12560    /// A `protocol: "none"` spawn carries NO `--subc` argument and NO launch
12561    /// nonce; a subc-wire spawn carries both. Asserted on the command plan for
12562    /// the same reason as the CK_LOG test above.
12563    ///
12564    /// The argument is the load-bearing half: a stock binary exits on an
12565    /// unknown flag before it listens, so with `--subc` appended the mode
12566    /// could not supervise the one process it exists for. Found by the first
12567    /// conformance run (nats-server: `flag provided but not defined: -subc`).
12568    #[test]
12569    fn protocol_none_spawn_carries_no_subc_argument_and_no_nonce() {
12570        let connection_file = std::path::Path::new("/run/subc-connection.json");
12571        let handle = SupervisorHandle::new();
12572
12573        let mut none_spec = spec(Vec::new());
12574        none_spec.protocol = ModuleProtocol::None;
12575        let mut none = Command::new("/nonexistent");
12576        let none_handoff =
12577            apply_wire_spawn_args(&mut none, &none_spec, Some(connection_file), Some(&handle))
12578                .expect("protocol-none spawn args apply");
12579        assert!(
12580            none_handoff.is_none(),
12581            "protocol:none spawn must not receive a nonce descriptor"
12582        );
12583        #[cfg(windows)]
12584        assert!(!none.as_std().get_envs().any(|(key, value)| key
12585            == OsStr::new(subc_os::LAUNCH_NONCE_PIPE_FALLBACK_ENV)
12586            && value.is_some()));
12587        assert!(
12588            !none.as_std().get_envs().any(|(key, value)| key
12589                == OsStr::new(subc_os::LAUNCH_NONCE_FD_ENV)
12590                && value.is_some()),
12591            "protocol:none spawn must not name a nonce descriptor"
12592        );
12593        let none_args: Vec<String> = none
12594            .as_std()
12595            .get_args()
12596            .map(|a| a.to_string_lossy().into_owned())
12597            .collect();
12598        assert!(
12599            !none_args.iter().any(|a| a == SUBC_ARG),
12600            "protocol:none argv must not carry --subc; got {none_args:?}"
12601        );
12602        let none_has_nonce = none
12603            .as_std()
12604            .get_envs()
12605            .any(|(key, value)| key == OsStr::new(SUBC_LAUNCH_NONCE_ENV) && value.is_some());
12606        assert!(
12607            !none_has_nonce,
12608            "protocol:none spawn must not receive a launch nonce"
12609        );
12610        let none_has_module_id = none
12611            .as_std()
12612            .get_envs()
12613            .any(|(key, value)| key == OsStr::new(SUBC_MODULE_ID_ENV) && value.is_some());
12614        assert!(
12615            none_has_module_id,
12616            "SUBC_MODULE_ID is inert and stays on every path"
12617        );
12618        assert!(
12619            handle.spawn_nonce(&none_spec.module_id).is_none(),
12620            "no nonce record for a process that will never present one"
12621        );
12622
12623        // Control: the subc-wire path is unchanged by the branch above.
12624        let wire_spec = spec(Vec::new());
12625        let mut wire = Command::new("/nonexistent");
12626        let wire_handoff =
12627            apply_wire_spawn_args(&mut wire, &wire_spec, Some(connection_file), Some(&handle))
12628                .expect("subc-wire spawn args apply");
12629        let wire_fd_env = wire
12630            .as_std()
12631            .get_envs()
12632            .find(|(key, _)| *key == OsStr::new(subc_os::LAUNCH_NONCE_FD_ENV))
12633            .and_then(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()));
12634        #[cfg(unix)]
12635        assert_eq!(
12636            wire_fd_env,
12637            Some(wire_handoff.expect("a descriptor handoff").fd_env_value()),
12638            "a subc-wire spawn names the pipe it will receive at descriptor 3"
12639        );
12640        #[cfg(windows)]
12641        assert!(wire_handoff.is_some() && wire_fd_env.is_none());
12642        #[cfg(not(any(unix, windows)))]
12643        assert!(wire_handoff.is_none() && wire_fd_env.is_none());
12644        let wire_args: Vec<String> = wire
12645            .as_std()
12646            .get_args()
12647            .map(|a| a.to_string_lossy().into_owned())
12648            .collect();
12649        assert_eq!(
12650            wire_args,
12651            vec![
12652                SUBC_ARG.to_string(),
12653                connection_file.to_string_lossy().into_owned()
12654            ],
12655            "a subc-wire spawn still carries --subc <path>"
12656        );
12657        assert_eq!(
12658            wire.as_std()
12659                .get_envs()
12660                .any(|(key, value)| key == OsStr::new(SUBC_LAUNCH_NONCE_ENV) && value.is_some()),
12661            !cfg!(unix),
12662            "only Windows supplies the environment nonce"
12663        );
12664        assert!(handle.spawn_nonce(&wire_spec.module_id).is_some());
12665    }
12666
12667    /// A plain spawn EXPLICITLY REMOVES the spawn role, even when the module's
12668    /// spec tries to set it; only a swap candidate carries it.
12669    ///
12670    /// "Set it only on candidates" is not enough, because spawn applies the
12671    /// spec's env verbatim and the daemon's own environment is inherited: either
12672    /// could hand a plain restart the swap role, and a module reading it would
12673    /// warm on its long swap budget while callers wait. Asserted as an explicit
12674    /// removal (`(key, None)`), not mere absence, for the reason the `CK_LOG`
12675    /// test above gives.
12676    #[test]
12677    fn plain_spawn_removes_the_spawn_role_even_when_the_spec_sets_it() {
12678        let role = |command: &Command| {
12679            command
12680                .as_std()
12681                .get_envs()
12682                .filter(|(key, _)| *key == OsStr::new(SUBC_SPAWN_ROLE_ENV))
12683                .last()
12684                .map(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()))
12685        };
12686        let forged = spec(vec![(
12687            SUBC_SPAWN_ROLE_ENV.to_string(),
12688            SPAWN_ROLE_SWAP_CANDIDATE.to_string(),
12689        )]);
12690
12691        let mut plain = Command::new("/nonexistent");
12692        apply_child_env(&mut plain, &forged);
12693        apply_spawn_role(&mut plain, SpawnRole::Plain);
12694        assert_eq!(
12695            role(&plain),
12696            Some(None),
12697            "a plain spawn must remove SUBC_SPAWN_ROLE, whatever the spec says"
12698        );
12699
12700        let mut candidate = Command::new("/nonexistent");
12701        apply_child_env(&mut candidate, &spec(Vec::new()));
12702        apply_spawn_role(&mut candidate, SpawnRole::SwapCandidate);
12703        assert_eq!(
12704            role(&candidate),
12705            Some(Some(SPAWN_ROLE_SWAP_CANDIDATE.to_string()))
12706        );
12707    }
12708
12709    /// Daemon-private capture retention keys never reach the child.
12710    ///
12711    /// cortexkit-log exposes retention as a Rust struct with no environment
12712    /// names, so these entries are supervisor metadata. Passing them through
12713    /// would invent a public child-process contract by accident.
12714    #[test]
12715    fn daemon_private_capture_keys_are_not_passed_to_the_child() {
12716        let mut command = Command::new("/nonexistent");
12717        apply_child_env(
12718            &mut command,
12719            &spec(vec![
12720                (super::CAPTURE_KEEP_ENV.to_string(), "5".to_string()),
12721                ("KEPT".to_string(), "yes".to_string()),
12722            ]),
12723        );
12724        let keys: Vec<String> = command
12725            .as_std()
12726            .get_envs()
12727            .filter(|(_, value)| value.is_some())
12728            .map(|(key, _)| key.to_string_lossy().into_owned())
12729            .collect();
12730        assert!(keys.contains(&"KEPT".to_string()), "got {keys:?}");
12731        assert!(
12732            !keys.contains(&super::CAPTURE_KEEP_ENV.to_string()),
12733            "daemon-private capture key leaked to the child: {keys:?}"
12734        );
12735    }
12736}
12737
12738#[cfg(test)]
12739mod jitter_tests {
12740    use super::jittered_health_delay;
12741    use std::{collections::HashSet, time::Duration};
12742
12743    /// Module ids drawn from a real fleet, so the dispersal claim is about names
12744    /// that actually occur rather than invented ones.
12745    ///
12746    /// This is a SAMPLE, not a registry: the property under test is that distinct
12747    /// ids disperse, which holds for any set of distinct strings. Several entries
12748    /// are already historical (modules get renamed), and that costs nothing here --
12749    /// but it means a reader must not mistake this for the live module set, and a
12750    /// rename sweep will match it without there being anything to change.
12751    const FLEET: [&str; 14] = [
12752        "aft",
12753        "alfonso-core",
12754        "magic-context",
12755        "broca",
12756        "thalamus",
12757        "quota",
12758        "engram",
12759        "plexus",
12760        "cerebellum",
12761        "astrocyte",
12762        "synapse",
12763        "subc-mcp",
12764        "cortexkit-credentials",
12765        "subc-federation",
12766    ];
12767
12768    /// Probes must not converge after a fleet-wide restart.
12769    ///
12770    /// This is the property the jitter exists for: every module reconnects at
12771    /// once, and without dispersal all fourteen would then probe on the same
12772    /// tick forever. Nothing failed visibly when this went untested -- a
12773    /// convergent fleet still probes correctly, just in a burst, so the symptom
12774    /// is a periodic load spike that looks like whatever else is running.
12775    #[test]
12776    fn probe_delays_disperse_across_the_fleet() {
12777        let cadence = Duration::from_secs(30);
12778        let delays: HashSet<Duration> = FLEET
12779            .iter()
12780            .map(|id| jittered_health_delay(id, 0, cadence))
12781            .collect();
12782        assert_eq!(
12783            delays.len(),
12784            FLEET.len(),
12785            "every supervised module must land on its own probe offset"
12786        );
12787    }
12788
12789    /// The offset may only ever DELAY a probe, never bring it forward.
12790    ///
12791    /// A delay below the cadence would probe a module more often than
12792    /// configured, which is the opposite of what an operator asked for and
12793    /// would tighten the failure budget without anyone changing it.
12794    #[test]
12795    fn jitter_only_delays_and_stays_within_one_tenth_of_cadence() {
12796        let cadence = Duration::from_secs(30);
12797        let span = cadence / 10;
12798        for id in FLEET {
12799            for probe_index in 0..8 {
12800                let delay = jittered_health_delay(id, probe_index, cadence);
12801                assert!(
12802                    delay >= cadence,
12803                    "{id}#{probe_index}: jitter must not shorten the cadence"
12804                );
12805                assert!(
12806                    delay < cadence + span,
12807                    "{id}#{probe_index}: jitter must stay inside one tenth of the cadence"
12808                );
12809            }
12810        }
12811    }
12812
12813    /// A module keeps its offset across daemon restarts.
12814    ///
12815    /// The delay is derived rather than randomised precisely so a restart does
12816    /// not re-roll every module into a fresh chance of collision. A random
12817    /// source would satisfy the dispersal test above and quietly lose this.
12818    #[test]
12819    fn a_module_offset_is_stable_across_restarts() {
12820        let cadence = Duration::from_secs(30);
12821        for id in FLEET {
12822            assert_eq!(
12823                jittered_health_delay(id, 0, cadence),
12824                jittered_health_delay(id, 0, cadence),
12825                "{id}: the same module and probe index must produce the same offset"
12826            );
12827        }
12828    }
12829
12830    /// A zero cadence disables probing rather than producing a busy loop.
12831    #[test]
12832    fn zero_cadence_yields_zero_delay() {
12833        assert_eq!(
12834            jittered_health_delay("aft", 0, Duration::ZERO),
12835            Duration::ZERO
12836        );
12837    }
12838}
12839
12840#[cfg(all(test, target_os = "linux"))]
12841mod cgroup_placement_tests {
12842    use super::{
12843        apply_cgroup_placement, remove_module_cgroup, ModuleProtocol, ModuleSpec, SuperviseError,
12844        SupervisedChild,
12845    };
12846    use crate::stderr_tail::{StderrRing, StderrTailConfig};
12847    use cortexkit_test_support::ScratchDir;
12848    use std::{
12849        fs, io,
12850        path::{Path, PathBuf},
12851        sync::{Arc, Mutex},
12852    };
12853    use tokio::process::Command;
12854
12855    #[tokio::test]
12856    async fn unique_spawn_cgroups_clean_up_across_restarts_and_shutdown() {
12857        use super::*;
12858        let dir = ScratchDir::new("unique-spawn-cgroups");
12859        let root = PathBuf::from(format!(
12860            "/sys/fs/cgroup/subc-unique-test-{}-{}",
12861            std::process::id(),
12862            unix_ms_now()
12863        ));
12864        if let Err(error) = fs::create_dir(&root) {
12865            assert!(
12866                std::env::var_os("SUBC_REQUIRE_CGROUP_TEST").is_none(),
12867                "required cgroup test cannot execute: {error}"
12868            );
12869            eprintln!("SKIP unique_spawn_cgroups_clean_up_across_restarts_and_shutdown: {error}");
12870            return;
12871        }
12872        let placement = subc_cgroup::prepare_at(&root).unwrap().unwrap();
12873        let group_count = || {
12874            fs::read_dir(root.join("subc-modules"))
12875                .unwrap()
12876                .map(|entry| entry.unwrap().file_type().unwrap())
12877                .filter(|kind| kind.is_dir())
12878                .count()
12879        };
12880        let supervisor =
12881            Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
12882                .with_cgroup_placement(Some(placement.clone()));
12883        let runtime = supervisor.runtime_config();
12884        let mut spec = ModuleSpec {
12885            module_id: "unique-spawn".into(),
12886            program: PathBuf::from("/bin/sleep"),
12887            args: vec!["60".into()],
12888            env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
12889                .into_iter()
12890                .map(|key| (key.into(), dir.to_string_lossy().into_owned()))
12891                .collect(),
12892            reserved: false,
12893            reserved_prefixes: vec![],
12894            protocol: ModuleProtocol::None,
12895            overlap: Default::default(),
12896        };
12897        let spawn = |spec: &ModuleSpec| {
12898            spawn_child(
12899                spec,
12900                None,
12901                None,
12902                &runtime.stderr_ring,
12903                None,
12904                &runtime.child_roster,
12905                Some(&placement),
12906            )
12907            .unwrap()
12908        };
12909        let mut live = spawn(&spec);
12910        for _ in 0..3 {
12911            // A new process can enter the old slot while retirement is pending.
12912            let next = spawn(&spec);
12913            assert_ne!(live.module_id, next.module_id);
12914            live.start_kill().unwrap();
12915            live.wait().await.unwrap();
12916            live = next;
12917            assert!(
12918                live.child.try_wait().unwrap().is_none(),
12919                "retiring the old slot must not kill the replacement"
12920            );
12921            assert_eq!(
12922                group_count(),
12923                1,
12924                "only the live spawn's cgroup should remain"
12925            );
12926        }
12927        supervisor.begin_daemon_shutdown();
12928        let reap = tokio::spawn(async move {
12929            live.wait().await.unwrap();
12930        });
12931        supervisor
12932            .end_children_for_daemon_shutdown(false, std::future::pending())
12933            .await;
12934        reap.await.unwrap();
12935        assert_eq!(group_count(), 0);
12936        // A normal exit uses the same tree-cleanup path as a killed spawn.
12937        spec.program = PathBuf::from("/bin/true");
12938        spec.args.clear();
12939        let fresh_roster = ChildRoster::default();
12940        let mut short = spawn_child(
12941            &spec,
12942            None,
12943            None,
12944            &runtime.stderr_ring,
12945            None,
12946            &fresh_roster,
12947            Some(&placement),
12948        )
12949        .unwrap();
12950        short.wait().await.unwrap();
12951        assert_eq!(group_count(), 0);
12952        spec.module_id = "_".repeat(255);
12953        let mut long_id = spawn_child(
12954            &spec,
12955            None,
12956            None,
12957            &runtime.stderr_ring,
12958            None,
12959            &fresh_roster,
12960            Some(&placement),
12961        )
12962        .unwrap();
12963        long_id.wait().await.unwrap();
12964        assert_eq!(
12965            group_count(),
12966            0,
12967            "valid long module IDs must not exceed cgroup NAME_MAX"
12968        );
12969        fs::remove_dir(root.join("subc-modules")).unwrap();
12970        fs::remove_dir(root).unwrap();
12971        eprintln!("EXECUTED unique_spawn_cgroups_clean_up_across_restarts_and_shutdown");
12972    }
12973
12974    #[test]
12975    fn failed_parent_cgroup_open_is_a_cgroup_supervision_error() {
12976        let path = Path::new("/definitely-missing-subc-cgroup");
12977        let mut command = Command::new("true");
12978        let error = apply_cgroup_placement(
12979            &mut command,
12980            &ModuleSpec {
12981                module_id: "broken-cgroup".to_string(),
12982                program: PathBuf::from("true"),
12983                args: Vec::new(),
12984                env: Vec::new(),
12985                reserved: false,
12986                reserved_prefixes: Vec::new(),
12987                protocol: ModuleProtocol::Subc,
12988                overlap: Default::default(),
12989            },
12990            path,
12991        )
12992        .expect_err("a parent cgroup open failure must reject the supervised spawn");
12993        let reason = error.to_string();
12994
12995        assert!(
12996            matches!(error, SuperviseError::Cgroup { .. }),
12997            "parent cgroup open must be reported as a cgroup supervision error: {reason}"
12998        );
12999        assert!(
13000            reason.contains("/definitely-missing-subc-cgroup/cgroup.procs"),
13001            "parent cgroup open failure must name cgroup.procs: {reason}"
13002        );
13003    }
13004
13005    #[tokio::test]
13006    async fn reaping_a_child_removes_its_empty_module_cgroup() {
13007        let root = ScratchDir::new("supervisor-reap-cgroup");
13008        fs::write(root.join("cgroup.procs"), b"").expect("write scratch cgroup marker");
13009        let placement = subc_cgroup::prepare_at(&root)
13010            .expect("prepare scratch cgroup root")
13011            .expect("scratch root has a cgroup.procs marker");
13012        let module_id = "reaped-module";
13013        let module = placement
13014            .module_path(module_id)
13015            .expect("create scratch module cgroup");
13016        let child = Command::new("true")
13017            .env("XDG_DATA_HOME", root.path())
13018            .env("XDG_RUNTIME_DIR", root.path())
13019            .env("XDG_CONFIG_HOME", root.path())
13020            .spawn()
13021            .expect("spawn short-lived child");
13022        let pid = child.id().expect("spawned child has pid");
13023        let mut child = SupervisedChild {
13024            child,
13025            protocol: ModuleProtocol::Subc,
13026            module_id: module_id.to_string(),
13027            cgroup_placement: Some(placement),
13028            stdout_pump: None,
13029            stderr_pump: None,
13030            stderr_ring: Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default()))),
13031            spawned_at_ms: 0,
13032            spawned_from: PathBuf::from("true"),
13033            spawned_file_identity: None,
13034            process_start_time: None,
13035            process_identity: None,
13036            pid,
13037            roster_guard: None,
13038            #[cfg(target_os = "macos")]
13039            privacy_exec: None,
13040            spawn_failure: None,
13041        };
13042
13043        child.wait().await.expect("reap short-lived child");
13044
13045        assert!(
13046            !module.exists(),
13047            "reaping the supervised child must remove its empty cgroup"
13048        );
13049    }
13050
13051    #[test]
13052    fn non_empty_cgroup_removal_is_reported_without_blocking_teardown() {
13053        let root = ScratchDir::new("supervisor-non-empty-cgroup");
13054        fs::write(root.join("cgroup.procs"), b"").expect("write scratch cgroup marker");
13055        let placement = subc_cgroup::prepare_at(&root)
13056            .expect("prepare scratch cgroup root")
13057            .expect("scratch root has a cgroup.procs marker");
13058        let module = placement
13059            .module_path("surviving-module")
13060            .expect("create scratch module cgroup");
13061        fs::write(module.join("surviving-process"), b"still present")
13062            .expect("make scratch cgroup non-empty");
13063        let (logs, _guard) = crate::router::test_log::log_capture(tracing::Level::WARN);
13064
13065        remove_module_cgroup(&placement, "surviving-module");
13066
13067        let logs = crate::router::test_log::captured_logs(&logs);
13068        assert!(
13069            module.exists(),
13070            "failed removal must leave the cgroup intact"
13071        );
13072        assert!(
13073            logs.contains("could not remove module cgroup after process exit; continuing teardown")
13074                && logs.contains("surviving-module"),
13075            "best-effort removal must report the failure without returning it: {logs}"
13076        );
13077    }
13078
13079    #[test]
13080    fn cgroup_pre_exec_spawn_failure_names_the_cgroup_path() {
13081        let cgroup_path = PathBuf::from("/sys/fs/cgroup/subc-modules/broken-module");
13082        let reason = SuperviseError::Spawn {
13083            program: PathBuf::from("/bin/true"),
13084            source: io::Error::from_raw_os_error(13),
13085            cgroup_path: Some(cgroup_path.clone()),
13086        }
13087        .to_string();
13088
13089        assert!(
13090            reason.contains(&cgroup_path.display().to_string()),
13091            "a pre_exec spawn failure must name the cgroup path: {reason}"
13092        );
13093    }
13094}
13095
13096#[cfg(test)]
13097mod spawn_subscriber_lag_tests {
13098    use super::*;
13099
13100    /// A subscriber whose connection stops draining is dropped once its frame
13101    /// channel fills. The client must learn that from a terminal Error frame
13102    /// after the frames already queued for it, not from a stream that simply
13103    /// goes quiet.
13104    #[tokio::test]
13105    async fn lagged_spawn_subscriber_receives_a_terminal_lagged_error_after_its_queued_frames() {
13106        let feed = SpawnEventFeed::default();
13107        feed.configure_incarnation("lag-incarnation".to_string());
13108        // A one-slot connection queue that nobody reads until the emits are
13109        // done: the forwarder parks on it and the subscriber channel fills.
13110        let (tx, mut rx) = mpsc::channel(1);
13111        feed.subscribe(ConnectionId::new(1), 7, 1, None, FrameSink::new(tx))
13112            .expect("subscribe");
13113        let emitted = SPAWN_SUBSCRIBER_BUFFER + 16;
13114        for index in 0..emitted {
13115            feed.emit_spawned(&format!("lag-module-{index}"), 1000, 0);
13116            // Let the forwarder take what it can so the fill point is the
13117            // subscriber channel, not a scheduling accident.
13118            tokio::task::yield_now().await;
13119        }
13120        assert_eq!(
13121            feed.subscriber_count(),
13122            0,
13123            "the lagged subscriber must be removed"
13124        );
13125
13126        let mut data = Vec::new();
13127        let mut last = None;
13128        loop {
13129            let next = tokio::time::timeout(Duration::from_secs(5), rx.recv())
13130                .await
13131                .expect("the forwarder must finish once the subscriber is dropped");
13132            let Some(outbound) = next else { break };
13133            let frame = outbound.frame;
13134            if frame.header.ty == FrameType::StreamData {
13135                assert!(last.is_none(), "no data may follow the terminal frame");
13136                let event: SpawnEvent = serde_json::from_slice(&frame.body).unwrap();
13137                data.push(event.cursor.seq);
13138            } else {
13139                assert!(last.is_none(), "exactly one terminal frame");
13140                last = Some(frame);
13141            }
13142        }
13143        assert!(!data.is_empty(), "queued frames drain before the terminal");
13144        for pair in data.windows(2) {
13145            assert_eq!(
13146                pair[1],
13147                pair[0] + 1,
13148                "queued frames arrive dense and in order"
13149            );
13150        }
13151        let terminal = last.expect("a lagged subscriber must receive a terminal frame");
13152        assert_eq!(terminal.header.ty, FrameType::Error);
13153        assert_eq!(terminal.header.corr, 7);
13154        let body: subc_protocol::ErrorBody = serde_json::from_slice(&terminal.body).unwrap();
13155        assert_eq!(body.code, SPAWN_SUBSCRIBER_LAGGED_CODE);
13156        let detail = body.detail.expect("lagged error carries detail");
13157        assert_eq!(
13158            detail["first_undelivered_cursor"]["seq"],
13159            data.last().unwrap() + 1,
13160            "the named cursor is the first event the subscriber did not receive"
13161        );
13162        assert_eq!(
13163            detail["first_undelivered_cursor"]["daemon_incarnation"],
13164            "lag-incarnation"
13165        );
13166    }
13167}
13168
13169#[cfg(test)]
13170mod terminal_history_read_concurrency_tests {
13171    use super::*;
13172    use crate::terminal_journal::read_pause;
13173    use cortexkit_test_support::ScratchDir;
13174    use std::sync::mpsc as std_mpsc;
13175
13176    fn journaled_ring(
13177        journal: &Arc<crate::terminal_journal::TerminalJournal>,
13178    ) -> Arc<Mutex<TerminalRing>> {
13179        Arc::new(Mutex::new(
13180            TerminalRing::new(TerminalRingConfig::default(), 1)
13181                .with_journal(Some(Arc::clone(journal))),
13182        ))
13183    }
13184
13185    fn crash(at_ms: u64) -> ExitReport {
13186        ExitReport {
13187            kind: ExitKind::Crash,
13188            code: Some(1),
13189            signal: None,
13190            at_ms,
13191        }
13192    }
13193
13194    /// Record an exit on another thread and report whether it finished within
13195    /// `bound`. The recorder thread is left running if it did not.
13196    fn record_within(
13197        module_id: &'static str,
13198        ring: &Arc<Mutex<TerminalRing>>,
13199        at_ms: u64,
13200        bound: Duration,
13201    ) -> bool {
13202        let ring = Arc::clone(ring);
13203        let (done, done_rx) = std_mpsc::channel();
13204        std::thread::spawn(move || {
13205            record_terminal(
13206                module_id,
13207                &ring,
13208                &SpawnEventFeed::default(),
13209                &crash(at_ms),
13210                TerminalDisposition::Restarting,
13211            );
13212            let _ = done.send(());
13213        });
13214        done_rx.recv_timeout(bound).is_ok()
13215    }
13216
13217    /// A history read in progress must not hold the journal writer (which every
13218    /// module's exit recording needs) or the module's own ring. Exits recorded
13219    /// while the read is paused complete promptly; the paused read answers as of
13220    /// the moment it started, and the next read has each exit exactly once.
13221    #[test]
13222    fn exits_recorded_during_a_paused_history_read_are_not_blocked_or_half_merged() {
13223        let dir = ScratchDir::new("terminal-history-concurrent-read");
13224        let path = dir.join("terminals.jsonl");
13225        let journal = Arc::new(crate::terminal_journal::TerminalJournal::open(
13226            path.clone(),
13227            "daemon".into(),
13228        ));
13229        let reader_ring = journaled_ring(&journal);
13230        let other_ring = journaled_ring(&journal);
13231        assert!(record_within(
13232            "reader-module",
13233            &reader_ring,
13234            10,
13235            Duration::from_secs(5)
13236        ));
13237
13238        let (started, release) = read_pause::install(&path);
13239        let reading = {
13240            let ring = Arc::clone(&reader_ring);
13241            std::thread::spawn(move || durable_terminal_history_of(&ring, "reader-module"))
13242        };
13243        started
13244            .recv_timeout(Duration::from_secs(5))
13245            .expect("the history read reached its pause");
13246
13247        let bound = Duration::from_secs(1);
13248        assert!(
13249            record_within("other-module", &other_ring, 20, bound),
13250            "another module's exit waited on a history read (journal writer held)"
13251        );
13252        assert!(
13253            record_within("reader-module", &reader_ring, 30, bound),
13254            "the read module's own exit waited on its history read (ring held)"
13255        );
13256
13257        drop(release);
13258        let paused = reading.join().unwrap();
13259        assert_eq!(
13260            paused.entries.iter().map(|e| e.at_ms).collect::<Vec<_>>(),
13261            vec![10],
13262            "an exit recorded after the read began lands in neither half of it"
13263        );
13264        assert_eq!(paused.journal_skipped_lines, 0);
13265        assert_eq!(paused.journal_read_errors, 0);
13266
13267        let after = durable_terminal_history_of(&reader_ring, "reader-module");
13268        assert_eq!(
13269            after.entries.iter().map(|e| e.at_ms).collect::<Vec<_>>(),
13270            vec![10, 30],
13271            "the next read merges ring and journal with no duplicate"
13272        );
13273        assert_eq!(after.journal_skipped_lines, 0);
13274    }
13275}
13276
13277/// What a restart does with the exited process's output readers. These drive
13278/// the same settle functions the supervisor calls, with a reader the test
13279/// holds, so a reader that has not been scheduled by the bound is a controlled
13280/// input rather than something only a loaded machine produces.
13281#[cfg(test)]
13282mod stderr_settle_tests {
13283    use std::{
13284        future::Future,
13285        io,
13286        pin::Pin,
13287        sync::{Arc, Mutex},
13288        task::{Context, Poll},
13289        time::Duration,
13290    };
13291
13292    use tokio::{
13293        io::{AsyncRead, ReadBuf},
13294        sync::oneshot,
13295        time::Instant,
13296    };
13297
13298    use super::{settle_stderr_pump, settle_stdout_pump, StderrPump};
13299    use crate::stderr_tail::{
13300        pump_stderr_to, pump_stdout_to, split_capture_stamp, untimed, CaptureState,
13301        ChildOutputSink, OutputSink, StderrRing, StderrTailConfig, TailEntry,
13302    };
13303
13304    const BOUND: Duration = Duration::from_millis(250);
13305
13306    /// Yields `before`, then stays pending until the gate is released, then
13307    /// yields `after` and reaches EOF. The bytes after the gate were written
13308    /// by a process that has already exited; only the reader is behind.
13309    struct HeldReader {
13310        before: Option<Vec<u8>>,
13311        gate: Option<oneshot::Receiver<()>>,
13312        after: io::Cursor<Vec<u8>>,
13313    }
13314
13315    impl AsyncRead for HeldReader {
13316        fn poll_read(
13317            mut self: Pin<&mut Self>,
13318            cx: &mut Context<'_>,
13319            buf: &mut ReadBuf<'_>,
13320        ) -> Poll<io::Result<()>> {
13321            if let Some(bytes) = self.before.take() {
13322                buf.put_slice(&bytes);
13323                return Poll::Ready(Ok(()));
13324            }
13325            if let Some(gate) = self.gate.as_mut() {
13326                match Pin::new(gate).poll(cx) {
13327                    Poll::Pending => return Poll::Pending,
13328                    Poll::Ready(_) => self.gate = None,
13329                }
13330            }
13331            Pin::new(&mut self.after).poll_read(cx, buf)
13332        }
13333    }
13334
13335    struct DiscardSink;
13336
13337    impl OutputSink for DiscardSink {
13338        fn write_line(&mut self, _line: &[u8]) {}
13339    }
13340
13341    fn line(text: &str) -> TailEntry {
13342        TailEntry::Line {
13343            text: text.to_string(),
13344            truncated: false,
13345            at_ms: None,
13346        }
13347    }
13348
13349    fn lock(ring: &Arc<Mutex<StderrRing>>) -> std::sync::MutexGuard<'_, StderrRing> {
13350        ring.lock().unwrap()
13351    }
13352
13353    /// Start a reader for a new process generation that delivers `before`
13354    /// immediately and `after` only once the returned sender fires (or is
13355    /// dropped).
13356    fn held_pump(
13357        ring: &Arc<Mutex<StderrRing>>,
13358        before: &str,
13359        after: &str,
13360    ) -> (StderrPump, oneshot::Sender<()>) {
13361        let generation = lock(ring).begin_process();
13362        let (release, gate) = oneshot::channel();
13363        let reader = HeldReader {
13364            before: Some(before.as_bytes().to_vec()),
13365            gate: Some(gate),
13366            after: io::Cursor::new(after.as_bytes().to_vec()),
13367        };
13368        let task = tokio::spawn(pump_stderr_to(
13369            reader,
13370            Arc::clone(ring),
13371            generation,
13372            DiscardSink,
13373        ));
13374        (StderrPump { task, generation }, release)
13375    }
13376
13377    async fn wait_until(ring: &Arc<Mutex<StderrRing>>, done: impl Fn(&StderrRing) -> bool) {
13378        for _ in 0..1000 {
13379            if done(&lock(ring)) {
13380                return;
13381            }
13382            tokio::time::sleep(Duration::from_millis(1)).await;
13383        }
13384        panic!(
13385            "ring never reached the expected state: {:?}",
13386            lock(ring).snapshot(None, None)
13387        );
13388    }
13389
13390    #[tokio::test(start_paused = true)]
13391    async fn a_crash_line_the_reader_had_not_reached_by_the_bound_is_kept_before_the_restart() {
13392        let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
13393        let (pump, release) = held_pump(&ring, "booting\n", "config error: missing storage\n");
13394
13395        settle_stderr_pump("crasher", &ring, pump, BOUND).await;
13396        let before_release = lock(&ring).snapshot(None, None);
13397        assert!(
13398            matches!(before_release.capture, CaptureState::Incomplete { .. }),
13399            "a reader that has not reached EOF cannot claim a whole tail: {before_release:?}"
13400        );
13401
13402        // The restart: the next process starts and writes before the old
13403        // reader catches up.
13404        let next = lock(&ring).begin_process();
13405        lock(&ring).push_line_from(next, "next process booting");
13406        release.send(()).unwrap();
13407        wait_until(&ring, |ring| {
13408            ring.snapshot(None, None).capture == CaptureState::Captured
13409        })
13410        .await;
13411
13412        assert_eq!(
13413            untimed(lock(&ring).snapshot(None, None).entries),
13414            vec![
13415                line("booting"),
13416                line("config error: missing storage"),
13417                TailEntry::ProcessStart,
13418                line("next process booting"),
13419            ],
13420            "the crash's last line must survive a slow reader and stay in the crashed process's section"
13421        );
13422    }
13423
13424    #[tokio::test(start_paused = true)]
13425    async fn a_pipe_held_open_by_a_descendant_reads_incomplete_without_delaying_the_restart_past_the_bound(
13426    ) {
13427        let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
13428        // `_held` is never fired: a descendant keeps the pipe open for the
13429        // whole test.
13430        let (pump, _held) = held_pump(&ring, "parent exiting\n", "");
13431
13432        let started = Instant::now();
13433        settle_stderr_pump("orphaning", &ring, pump, BOUND).await;
13434        assert_eq!(
13435            started.elapsed(),
13436            BOUND,
13437            "the restart must wait exactly the bound for a pipe that stays open, no longer"
13438        );
13439
13440        let next = lock(&ring).begin_process();
13441        lock(&ring).push_line_from(next, "next process booting");
13442        tokio::time::sleep(Duration::from_secs(60)).await;
13443
13444        let snapshot = lock(&ring).snapshot(None, None);
13445        match &snapshot.capture {
13446            CaptureState::Incomplete { reason } => assert!(
13447                reason.contains("had not reached EOF") && reason.contains("250ms"),
13448                "the reason must say what is missing and after how long: {reason}"
13449            ),
13450            other => panic!("expected Incomplete while the pipe is held open, got {other:?}"),
13451        }
13452        assert_eq!(
13453            untimed(snapshot.entries),
13454            vec![
13455                line("parent exiting"),
13456                TailEntry::ProcessStart,
13457                line("next process booting"),
13458            ]
13459        );
13460    }
13461
13462    #[tokio::test(start_paused = true)]
13463    async fn a_reader_that_reaches_eof_within_the_bound_leaves_the_tail_captured() {
13464        let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
13465        let (pump, release) = held_pump(&ring, "one\n", "two\n");
13466        release.send(()).unwrap();
13467
13468        settle_stderr_pump("clean", &ring, pump, BOUND).await;
13469
13470        let snapshot = lock(&ring).snapshot(None, None);
13471        assert_eq!(snapshot.capture, CaptureState::Captured);
13472        assert_eq!(untimed(snapshot.entries), vec![line("one"), line("two")]);
13473    }
13474
13475    #[tokio::test(start_paused = true)]
13476    async fn stdout_buffered_past_the_exit_drain_bound_reaches_the_capture_file() {
13477        const BURST: usize = 150;
13478        let capture = cortexkit_test_support::ScratchDir::new("stdout-exit-drain");
13479        let path = capture.join("two-pipe.stderr.log");
13480        let sink = ChildOutputSink::open(&path, cortexkit_log::Retention::default()).unwrap();
13481        let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
13482        let generation = lock(&ring).begin_process();
13483        let stdout = (0..BURST)
13484            .map(|i| format!("out-{i:04}-{}\n", "o".repeat(64)))
13485            .collect::<String>();
13486        let stderr = (0..BURST)
13487            .map(|i| format!("err-{i:04}-{}\n", "e".repeat(64)))
13488            .collect::<String>();
13489        // Model a child that has written both bursts and exited. Hold the stdout
13490        // reader after one line and a partial prefix, as if pipe readiness or
13491        // capture-file writes had delayed it. The final line also needs EOF to
13492        // flush: neither complete nor partial buffered output may be discarded.
13493        let first_chunk = stdout.find('\n').unwrap() + 1 + "out-0001-".len();
13494        let (release, gate) = oneshot::channel();
13495        let reader = HeldReader {
13496            before: Some(stdout.as_bytes()[..first_chunk].to_vec()),
13497            gate: Some(gate),
13498            after: io::Cursor::new(stdout.as_bytes()[first_chunk..stdout.len() - 1].to_vec()),
13499        };
13500        let (finished, completion) = oneshot::channel();
13501        let stdout_sink = sink.clone();
13502        let pump = tokio::spawn(async move {
13503            pump_stdout_to(reader, stdout_sink).await;
13504            finished.send(()).unwrap();
13505        });
13506        pump_stderr_to(io::Cursor::new(stderr.into_bytes()), ring, generation, sink).await;
13507
13508        let started = Instant::now();
13509        settle_stdout_pump("two-pipe", pump, BOUND).await;
13510        assert_eq!(
13511            started.elapsed(),
13512            BOUND,
13513            "a late reader must not hold up restart"
13514        );
13515        assert_eq!(
13516            std::fs::read_to_string(&path).unwrap().lines().count(),
13517            BURST + 1
13518        );
13519
13520        release
13521            .send(())
13522            .expect("stdout reader must survive the exit drain bound");
13523        tokio::time::timeout(Duration::from_secs(1), completion)
13524            .await
13525            .expect("released stdout reader must reach EOF")
13526            .expect("stdout capture must finish at EOF, not be cancelled");
13527        let contents = std::fs::read_to_string(&path).unwrap();
13528        let mut lines = contents
13529            .lines()
13530            .map(|line| split_capture_stamp(line).expect("capture stamp").1)
13531            .collect::<Vec<_>>();
13532        let mut expected = (0..BURST)
13533            .flat_map(|i| {
13534                [
13535                    format!("out-{i:04}-{}", "o".repeat(64)),
13536                    format!("err-{i:04}-{}", "e".repeat(64)),
13537                ]
13538            })
13539            .collect::<Vec<_>>();
13540        lines.sort_unstable();
13541        expected.sort_unstable();
13542        assert_eq!(
13543            lines, expected,
13544            "both pipes must retain every complete line exactly once"
13545        );
13546    }
13547}
13548
13549/// Containment of a module's process tree (issue #109).
13550///
13551/// The behaviour these defend against is a module helper surviving its module:
13552/// on a real machine the Synapse embedding module's CUDA worker holds ~2.2 GB of
13553/// VRAM, so a leaked grandchild is a leaked GPU allocation, and a day of restarts
13554/// compounds it.
13555///
13556/// They run against the SUPERVISOR rather than the job-object crate because the
13557/// claim is about teardown: a crate-level test proves a job can reap a tree, not
13558/// that the daemon's drain path reaches it.
13559///
13560/// Windows-only, like the mechanism. On Unix this arm compiles out; the cgroup
13561/// lane there is a separate containment path with its own tests.
13562#[cfg(all(test, windows))]
13563mod job_containment_tests {
13564    use super::*;
13565    use cortexkit_test_support::ScratchDir;
13566    use std::{
13567        path::{Path, PathBuf},
13568        sync::{Arc, Mutex},
13569        time::{Duration, Instant},
13570    };
13571
13572    /// The stub, expected beside this test executable.
13573    ///
13574    /// The existence check is here for the reason its twin at `fake_aft_stub_path`
13575    /// documents: `--lib` does not build `[[bin]]` targets, and a bare spawn
13576    /// failure then reads as a broken test rather than an unbuilt dependency.
13577    fn stub_path() -> PathBuf {
13578        let mut path = std::env::current_exe().expect("current_exe available in tests");
13579        path.pop();
13580        path.pop();
13581        path.push("fake-aft-stub.exe");
13582        assert!(
13583            path.exists(),
13584            "fake-aft-stub not built at {}: run `cargo test -p subc-core` (which builds \
13585             [[bin]] targets) rather than `cargo test -p subc-core --lib` (which does not)",
13586            path.display()
13587        );
13588        path
13589    }
13590
13591    /// Poll for the grandchild pid the stub records, and parse it.
13592    fn read_grandchild_pid(path: &Path) -> u32 {
13593        let deadline = Instant::now() + Duration::from_secs(10);
13594        loop {
13595            if let Ok(contents) = std::fs::read_to_string(path) {
13596                if let Ok(pid) = contents.trim().parse() {
13597                    return pid;
13598                }
13599            }
13600            assert!(
13601                Instant::now() < deadline,
13602                "the stub never recorded a grandchild pid at {}",
13603                path.display()
13604            );
13605            std::thread::sleep(Duration::from_millis(10));
13606        }
13607    }
13608
13609    /// Everything one fixture run needs, so the two tests below differ in exactly
13610    /// one place: whether the child is contained.
13611    struct Fixture {
13612        _dir: ScratchDir,
13613        module_id: String,
13614        grandchild: u32,
13615        child: Option<SupervisedChild>,
13616        registry: Arc<Registry>,
13617        snapshot: Arc<Mutex<SupervisorSnapshot>>,
13618        terminal_ring: Arc<Mutex<TerminalRing>>,
13619        spawn_events: SpawnEventFeed,
13620    }
13621
13622    fn fixture(label: &str, module_id: &str) -> Fixture {
13623        let dir = ScratchDir::new(label);
13624        let pid_file = dir.join("grandchild.pid");
13625        let supervisor = Supervisor::new_for_test(
13626            Arc::new(Registry::default()),
13627            RestartPolicy::new(3, Duration::ZERO),
13628        );
13629        let runtime = supervisor.runtime_config();
13630        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
13631        let spec = ModuleSpec {
13632            module_id: module_id.to_string(),
13633            program: stub_path(),
13634            // Zero args deliberately: a `--subc` argument would make the stub dial
13635            // a daemon that is not there, and the failure would land in the same
13636            // stderr ring this fixture exists to keep quiet.
13637            args: Vec::new(),
13638            env: vec![
13639                ("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string()),
13640                (
13641                    "FAKE_AFT_GRANDCHILD_PID_FILE".to_string(),
13642                    pid_file.display().to_string(),
13643                ),
13644            ],
13645            reserved: false,
13646            reserved_prefixes: Vec::new(),
13647            protocol: ModuleProtocol::Subc,
13648            overlap: Default::default(),
13649        };
13650        let child = spawn_and_mark_running(&spec, &runtime, &snapshot)
13651            .expect("spawn the supervised fixture");
13652        let grandchild = read_grandchild_pid(&pid_file);
13653        Fixture {
13654            _dir: dir,
13655            module_id: module_id.to_string(),
13656            grandchild,
13657            child: Some(child),
13658            registry: Arc::new(Registry::default()),
13659            snapshot,
13660            terminal_ring: Arc::clone(&runtime.terminal_ring),
13661            spawn_events: SpawnEventFeed::default(),
13662        }
13663    }
13664
13665    impl Fixture {
13666        /// Drain through the supervisor's own teardown path.
13667        async fn drain(&mut self) {
13668            let child = self
13669                .child
13670                .take()
13671                .expect("the fixture child is still present");
13672            drain_child_to_state(
13673                &self.module_id,
13674                ModuleProtocol::Subc,
13675                // No forwarding table in this fixture, so nothing reaches the
13676                // child over a connection.
13677                StopNotice::NotSent,
13678                &self.registry,
13679                None,
13680                &self.snapshot,
13681                &self.terminal_ring,
13682                &self.spawn_events,
13683                child,
13684                Duration::from_millis(500),
13685                ModuleState::Stopped,
13686                Some(false),
13687            )
13688            .await
13689            .expect("drain the supervised fixture");
13690        }
13691    }
13692
13693    /// Teardown reaps the grandchild, not merely the direct child.
13694    ///
13695    /// This is the assertion the change exists for. Before containment the
13696    /// grandchild survived: it is a separate process, and `start_kill` is
13697    /// `TerminateProcess` scoped to one pid.
13698    #[tokio::test]
13699    async fn teardown_reaps_the_grandchild() {
13700        let mut fixture = fixture("teardown-grandchild", "tree-teardown");
13701        let grandchild = fixture.grandchild;
13702
13703        assert!(
13704            subc_jobobject::process_exists(grandchild),
13705            "grandchild {grandchild} must be alive before teardown, or this proves nothing"
13706        );
13707
13708        fixture.drain().await;
13709
13710        assert!(
13711            subc_jobobject::wait_for_process_exit(grandchild, Duration::from_secs(10)),
13712            "grandchild {grandchild} outlived module teardown: the tree was not contained"
13713        );
13714    }
13715
13716    /// The mutation control: with containment withheld, the grandchild survives
13717    /// the same kill.
13718    ///
13719    /// This is the defect reproduction from #109 — a direct-child kill reaches
13720    /// one pid, and the grandchild is a different process. It spawns OUTSIDE the
13721    /// supervisor because `spawn_and_mark_running` now always contains on
13722    /// Windows, which is the point: there is no longer a path that spawns
13723    /// uncontained, so the control has to construct one.
13724    ///
13725    /// Its job is to keep `teardown_reaps_the_grandchild` honest. If the
13726    /// grandchild ever dies here, that test is passing for a reason unrelated to
13727    /// the job object and the containment claim is unproven.
13728    #[test]
13729    fn an_uncontained_grandchild_survives_a_direct_child_kill() {
13730        let dir = ScratchDir::new("teardown-uncontained");
13731        let pid_file = dir.join("grandchild.pid");
13732        let mut child = std::process::Command::new(stub_path())
13733            .env("FAKE_AFT_NEVER_CONNECT", "1")
13734            .env(
13735                "FAKE_AFT_GRANDCHILD_PID_FILE",
13736                pid_file.display().to_string(),
13737            )
13738            .stdin(std::process::Stdio::null())
13739            .stdout(std::process::Stdio::null())
13740            .stderr(std::process::Stdio::null())
13741            .spawn()
13742            .expect("spawn the uncontained fixture");
13743        let grandchild = read_grandchild_pid(&pid_file);
13744
13745        // Exactly what the pre-fix teardown did: kill the direct child.
13746        child.kill().expect("kill the direct child");
13747        let _ = child.wait();
13748
13749        assert!(
13750            subc_jobobject::process_exists(grandchild),
13751            "grandchild {grandchild} died with the direct child, so this control no longer \
13752             distinguishes contained from uncontained teardown and the regression test is \
13753             passing vacuously"
13754        );
13755
13756        // The orphan this control demonstrates is the leak the fix prevents, so
13757        // the control must not leave one behind.
13758        kill_tree(grandchild);
13759    }
13760
13761    /// Crash durability: closing the containment handle reaps the tree with no
13762    /// teardown code running at all.
13763    ///
13764    /// This is the case `taskkill /T` cannot cover — a daemon that dies cannot
13765    /// call anything — and it is why containment is a kernel property of the
13766    /// handle rather than a step in the drain. Discovered by getting the
13767    /// mutation control wrong: clearing `job` to "disable" containment instead
13768    /// killed the tree, which is the guarantee, not a mistake.
13769    #[tokio::test]
13770    async fn dropping_containment_reaps_the_grandchild() {
13771        let mut fixture = fixture("drop-containment", "tree-drop");
13772        let grandchild = fixture.grandchild;
13773
13774        assert!(subc_jobobject::process_exists(grandchild));
13775
13776        // No `drain` call, no kill: dropping the handle is the entire mechanism.
13777        fixture.child.as_mut().expect("child present").job = None;
13778
13779        assert!(
13780            subc_jobobject::wait_for_process_exit(grandchild, Duration::from_secs(10)),
13781            "grandchild {grandchild} survived the containment handle closing, so a daemon \
13782             crash would leave the tree behind"
13783        );
13784    }
13785
13786    /// Kill a pid and its tree, then confirm it is gone.
13787    fn kill_tree(pid: u32) {
13788        let _ = std::process::Command::new("taskkill.exe")
13789            .args(["/PID", &pid.to_string(), "/T", "/F"])
13790            .stdin(std::process::Stdio::null())
13791            .stdout(std::process::Stdio::null())
13792            .stderr(std::process::Stdio::null())
13793            .status();
13794        assert!(
13795            subc_jobobject::wait_for_process_exit(pid, Duration::from_secs(10)),
13796            "could not clean up grandchild {pid}"
13797        );
13798    }
13799}
13800
13801#[cfg(test)]
13802mod privacy_trampoline_configuration_tests {
13803    #[tokio::test]
13804    #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
13805    async fn macos_spawn_without_a_configured_trampoline_refuses_by_name() {
13806        #[cfg(target_os = "macos")]
13807        {
13808            let supervisor = super::Supervisor::new(
13809                std::sync::Arc::new(crate::Registry::default()),
13810                super::RestartPolicy::default(),
13811            );
13812            let error = supervisor.spawn(spec()).unwrap_err();
13813            assert!(
13814                error
13815                    .to_string()
13816                    .contains("no privacy trampoline configured"),
13817                "{error}"
13818            );
13819        }
13820    }
13821
13822    #[tokio::test]
13823    #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
13824    async fn macos_wrong_trampoline_is_rejected_by_the_startup_probe() {
13825        #[cfg(target_os = "macos")]
13826        {
13827            let supervisor = super::Supervisor::new(
13828                std::sync::Arc::new(crate::Registry::default()),
13829                super::RestartPolicy::default(),
13830            )
13831            .with_privacy_trampoline(std::env::current_exe().unwrap());
13832            let error = supervisor.spawn(spec()).unwrap_err();
13833            assert!(
13834                error
13835                    .to_string()
13836                    .contains("binary does not implement the privacy trampoline protocol"),
13837                "{error}"
13838            );
13839        }
13840    }
13841
13842    #[cfg(target_os = "macos")]
13843    fn spec() -> super::ModuleSpec {
13844        super::ModuleSpec {
13845            module_id: "privacy-configuration".into(),
13846            program: "/bin/sleep".into(),
13847            args: vec!["30".into()],
13848            env: vec![],
13849            reserved: false,
13850            reserved_prefixes: vec![],
13851            protocol: subc_control::ModuleProtocol::None,
13852            overlap: super::ModuleOverlap::Exclusive,
13853        }
13854    }
13855}
13856
13857#[cfg(test)]
13858mod privacy_exec_boundary_tests {
13859    #[cfg(target_os = "macos")]
13860    use super::*;
13861    #[cfg(target_os = "macos")]
13862    use std::{
13863        io::{Read, Write},
13864        net::{TcpListener, TcpStream},
13865    };
13866
13867    /// Unit-test-only pause at the actual early image read, not at a later
13868    /// status read. Production supervisors never inspect this environment key.
13869    #[cfg(target_os = "macos")]
13870    pub(super) fn before_image_sample(spec: &ModuleSpec, pid: u32) {
13871        if let Some((_, path)) = spec
13872            .env
13873            .iter()
13874            .find(|(key, _)| key == "SUBC_TEST_PRIVACY_SAMPLE_BARRIER")
13875        {
13876            let mut barrier = TcpStream::connect(path).unwrap();
13877            barrier
13878                .set_read_timeout(Some(Duration::from_secs(30)))
13879                .unwrap();
13880            barrier.write_all(&pid.to_ne_bytes()).unwrap();
13881            let mut release = [0];
13882            barrier.read_exact(&mut release).unwrap();
13883            assert_eq!(&release, b"X");
13884        }
13885    }
13886
13887    #[cfg(target_os = "macos")]
13888    fn spec(program: &str, args: &[&str]) -> ModuleSpec {
13889        ModuleSpec {
13890            module_id: "privacy-boundary".into(),
13891            program: program.into(),
13892            args: args.iter().map(|arg| (*arg).into()).collect(),
13893            env: vec![],
13894            reserved: false,
13895            reserved_prefixes: vec![],
13896            protocol: ModuleProtocol::None,
13897            overlap: ModuleOverlap::Exclusive,
13898        }
13899    }
13900
13901    #[cfg(target_os = "macos")]
13902    async fn accept(listener: TcpListener) -> TcpStream {
13903        // Socket readiness, not elapsed time, establishes both pause points.
13904        let listener = tokio::net::TcpListener::from_std({
13905            listener.set_nonblocking(true).unwrap();
13906            listener
13907        })
13908        .unwrap();
13909        let (stream, _) = tokio::time::timeout(Duration::from_secs(30), listener.accept())
13910            .await
13911            .unwrap()
13912            .unwrap();
13913        let stream = stream.into_std().unwrap();
13914        stream.set_nonblocking(false).unwrap();
13915        stream
13916            .set_read_timeout(Some(Duration::from_secs(30)))
13917            .unwrap();
13918        stream
13919    }
13920
13921    #[tokio::test]
13922    #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
13923    async fn macos_roster_withholds_a_nonnull_trampoline_image_until_exec_confirmation() {
13924        #[cfg(target_os = "macos")]
13925        {
13926            let root = cortexkit_test_support::ScratchDir::new("privacy-roster-barrier");
13927            // Loopback sockets also work when the replay adapter's TMPDIR is
13928            // longer than Darwin's Unix-domain socket path limit.
13929            let exec_listener = TcpListener::bind("127.0.0.1:0").unwrap();
13930            let sample_listener = TcpListener::bind("127.0.0.1:0").unwrap();
13931            let record = root.join("live-children.json");
13932            let supervisor =
13933                Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
13934                    .with_live_children_record(&record);
13935            let runtime = supervisor.runtime_config();
13936            let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
13937            let mut spec = spec("/bin/sleep", &["30"]);
13938            spec.env = vec![
13939                (
13940                    "SUBC_TEST_PRIVACY_EXEC_BARRIER".into(),
13941                    exec_listener.local_addr().unwrap().to_string(),
13942                ),
13943                (
13944                    "SUBC_TEST_PRIVACY_SAMPLE_BARRIER".into(),
13945                    sample_listener.local_addr().unwrap().to_string(),
13946                ),
13947            ];
13948            let spawn = tokio::task::spawn_blocking(move || {
13949                spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap()
13950            });
13951            let mut sample = accept(sample_listener).await;
13952            let mut pid = [0; 4];
13953            sample.read_exact(&mut pid).unwrap();
13954            let pid = u32::from_ne_bytes(pid);
13955            let mut exec = accept(exec_listener).await;
13956            let mut ready = [0];
13957            exec.read_exact(&mut ready).unwrap();
13958            assert_eq!(&ready, b"R");
13959            // The early read is guaranteed to see a real, non-null trampoline
13960            // image: the fixture has reached its barrier and cannot exec yet.
13961            let trampoline = subc_os::file_identity(&test_privacy_trampoline()).unwrap();
13962            assert_eq!(
13963                observe_spawned_image(pid).unwrap().executable,
13964                Some(trampoline)
13965            );
13966            sample.write_all(b"X").unwrap();
13967            let mut child = spawn.await.unwrap();
13968            let early = crate::live_children::read_record(&record).unwrap();
13969            assert_eq!(early.len(), 1);
13970            assert_eq!(early[0].pid, pid);
13971            assert_eq!(
13972                early[0].executable, None,
13973                "unconfirmed trampoline image entered the roster"
13974            );
13975            assert!(child.report_ready.get().is_none());
13976            // The barrier's duration is unrelated to the production five-second
13977            // exec budget. Start the test's confirmation budget upon release.
13978            child.privacy_exec.as_mut().unwrap().deadline =
13979                tokio::time::Instant::now() + Duration::from_secs(30);
13980            exec.write_all(b"X").unwrap();
13981            child.confirm_privacy_exec().await;
13982            assert_eq!(child.spawn_failure, None);
13983            assert!(child.report_ready.get().is_some());
13984            let confirmed = crate::live_children::read_record(&record).unwrap();
13985            let module = subc_os::file_identity(std::path::Path::new("/bin/sleep")).unwrap();
13986            assert_ne!(module, trampoline);
13987            assert_eq!(confirmed[0].executable, Some(module.into()));
13988            child.start_kill().unwrap();
13989            child.wait().await.unwrap();
13990            child.release_roster();
13991        }
13992    }
13993
13994    #[tokio::test]
13995    #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
13996    async fn macos_already_exited_121_in_try_wait_is_a_module_exit_not_a_trampoline_refusal() {
13997        #[cfg(target_os = "macos")]
13998        {
13999            let registry = Arc::new(Registry::default());
14000            let policy = RestartPolicy::new(0, Duration::ZERO);
14001            let supervisor = Supervisor::new_for_test(Arc::clone(&registry), policy);
14002            let runtime = supervisor.runtime_config();
14003            let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
14004            let spec = spec("/bin/sh", &["-c", "exit 121"]);
14005            // Drive spawn and confirmation separately instead of starting the
14006            // monitor. WNOWAIT observes a real exit without consuming its status,
14007            // so confirmation's first try_wait must take the already-exited arm.
14008            let mut child = spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap();
14009            let pid = child.pid;
14010            tokio::task::spawn_blocking(move || {
14011                subc_os::fork_exec_test::wait_for_child_exit_without_reaping(pid)
14012            })
14013            .await
14014            .unwrap()
14015            .unwrap();
14016            child.privacy_exec.as_mut().unwrap().deadline =
14017                tokio::time::Instant::now() + Duration::from_secs(30);
14018            let status = child.wait().await.unwrap();
14019            assert_eq!(status.code(), Some(121));
14020            assert!(child.privacy_exec.is_none());
14021            assert!(
14022                child.report_ready.get().is_none(),
14023                "an exited module must not publish a live pid"
14024            );
14025            let report = classify_reaped_child_exit(&snapshot, &child, &status);
14026            on_child_exit(
14027                &spec,
14028                policy,
14029                &registry,
14030                &snapshot,
14031                &runtime.terminal_ring,
14032                &runtime.spawn_events,
14033                &runtime.child_roster,
14034                report,
14035            )
14036            .await;
14037            let state = lock_snapshot(&snapshot).unwrap();
14038            assert_eq!(state.state, ModuleState::Failed);
14039            assert_eq!(state.last_exit.as_ref().unwrap().code, Some(121));
14040            assert_eq!(state.reported_pid(), None);
14041            drop(state);
14042            let history = runtime.terminal_ring.lock().unwrap().snapshot();
14043            assert_eq!(history.entries.len(), 1);
14044            let terminal = &history.entries[0];
14045            assert_eq!(terminal.exit_code, Some(121));
14046            assert_eq!(terminal.exit_kind, subc_control::TerminalExitKind::Crash);
14047            assert_eq!(terminal.disposition, TerminalDisposition::Failed);
14048            assert_eq!(
14049                terminal.disposition_detail.as_deref(),
14050                Some(policy.budget_exhausted_detail().as_str()),
14051                "module exit 121 was classified as a trampoline refusal: {terminal:?}"
14052            );
14053            assert_eq!(child.spawn_failure, None);
14054            child.release_roster();
14055        }
14056    }
14057}
14058
14059/// The daemon's real spawn path hands a subc-wire child its launch nonce on
14060/// descriptor 3, without an environment copy. The shell records the nonce
14061/// and its environment after exec so these tests observe the real handover.
14062#[cfg(all(test, unix))]
14063mod launch_nonce_descriptor_tests {
14064    use super::{ChildRoster, ModuleProtocol, ModuleSpec, SupervisorHandle};
14065    use crate::stderr_tail::{StderrRing, StderrTailConfig};
14066    use cortexkit_test_support::ScratchDir;
14067    use std::{
14068        path::PathBuf,
14069        sync::{Arc, Mutex},
14070        time::{Duration, Instant},
14071    };
14072
14073    async fn probe(role: super::SpawnRole) {
14074        let scratch = ScratchDir::new("launch-nonce-descriptor");
14075        let fd_copy = scratch.join("from-descriptor");
14076        let env_copy = scratch.join("environment");
14077        let script = format!(
14078            "env > '{env}.tmp' && mv '{env}.tmp' '{env}'; cat <&3 > '{fd}.tmp' && mv '{fd}.tmp' '{fd}'; sleep 30",
14079            fd = fd_copy.display(), env = env_copy.display(),
14080        );
14081        let xdg = |name: &str| (name.to_string(), scratch.join(name).display().to_string());
14082        let spec = ModuleSpec {
14083            module_id: "nonce-descriptor-probe".to_string(),
14084            program: PathBuf::from("/bin/sh"),
14085            args: vec!["-c".to_string(), script],
14086            env: vec![
14087                xdg("XDG_DATA_HOME"),
14088                xdg("XDG_RUNTIME_DIR"),
14089                xdg("XDG_CONFIG_HOME"),
14090                ("SUBC_LAUNCH_NONCE".to_string(), "must-not-leak".to_string()),
14091            ],
14092            reserved: true,
14093            reserved_prefixes: Vec::new(),
14094            protocol: ModuleProtocol::Subc,
14095            overlap: Default::default(),
14096        };
14097        let handle = SupervisorHandle::new();
14098        let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
14099        let roster = ChildRoster::default();
14100        #[cfg(target_os = "macos")]
14101        {
14102            let path = super::test_privacy_trampoline();
14103            roster.set_privacy_trampoline(super::probe_privacy_trampoline(&path).map(|()| path));
14104        }
14105        let child = super::spawn_child_in_slot(
14106            &spec,
14107            None,
14108            Some(&handle),
14109            &ring,
14110            None,
14111            &roster,
14112            #[cfg(target_os = "linux")]
14113            None,
14114            role,
14115            matches!(role, super::SpawnRole::SwapCandidate),
14116        )
14117        .expect("spawn probe");
14118        let deadline = Instant::now() + Duration::from_secs(10);
14119        while !(fd_copy.exists() && env_copy.exists()) {
14120            assert!(Instant::now() < deadline, "probe never wrote its copies");
14121            tokio::time::sleep(Duration::from_millis(20)).await;
14122        }
14123        let nonce = std::fs::read_to_string(fd_copy).unwrap();
14124        assert!(!nonce.is_empty());
14125        let environment = std::fs::read_to_string(env_copy).unwrap();
14126        assert!(environment
14127            .lines()
14128            .any(|line| line.starts_with("SUBC_LAUNCH_NONCE_FD=3:")));
14129        let copy = environment
14130            .lines()
14131            .find_map(|line| line.strip_prefix("SUBC_LAUNCH_NONCE="));
14132        assert_eq!(
14133            copy, None,
14134            "Unix children must never receive the environment nonce"
14135        );
14136        if matches!(role, super::SpawnRole::Plain) {
14137            assert_eq!(
14138                handle.spawn_nonce(&spec.module_id).as_deref(),
14139                Some(nonce.as_str())
14140            );
14141        }
14142        drop(child);
14143    }
14144
14145    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
14146    async fn a_spawned_module_receives_its_nonce_only_on_descriptor_3() {
14147        probe(super::SpawnRole::Plain).await;
14148    }
14149
14150    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
14151    async fn swap_candidate_receives_its_nonce_only_on_descriptor_3() {
14152        probe(super::SpawnRole::SwapCandidate).await;
14153    }
14154}
14155
14156#[cfg(all(test, target_os = "linux"))]
14157mod cgroup_containment_tests {
14158    use super::*;
14159    use cortexkit_test_support::ScratchDir;
14160
14161    fn running(pid: u32) -> bool {
14162        // An orphan can remain a zombie until the container init reaps it.
14163        std::fs::read_to_string(format!("/proc/{pid}/stat"))
14164            .ok()
14165            .and_then(|stat| {
14166                stat.rsplit_once(") ")
14167                    .map(|(_, rest)| rest.starts_with('Z'))
14168            })
14169            .is_some_and(|zombie| !zombie)
14170    }
14171
14172    #[tokio::test]
14173    async fn linux_teardown_reaps_the_grandchild() {
14174        teardown_tree("linux_teardown_reaps_the_grandchild", false).await;
14175    }
14176
14177    #[tokio::test]
14178    async fn linux_shutdown_straggler_reaps_the_grandchild() {
14179        teardown_tree("linux_shutdown_straggler_reaps_the_grandchild", true).await;
14180    }
14181
14182    async fn teardown_tree(test_name: &str, shutdown: bool) {
14183        let dir = ScratchDir::new(test_name);
14184        let root = PathBuf::from(format!(
14185            "/sys/fs/cgroup/subc-tree-test-{test_name}-{}-{}",
14186            std::process::id(),
14187            unix_ms_now()
14188        ));
14189        if let Err(error) = std::fs::create_dir(&root) {
14190            assert!(
14191                std::env::var_os("SUBC_REQUIRE_CGROUP_TEST").is_none(),
14192                "required cgroup test cannot execute: {error}"
14193            );
14194            eprintln!(
14195                "SKIP {test_name}: no writable delegated cgroup at {}: {error}",
14196                root.display()
14197            );
14198            return;
14199        }
14200        let placement = subc_cgroup::prepare_at(&root)
14201            .expect("prepare isolated kernel cgroup")
14202            .expect("isolated cgroup is delegated");
14203        let module_id = "tree-teardown";
14204        let module = placement
14205            .module_path(module_id)
14206            .expect("create isolated module cgroup");
14207        if !module.join("cgroup.kill").exists() {
14208            std::fs::remove_dir(&module).unwrap();
14209            std::fs::remove_dir(root.join("subc-modules")).unwrap();
14210            std::fs::remove_dir(&root).unwrap();
14211            assert!(
14212                std::env::var_os("SUBC_REQUIRE_CGROUP_TEST").is_none(),
14213                "required cgroup.kill interface unavailable"
14214            );
14215            eprintln!("SKIP {test_name}: cgroup.kill unavailable (kernel < 5.14)");
14216            return;
14217        }
14218        let supervisor = Supervisor::new_for_test(
14219            Arc::new(Registry::default()),
14220            RestartPolicy::new(3, Duration::ZERO),
14221        )
14222        .with_cgroup_placement(Some(placement));
14223        let mut runtime = supervisor.runtime_config();
14224        runtime.child_roster = runtime
14225            .child_roster
14226            .for_module(Arc::new(Mutex::new(Duration::from_millis(100))));
14227        let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
14228        let pid_file = dir.join("grandchild.pid");
14229        let spec = ModuleSpec {
14230            module_id: module_id.to_string(),
14231            program: PathBuf::from("/bin/sh"),
14232            args: vec![
14233                "-c".into(),
14234                "trap '' TERM; sleep 600 & echo $! > \"$1\"; wait".into(),
14235                "fixture".into(),
14236                pid_file.display().to_string(),
14237            ],
14238            env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
14239                .into_iter()
14240                .map(|key| (key.to_string(), dir.display().to_string()))
14241                .collect(),
14242            reserved: false,
14243            reserved_prefixes: Vec::new(),
14244            protocol: ModuleProtocol::None,
14245            overlap: Default::default(),
14246        };
14247        let child =
14248            spawn_and_mark_running(&spec, &runtime, &snapshot).expect("spawn supervised tree");
14249        let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
14250        let grandchild: u32 = loop {
14251            if let Ok(contents) = std::fs::read_to_string(&pid_file) {
14252                if let Ok(pid) = contents.trim().parse() {
14253                    break pid;
14254                }
14255            }
14256            assert!(
14257                tokio::time::Instant::now() < deadline,
14258                "grandchild pid was not recorded"
14259            );
14260            tokio::time::sleep(Duration::from_millis(10)).await;
14261        };
14262        assert!(
14263            running(grandchild),
14264            "grandchild must be alive before teardown"
14265        );
14266        if shutdown {
14267            let mut child = child;
14268            crate::child_roster::end_children_for_daemon_shutdown(
14269                &runtime.child_roster,
14270                false,
14271                std::future::pending(),
14272            )
14273            .await;
14274            child.wait().await.expect("reap shutdown straggler");
14275        } else {
14276            drain_child_to_state(
14277                module_id,
14278                ModuleProtocol::None,
14279                StopNotice::NotSent,
14280                &Registry::default(),
14281                None,
14282                &snapshot,
14283                &runtime.terminal_ring,
14284                &SpawnEventFeed::default(),
14285                child,
14286                Duration::from_millis(100),
14287                ModuleState::Stopped,
14288                Some(false),
14289            )
14290            .await
14291            .expect("real supervisor teardown");
14292        }
14293        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
14294        while running(grandchild) && tokio::time::Instant::now() < deadline {
14295            tokio::time::sleep(Duration::from_millis(10)).await;
14296        }
14297        let survived = running(grandchild);
14298        // Kill a surviving grandchild so a failed test does not leave it behind.
14299        if survived {
14300            let pid = rustix::process::Pid::from_raw(grandchild as i32).unwrap();
14301            let _ = rustix::process::kill_process(pid, rustix::process::Signal::KILL);
14302            tokio::time::sleep(Duration::from_millis(100)).await;
14303        }
14304        if module.exists() {
14305            std::fs::remove_dir(&module).expect("remove empty module cgroup");
14306        }
14307        std::fs::remove_dir(root.join("subc-modules")).unwrap();
14308        std::fs::remove_dir(&root).unwrap();
14309        assert!(
14310            !survived,
14311            "grandchild {grandchild} outlived module teardown"
14312        );
14313        eprintln!("EXECUTED {test_name}: grandchild {grandchild} killed in isolated cgroup");
14314    }
14315}