1use std::{
2 collections::{HashMap, HashSet, VecDeque},
3 error::Error,
4 fmt, io,
5 path::PathBuf,
6 process::{ExitStatus, Stdio},
7 sync::{Arc, Mutex, OnceLock},
8 time::{Duration, SystemTime, UNIX_EPOCH},
9};
10
11use cortexkit_log::Retention;
12use serde_json::Value;
13use subc_control::{
14 ClientControlPush, LiveSpawn, ModuleProtocol, RouteCloseReason, SpawnCursor, SpawnEvent,
15 SpawnEventKind, SpawnSnapshot, SupervisorHealthStatus, TerminalDisposition, TerminalExitKind,
16};
17use subc_protocol::{
18 manifest::{SelfSignalKind, SignalAnchor},
19 session::{
20 HealthReport, HealthStatus, ModuleControlCommand, ModuleControlRequest,
21 MODULE_CONTROL_OP_HEALTH_CHECK,
22 },
23 Flags, FrameType, Priority, SUBC_LAUNCH_NONCE_ENV, SUBC_MODULE_ID_ENV,
24};
25use tokio::{
26 process::{Child, Command},
27 sync::{mpsc, oneshot, watch, Mutex as AsyncMutex},
28 task::JoinHandle,
29 time::{sleep, sleep_until, timeout, timeout_at, Instant},
30};
31use tracing::{debug, error, info, warn};
32
33use crate::{
34 child_roster::ChildRoster,
35 daemon_config::{
36 CAPTURE_KEEP_ENV, CAPTURE_MAX_AGE_DAYS_ENV, CAPTURE_MAX_FILE_MB_ENV, CK_LOG_ENV,
37 },
38 forwarding::{
39 CloseReason, ForwardingError, ForwardingTable, GoodbyeTarget, ModuleControlRpcOutcome,
40 ModuleDrainTarget, PendingModuleControlRpc,
41 },
42 provenance::{spawned_file_identity, ExecutableIdentityProbe, SpawnedFileIdentity},
43 registry::{ConnectionId, RegistrationEndReason, RegistryError},
44 stderr_tail::{
45 pump_stderr_to, pump_stdout_to, ChildOutputSink, StderrRing, StderrTailConfig,
46 StderrTailSnapshot,
47 },
48 terminal_ring::{TerminalHistorySnapshot, TerminalRecord, TerminalRing, TerminalRingConfig},
49 Frame, FrameSink, Registry,
50};
51
52#[path = "supervise_swap.rs"]
53mod swap;
54
55pub const SUBC_ARG: &str = "--subc";
61
62const DEFAULT_MAX_RESTARTS: u32 = 3;
63const DEFAULT_BACKOFF: Duration = Duration::from_millis(100);
64const DEFAULT_MAX_BACKOFF: Duration = Duration::from_secs(30);
65const DEFAULT_RESTART_WINDOW: Duration = Duration::from_secs(600);
69pub const DEFAULT_DRAIN_TIMEOUT: Duration = Duration::from_secs(30);
80const REGISTRY_RELEASE_TIMEOUT: Duration = Duration::from_secs(1);
81const REGISTRY_RELEASE_POLL: Duration = Duration::from_millis(10);
82const STDERR_PUMP_DRAIN_TIMEOUT: Duration = Duration::from_millis(250);
105pub const SPAWN_EVENT_RING_CAPACITY: usize = 4096;
107const SPAWN_SUBSCRIBER_BUFFER: usize = SPAWN_EVENT_RING_CAPACITY + 1;
108pub(crate) const SPAWN_SUBSCRIBER_LAGGED_CODE: &str = "spawn_subscriber_lagged";
113
114#[cfg(target_os = "macos")]
115struct PrivacyExec {
116 reader: tokio::io::unix::AsyncFd<std::io::PipeReader>,
117 deadline: tokio::time::Instant,
118 expected: Option<subc_os::FileIdentity>,
119 trampoline: Option<subc_os::FileIdentity>,
120 script: bool,
121 module_id: String,
122}
123
124#[cfg(all(test, target_os = "macos"))]
125pub(crate) fn test_privacy_trampoline() -> PathBuf {
126 let path = std::env::current_exe()
129 .unwrap()
130 .parent()
131 .unwrap()
132 .parent()
133 .unwrap()
134 .join("privacy-trampoline-fixture");
135 assert!(
139 path.exists(),
140 "privacy-trampoline-fixture not built at {}: run `cargo build -p subc-daemon \
141 --bins --features test-support` or `cargo test -p subc-daemon` first",
142 path.display()
143 );
144 path
145}
146
147#[cfg(target_os = "macos")]
148fn probe_privacy_trampoline(path: &std::path::Path) -> Result<(), String> {
149 use std::io::Read;
150 let mut probe = std::process::Command::new(path)
151 .args(["__disclaim-exec", "--probe"])
152 .stdin(Stdio::null())
153 .stdout(Stdio::piped())
154 .stderr(Stdio::piped())
155 .spawn()
156 .map_err(|error| {
157 format!(
158 "privacy trampoline probe failed for {}: {error}",
159 path.display()
160 )
161 })?;
162 let deadline = std::time::Instant::now() + Duration::from_secs(5);
163 let status = loop {
164 match probe.try_wait() {
165 Ok(Some(status)) => break status,
166 Ok(None) if std::time::Instant::now() < deadline => {
167 std::thread::sleep(Duration::from_millis(5))
168 }
169 result => {
170 let _ = probe.kill();
171 let _ = probe.wait();
172 return Err(format!(
173 "privacy trampoline probe failed or timed out for {}: {result:?}",
174 path.display()
175 ));
176 }
177 }
178 };
179 let mut answer = String::new();
180 if let Some(stdout) = probe.stdout.take() {
181 let _ = stdout.take(256).read_to_string(&mut answer);
182 }
183 if status.success() && answer.trim() == subc_os::privacy_identity::TRAMPOLINE_PROBE {
184 return Ok(());
185 }
186 let mut diagnostic = String::new();
187 if let Some(stderr) = probe.stderr.take() {
188 let _ = stderr.take(1024).read_to_string(&mut diagnostic);
189 }
190 let cause = diagnostic
191 .trim()
192 .strip_prefix("ck-subc: own privacy identity refused: ")
193 .unwrap_or("binary does not implement the privacy trampoline protocol");
194 Err(format!(
195 "{cause}: probe of {} exited {status}",
196 path.display()
197 ))
198}
199
200#[cfg(target_os = "macos")]
201fn privacy_command(
202 spec: &ModuleSpec,
203 roster: &ChildRoster,
204) -> Result<
205 (
206 Command,
207 Option<PrivacyExec>,
208 subc_os::privacy_identity::ExecAcknowledgement,
209 ),
210 SuperviseError,
211> {
212 let failure = |cause: String| {
213 warn!(module_id = %spec.module_id, %cause, "privacy identity trampoline refused module spawn");
214 SuperviseError::Spawn {
215 program: spec.program.clone(),
216 source: io::Error::other(cause),
217 cgroup_path: None,
218 }
219 };
220 let trampoline = roster.privacy_trampoline().map_err(failure)?;
221 let program = if spec.program.components().count() == 1 && !spec.program.is_absolute() {
226 let path = spec
227 .env
228 .iter()
229 .find(|(key, _)| key == "PATH")
230 .map(|(_, value)| std::ffi::OsString::from(value))
231 .or_else(|| std::env::var_os("PATH"))
232 .unwrap_or_else(|| "/usr/bin:/bin".into());
233 std::env::split_paths(&path)
234 .map(|dir| dir.join(&spec.program))
235 .find(|path| path.is_file())
236 .unwrap_or_else(|| spec.program.clone())
237 } else {
238 spec.program.clone()
239 };
240 let expected = subc_os::file_identity(&program);
241 let trampoline_image = subc_os::file_identity(&trampoline);
242 let script = {
243 use std::io::Read;
244 let mut prefix = [0u8; 2];
245 std::fs::File::open(&program)
246 .is_ok_and(|mut file| file.read_exact(&mut prefix).is_ok() && &prefix == b"#!")
247 };
248 if expected.is_none() || expected == subc_os::file_identity(&trampoline) {
249 return Err(failure(
250 "privacy identity module executable is missing or is the trampoline itself".to_string(),
251 ));
252 }
253 let (reader, ack) = subc_os::privacy_identity::ExecAcknowledgement::pipe()
254 .map_err(|error| failure(error.to_string()))?;
255 let reader =
256 tokio::io::unix::AsyncFd::new(reader).map_err(|error| failure(error.to_string()))?;
257 let mut command = Command::new(&trampoline);
258 command
259 .arg("__disclaim-exec")
260 .arg(ack.fd().to_string())
261 .arg(&program);
262 ack.install(command.as_std_mut());
263 Ok((
264 command,
265 Some(PrivacyExec {
266 reader,
267 deadline: tokio::time::Instant::now() + Duration::from_secs(5),
268 expected,
269 trampoline: trampoline_image,
270 script,
271 module_id: spec.module_id.clone(),
272 }),
273 ack,
274 ))
275}
276
277struct SupervisedChild {
278 child: Child,
279 #[cfg(target_os = "macos")]
280 privacy_exec: Option<PrivacyExec>,
281 #[cfg(target_os = "macos")]
288 report_ready: Arc<OnceLock<()>>,
289 spawn_failure: Option<String>,
291 protocol: ModuleProtocol,
296 #[cfg(target_os = "linux")]
302 module_id: String,
303 #[cfg(target_os = "linux")]
304 cgroup_placement: Option<subc_cgroup::Placement>,
305 #[cfg(windows)]
327 job: Option<subc_jobobject::JobObject>,
328 stdout_pump: Option<JoinHandle<()>>,
329 stderr_pump: Option<StderrPump>,
330 stderr_ring: Arc<Mutex<StderrRing>>,
331 spawned_at_ms: u64,
332 spawned_from: PathBuf,
333 spawned_file_identity: Option<SpawnedFileIdentity>,
334 process_start_time: Option<u64>,
335 process_identity: Option<ProcessIdentity>,
336 pid: u32,
337 roster_guard: Option<crate::child_roster::RosterGuard>,
340}
341
342impl SupervisedChild {
343 fn id(&self) -> Option<u32> {
344 Some(self.pid)
345 }
346
347 fn process_identity(&self) -> Option<ProcessIdentity> {
348 self.process_identity
349 }
350
351 async fn wait(&mut self) -> io::Result<ExitStatus> {
352 #[cfg(target_os = "macos")]
353 self.confirm_privacy_exec().await;
354 let result = self.child.wait().await;
362 #[cfg(target_os = "linux")]
363 if result.is_ok() {
364 if let Some(placement) = self.cgroup_placement.as_ref() {
365 cleanup_reaped_cgroup(placement, &self.module_id).await;
366 self.cgroup_placement = None;
369 }
370 }
371 result
372 }
373
374 #[cfg(target_os = "macos")]
375 async fn confirm_privacy_exec(&mut self) {
376 let Some(pending) = &mut self.privacy_exec else {
377 return;
378 };
379 let result = tokio::time::timeout_at(pending.deadline, async {
380 let mut record = Vec::new();
381 loop {
382 let mut ready = pending.reader.readable().await?;
383 let read = ready.try_io(|reader| {
384 use std::io::Read;
385 let mut reader = reader.get_ref();
386 let mut buffer = [0u8; 256];
387 reader.read(&mut buffer).map(|count| (count, buffer))
388 });
389 match read {
390 Ok(Ok((0, _))) => return Ok::<_, io::Error>(record),
391 Ok(Ok((count, buffer))) => {
392 if record.len() + count > 1024 {
393 return Err(io::Error::other(
394 "privacy exec refusal record is too long",
395 ));
396 }
397 record.extend_from_slice(&buffer[..count]);
398 }
399 Ok(Err(error)) => return Err(error),
400 Err(_) => continue,
401 }
402 }
403 })
404 .await;
405 let pending = self.privacy_exec.as_ref().expect("pending exec");
408 let cause = match result {
409 Err(_) => Some("privacy identity trampoline did not exec within 5s".to_string()),
410 Ok(Err(error)) => Some(format!(
411 "privacy identity exec acknowledgement failed: {error}"
412 )),
413 Ok(Ok(record)) if !record.is_empty() => Some(
414 std::str::from_utf8(&record)
415 .ok()
416 .and_then(|record| {
417 record
418 .trim()
419 .strip_prefix(subc_os::privacy_identity::EXEC_REFUSAL_TAG)
420 })
421 .filter(|cause| !cause.is_empty())
422 .unwrap_or("invalid privacy exec refusal record")
423 .to_string(),
424 ),
425 Ok(Ok(_)) => match self.child.try_wait() {
426 Ok(Some(_status)) => None,
430 Err(error) => Some(format!("privacy identity trampoline wait failed: {error}")),
431 Ok(None) => {
432 let image = observe_spawned_image(self.pid);
433 if let Some(image) = image.filter(|image| {
434 image.executable.is_some()
435 && image.executable != pending.trampoline
436 && (image.executable == pending.expected || pending.script)
437 }) {
438 if let Some(guard) = &self.roster_guard {
439 guard.confirm_executable(image);
440 }
441 let _ = self.report_ready.set(());
442 info!(module_id = %pending.module_id, pid = self.pid,
443 "module spawned with own privacy identity (responsibility disclaimed)");
444 None
445 } else if image.is_none()
446 || image.is_some_and(|image| image.executable.is_none())
447 {
448 match tokio::time::timeout_at(pending.deadline, self.child.wait()).await {
455 Ok(Ok(_status)) => None,
456 Ok(Err(error)) => Some(format!("privacy identity trampoline wait failed: {error}")),
457 Err(_) => Some("privacy identity module executable remained unreadable until the 5s exec deadline".to_string()),
458 }
459 } else {
460 Some("privacy identity trampoline executable mismatch: expected the module program, not ck-subc".to_string())
461 }
462 }
463 },
464 };
465 let pending = self.privacy_exec.take().expect("pending exec");
466 if let Some(cause) = cause {
467 warn!(module_id = %pending.module_id, pid = self.pid, %cause, "privacy identity trampoline refused module spawn");
468 self.spawn_failure = Some(cause);
469 if let Some(pid) = rustix::process::Pid::from_raw(self.pid as i32) {
472 let _ = rustix::process::kill_process_group(pid, rustix::process::Signal::KILL);
473 }
474 let _ = self.child.start_kill();
475 }
476 }
477
478 fn release_roster(&mut self) {
482 self.roster_guard = None;
483 }
484
485 fn start_kill(&mut self) -> io::Result<()> {
498 #[cfg(windows)]
499 if let Some(job) = &self.job {
500 if let Err(error) = job.terminate() {
501 debug!(
502 error = %error,
503 "job termination failed; the direct-child kill still owns the outcome"
504 );
505 }
506 }
507 #[cfg(target_os = "linux")]
508 kill_module_cgroup(self.cgroup_placement.as_ref(), &self.module_id);
509 self.child.start_kill()
510 }
511
512 async fn drain_stderr(&mut self, module_id: &str) {
513 if let Some(pump) = self.stdout_pump.take() {
514 settle_stdout_pump(module_id, pump, STDERR_PUMP_DRAIN_TIMEOUT).await;
515 }
516
517 let Some(pump) = self.stderr_pump.take() else {
518 return;
519 };
520 settle_stderr_pump(
521 module_id,
522 &self.stderr_ring,
523 pump,
524 STDERR_PUMP_DRAIN_TIMEOUT,
525 )
526 .await;
527 }
528}
529
530async fn settle_stdout_pump(module_id: &str, mut pump: JoinHandle<()>, bound: Duration) {
534 match timeout(bound, &mut pump).await {
535 Ok(Ok(())) => {}
536 Ok(Err(error)) => {
537 warn!(module_id, error = %error, "stdout pump ended unexpectedly");
538 }
539 Err(_) => {
540 drop(pump);
541 warn!(
542 module_id,
543 waited = ?bound,
544 "stdout pipe of the exited process has not drained; its reader keeps running without delaying the restart"
545 );
546 }
547 }
548}
549
550struct StderrPump {
553 task: JoinHandle<()>,
554 generation: u64,
555}
556
557async fn settle_stderr_pump(
563 module_id: &str,
564 ring: &Arc<Mutex<StderrRing>>,
565 pump: StderrPump,
566 bound: Duration,
567) {
568 let lock = || ring.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
569 let StderrPump {
570 mut task,
571 generation,
572 } = pump;
573 lock().retire_pump(generation);
574 match timeout(bound, &mut task).await {
575 Ok(Ok(())) => {}
576 Ok(Err(err)) => {
577 let mut ring = lock();
578 ring.mark_incomplete_from(generation, format!("stderr pump ended unexpectedly: {err}"));
579 ring.finish_pump(generation);
580 warn!(module_id, error = %err, "stderr pump ended before clean EOF");
581 }
582 Err(_) => {
583 drop(task);
585 lock().mark_pump_late(
586 generation,
587 format!(
588 "stderr of the exited process had not reached EOF {bound:?} after it was \
589 retired (a descendant may still hold the pipe open); lines it still \
590 writes are kept in that process's section"
591 ),
592 );
593 warn!(
594 module_id,
595 waited = ?bound,
596 "stderr pipe of the exited process is still open; its reader keeps running without delaying the restart"
597 );
598 }
599 }
600}
601
602fn registration_release_events() -> &'static watch::Sender<u64> {
603 static EVENTS: OnceLock<watch::Sender<u64>> = OnceLock::new();
604 EVENTS.get_or_init(|| {
605 let (sender, _receiver) = watch::channel(0);
606 sender
607 })
608}
609
610pub(crate) fn notify_registration_release() {
611 let events = registration_release_events();
612 let next_generation = (*events.borrow()).wrapping_add(1);
613 events.send_replace(next_generation);
614}
615
616#[derive(Debug, Clone, PartialEq, Eq)]
618pub struct ModuleSpec {
619 pub module_id: String,
620 pub program: PathBuf,
621 pub args: Vec<String>,
622 pub env: Vec<(String, String)>,
623 pub reserved: bool,
628 pub reserved_prefixes: Vec<String>,
633 pub protocol: ModuleProtocol,
652 pub overlap: ModuleOverlap,
657}
658
659#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
666pub enum ModuleOverlap {
667 #[default]
669 Exclusive,
670 Safe,
682}
683
684impl ModuleOverlap {
685 pub fn as_str(self) -> &'static str {
686 match self {
687 Self::Exclusive => "exclusive",
688 Self::Safe => "safe",
689 }
690 }
691}
692
693pub const SUBC_SPAWN_ROLE_ENV: &str = "SUBC_SPAWN_ROLE";
703pub const SPAWN_ROLE_SWAP_CANDIDATE: &str = "swap_candidate";
705pub const DEFAULT_SWAP_READY_TIMEOUT: Duration = Duration::from_secs(100);
710
711#[derive(Debug, Clone, Copy, PartialEq, Eq)]
729pub struct RestartPolicy {
730 pub max_restarts: u32,
731 pub backoff: Duration,
734 pub max_backoff: Duration,
736 pub window: Duration,
740}
741
742impl RestartPolicy {
743 pub fn new(max_restarts: u32, backoff: Duration) -> Self {
747 Self {
748 max_restarts,
749 backoff,
750 max_backoff: DEFAULT_MAX_BACKOFF,
751 window: DEFAULT_RESTART_WINDOW,
752 }
753 }
754
755 pub fn with_max_backoff(mut self, max_backoff: Duration) -> Self {
756 self.max_backoff = max_backoff;
757 self
758 }
759
760 pub fn with_window(mut self, window: Duration) -> Self {
761 self.window = window;
762 self
763 }
764
765 fn delay_for_restart(&self, restart_in_window: u32) -> Duration {
770 if self.backoff.is_zero() || self.max_backoff.is_zero() {
771 return Duration::ZERO;
772 }
773
774 let mut delay = self.backoff;
775 for _ in 0..restart_in_window {
776 if delay >= self.max_backoff {
777 return self.max_backoff;
778 }
779 delay = delay
780 .checked_mul(10)
781 .unwrap_or(self.max_backoff)
782 .min(self.max_backoff);
783 }
784 delay.min(self.max_backoff)
785 }
786
787 fn budget_exhausted_detail(&self) -> String {
792 format!(
793 "crash budget exhausted: max_restarts={} within window_secs={}",
794 self.max_restarts,
795 self.window.as_secs()
796 )
797 }
798}
799
800impl Default for RestartPolicy {
801 fn default() -> Self {
802 Self {
803 max_restarts: DEFAULT_MAX_RESTARTS,
804 backoff: DEFAULT_BACKOFF,
805 max_backoff: DEFAULT_MAX_BACKOFF,
806 window: DEFAULT_RESTART_WINDOW,
807 }
808 }
809}
810
811#[derive(Debug, Clone, Copy, PartialEq, Eq)]
812struct CrashRestartSchedule {
813 restart_in_window: u32,
814 delay: Duration,
815}
816
817fn daemon_will_restart(
824 state: &mut SupervisorSnapshot,
825 policy: &RestartPolicy,
826 now: Instant,
827) -> bool {
828 state.enabled && state.crash_restarts_in_window(policy.window, now) < policy.max_restarts
829}
830
831const DEFAULT_HEALTH_CADENCE: Duration = Duration::from_secs(30);
832const DEFAULT_HEALTH_DEADLINE: Duration = Duration::from_secs(5);
833const DEFAULT_HEALTH_FAILURE_THRESHOLD: u32 = 3;
834const MAX_HEALTH_METRICS_BYTES: usize = 16 * 1024;
835
836#[derive(Debug, Clone, Copy, PartialEq, Eq)]
837pub enum HealthAction {
838 Report,
839 Restart,
840 Alert,
841}
842
843impl fmt::Display for HealthAction {
844 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
845 f.write_str(match self {
846 Self::Report => "report",
847 Self::Restart => "restart",
848 Self::Alert => "alert",
849 })
850 }
851}
852
853#[derive(Debug, Clone, PartialEq, Eq)]
854pub struct HealthConfig {
855 pub http: Option<String>,
859 pub cadence: Duration,
860 pub deadline: Duration,
861 pub failure_threshold: u32,
862 pub on_degraded: HealthAction,
863 pub on_failing: HealthAction,
864 pub critical: bool,
865}
866
867impl Default for HealthConfig {
868 fn default() -> Self {
869 Self {
870 http: None,
871 cadence: DEFAULT_HEALTH_CADENCE,
872 deadline: DEFAULT_HEALTH_DEADLINE,
873 failure_threshold: DEFAULT_HEALTH_FAILURE_THRESHOLD,
874 on_degraded: HealthAction::Report,
875 on_failing: HealthAction::Report,
876 critical: false,
877 }
878 }
879}
880
881#[derive(Debug, Clone, PartialEq)]
899pub struct ModuleHealthStatus {
900 pub status: SupervisorHealthStatus,
901 pub last_probe_ms: Option<u64>,
902 pub detail: Option<String>,
903 pub metrics: Option<Value>,
904 pub consecutive_failures: u32,
905 pub late_answer_count: u64,
908 pub last_late_answer_latency_ms: Option<u64>,
910 pub last_action: Option<String>,
911 pub last_action_ms: Option<u64>,
915}
916
917impl Default for ModuleHealthStatus {
918 fn default() -> Self {
919 Self {
920 status: SupervisorHealthStatus::Unknown,
921 last_probe_ms: None,
922 detail: None,
923 metrics: None,
924 consecutive_failures: 0,
925 late_answer_count: 0,
926 last_late_answer_latency_ms: None,
927 last_action: None,
928 last_action_ms: None,
929 }
930 }
931}
932
933#[derive(Debug, Clone, Copy, PartialEq, Eq)]
935pub enum ModuleState {
936 Starting,
937 Running,
938 Unresponsive,
939 Restarting,
940 Draining,
941 Stopped,
942 Failed,
943 Disabled,
944}
945
946impl fmt::Display for ModuleState {
947 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
948 f.write_str(match self {
949 Self::Starting => "starting",
950 Self::Running => "running",
951 Self::Unresponsive => "unresponsive",
952 Self::Restarting => "restarting",
953 Self::Draining => "draining",
954 Self::Stopped => "stopped",
955 Self::Failed => "failed",
956 Self::Disabled => "disabled",
957 })
958 }
959}
960
961#[derive(Debug, Clone, Copy, PartialEq, Eq)]
963pub enum ExitKind {
964 Clean,
965 Crash,
966 DeliberateSeverance,
967}
968
969impl From<ExitKind> for TerminalExitKind {
970 fn from(kind: ExitKind) -> Self {
971 match kind {
972 ExitKind::Clean => Self::Clean,
973 ExitKind::Crash => Self::Crash,
974 ExitKind::DeliberateSeverance => Self::DeliberateSeverance,
975 }
976 }
977}
978
979#[derive(Debug, Clone, Copy, PartialEq, Eq)]
982pub(crate) struct ProcessIdentity {
983 pub(crate) pid: u32,
984 pub(crate) start_time: u64,
985}
986
987#[derive(Debug, Clone, PartialEq, Eq)]
989pub struct ExitReport {
990 pub kind: ExitKind,
991 pub code: Option<i32>,
992 pub signal: Option<i32>,
993 pub at_ms: u64,
994}
995
996#[derive(Debug, Clone, PartialEq)]
999pub struct ModuleStatus {
1000 pub module_id: String,
1001 pub state: ModuleState,
1002 pub enabled: bool,
1003 pub process_alive: bool,
1004 pub registration_active: bool,
1005 pub protocol: ModuleProtocol,
1010 pub live: bool,
1021 pub restart_count: u32,
1025 pub lifetime_restarts: u32,
1029 pub spawn_generation: u64,
1030 pub max_restarts: u32,
1035 pub restart_window: Duration,
1039 pub drain_timeout: Duration,
1043 pub restart_backoff: Duration,
1044 pub restart_max_backoff: Duration,
1045 pub pid: Option<u32>,
1050 pub spawned_at_ms: Option<u64>,
1051 pub spawned_from: Option<PathBuf>,
1052 pub process_start_time: Option<u64>,
1053 pub last_exit: Option<ExitReport>,
1054 pub health: ModuleHealthStatus,
1055}
1056
1057#[cfg(test)]
1058#[derive(Debug, Clone, Copy, PartialEq)]
1059struct ActorSelectCheckpoint {
1060 generation: u64,
1061 turn: u64,
1062 registered_connection: Option<ConnectionId>,
1063 next_probe_at: Option<Instant>,
1064 wake_after: Option<Duration>,
1065}
1066
1067#[derive(Debug, Clone, PartialEq)]
1068struct SupervisorSnapshot {
1069 #[cfg(test)]
1071 actor_turns: u64,
1072 #[cfg(test)]
1075 actor_select: Option<ActorSelectCheckpoint>,
1076 state: ModuleState,
1077 enabled: bool,
1078 process_alive: bool,
1079 spawned_protocol: Option<ModuleProtocol>,
1080 spawn_failure: Option<String>,
1081 crash_restarts: VecDeque<Instant>,
1087 lifetime_restarts: u32,
1088 spawn_generation: u64,
1097 pid: Option<u32>,
1098 #[cfg(target_os = "macos")]
1099 report_ready: Option<Arc<OnceLock<()>>>,
1100 reaped_pid: Option<u32>,
1102 respawn_pending: bool,
1104 coalesced_restart_pending: bool,
1106 spawned_at_ms: Option<u64>,
1107 spawned_from: Option<PathBuf>,
1108 spawned_file_identity: Option<SpawnedFileIdentity>,
1109 process_start_time: Option<u64>,
1110 deliberate_severance: Option<ProcessIdentity>,
1111 last_exit: Option<ExitReport>,
1112 drain_disposition_detail: Option<String>,
1114 health: ModuleHealthStatus,
1115 in_alternate_slot: bool,
1120 draining_to_replace: bool,
1127 configuration_updated_since_spawn: bool,
1133}
1134
1135impl SupervisorSnapshot {
1136 fn reported_pid(&self) -> Option<u32> {
1141 #[cfg(target_os = "macos")]
1142 if self
1143 .report_ready
1144 .as_ref()
1145 .is_some_and(|ready| ready.get().is_none())
1146 {
1147 return None;
1148 }
1149 self.pid
1150 }
1151
1152 fn starting() -> Self {
1153 Self::new(ModuleState::Starting, true)
1154 }
1155
1156 fn disabled() -> Self {
1157 Self::new(ModuleState::Disabled, false)
1158 }
1159
1160 fn failed() -> Self {
1161 Self::new(ModuleState::Failed, true)
1162 }
1163
1164 fn crash_restarts_in_window(&mut self, window: Duration, now: Instant) -> u32 {
1168 while let Some(oldest) = self.crash_restarts.front() {
1169 if now.duration_since(*oldest) > window {
1170 self.crash_restarts.pop_front();
1171 } else {
1172 break;
1173 }
1174 }
1175 u32::try_from(self.crash_restarts.len()).unwrap_or(u32::MAX)
1176 }
1177
1178 fn record_crash_restart(&mut self, policy: &RestartPolicy, now: Instant) {
1184 self.crash_restarts.push_back(now);
1185 while self.crash_restarts.len() > policy.max_restarts as usize {
1186 self.crash_restarts.pop_front();
1187 }
1188 self.lifetime_restarts += 1;
1189 }
1190
1191 fn next_crash_restart(
1195 &mut self,
1196 policy: &RestartPolicy,
1197 now: Instant,
1198 ) -> Option<CrashRestartSchedule> {
1199 let restart_in_window = self.crash_restarts_in_window(policy.window, now);
1200 if restart_in_window >= policy.max_restarts {
1201 return None;
1202 }
1203 self.record_crash_restart(policy, now);
1204 Some(CrashRestartSchedule {
1205 restart_in_window,
1206 delay: policy.delay_for_restart(restart_in_window),
1207 })
1208 }
1209
1210 fn clear_crash_restarts(&mut self) {
1215 self.crash_restarts.clear();
1216 }
1217
1218 fn new(state: ModuleState, enabled: bool) -> Self {
1219 Self {
1220 #[cfg(test)]
1221 actor_turns: 0,
1222 #[cfg(test)]
1223 actor_select: None,
1224 state,
1225 enabled,
1226 process_alive: false,
1227 spawned_protocol: None,
1228 spawn_failure: None,
1229 crash_restarts: VecDeque::new(),
1230 lifetime_restarts: 0,
1231 spawn_generation: 0,
1232 pid: None,
1233 #[cfg(target_os = "macos")]
1234 report_ready: None,
1235 reaped_pid: None,
1236 respawn_pending: false,
1237 coalesced_restart_pending: false,
1238 spawned_at_ms: None,
1239 spawned_from: None,
1240 spawned_file_identity: None,
1241 process_start_time: None,
1242 deliberate_severance: None,
1243 last_exit: None,
1244 drain_disposition_detail: None,
1245 health: ModuleHealthStatus::default(),
1246 in_alternate_slot: false,
1247 draining_to_replace: false,
1248 configuration_updated_since_spawn: false,
1249 }
1250 }
1251}
1252
1253type SharedSnapshot = Arc<Mutex<SupervisorSnapshot>>;
1254
1255type SpawnSubscriberKey = (ConnectionId, u64);
1256
1257#[derive(Debug)]
1258struct SpawnSubscriber {
1259 version: u8,
1260 frames: mpsc::Sender<Frame>,
1261 lagged: Option<oneshot::Sender<SpawnCursor>>,
1265}
1266
1267#[derive(Debug)]
1268struct SpawnEventState {
1269 daemon_incarnation: String,
1270 seq: u64,
1271 capacity: usize,
1272 live: HashMap<String, LiveSpawn>,
1273 generations: HashMap<String, u64>,
1274 events: VecDeque<SpawnEvent>,
1275 subscribers: HashMap<SpawnSubscriberKey, SpawnSubscriber>,
1276}
1277
1278impl Default for SpawnEventState {
1279 fn default() -> Self {
1280 Self {
1281 daemon_incarnation: "unconfigured".to_string(),
1282 seq: 0,
1283 capacity: SPAWN_EVENT_RING_CAPACITY,
1284 live: HashMap::new(),
1285 generations: HashMap::new(),
1286 events: VecDeque::new(),
1287 subscribers: HashMap::new(),
1288 }
1289 }
1290}
1291
1292#[derive(Debug, Clone, Default)]
1293struct SpawnEventFeed(Arc<Mutex<SpawnEventState>>);
1294
1295#[derive(Debug, Clone, PartialEq, Eq)]
1296pub(crate) enum SpawnSubscribeRefusal {
1297 ForeignIncarnation { current: String },
1298 TooOld { oldest: SpawnCursor },
1299 Frame(String),
1300}
1301
1302impl SpawnEventFeed {
1303 fn configure_incarnation(&self, daemon_incarnation: String) {
1304 let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1305 state.daemon_incarnation = daemon_incarnation;
1306 state.seq = 0;
1307 state.live.clear();
1308 state.generations.clear();
1309 state.events.clear();
1310 state.subscribers.clear();
1311 }
1312
1313 fn cursor(state: &SpawnEventState) -> SpawnCursor {
1314 SpawnCursor {
1315 daemon_incarnation: state.daemon_incarnation.clone(),
1316 seq: state.seq,
1317 }
1318 }
1319
1320 fn snapshot(&self) -> SpawnSnapshot {
1321 let state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1322 let mut live = state.live.values().cloned().collect::<Vec<_>>();
1323 live.sort_by(|left, right| left.module_id.cmp(&right.module_id));
1324 SpawnSnapshot {
1325 cursor: Self::cursor(&state),
1326 ring_bound: state.capacity as u64,
1327 live,
1328 }
1329 }
1330
1331 fn emit_spawned(&self, module_id: &str, pid: u32, spawned_at_ms: u64) -> u64 {
1332 let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1333 let generation = state
1334 .generations
1335 .get(module_id)
1336 .copied()
1337 .unwrap_or(0)
1338 .checked_add(1)
1339 .expect("spawn generation exhausted");
1340 state.generations.insert(module_id.to_string(), generation);
1341 let live = LiveSpawn {
1342 module_id: module_id.to_string(),
1343 spawn_generation: generation,
1344 pid,
1345 spawned_at_ms,
1346 };
1347 state.live.insert(module_id.to_string(), live);
1348 Self::emit_locked(
1349 &mut state,
1350 SpawnEventKind::Spawned,
1351 module_id.to_string(),
1352 generation,
1353 pid,
1354 None,
1355 None,
1356 );
1357 generation
1358 }
1359
1360 fn emit_exited(&self, module_id: &str, exit_code: Option<i32>, exit_signal: Option<i32>) {
1361 let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1362 let Some(live) = state.live.remove(module_id) else {
1363 warn!(
1364 module_id,
1365 "terminal record had no live spawn event identity"
1366 );
1367 return;
1368 };
1369 Self::emit_locked(
1370 &mut state,
1371 SpawnEventKind::Exited,
1372 module_id.to_string(),
1373 live.spawn_generation,
1374 live.pid,
1375 exit_code,
1376 exit_signal,
1377 );
1378 }
1379
1380 fn emit_superseded_exited(
1387 &self,
1388 module_id: &str,
1389 spawn_generation: u64,
1390 pid: u32,
1391 exit_code: Option<i32>,
1392 exit_signal: Option<i32>,
1393 ) {
1394 let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1395 if state
1396 .live
1397 .get(module_id)
1398 .is_some_and(|live| live.spawn_generation == spawn_generation)
1399 {
1400 state.live.remove(module_id);
1401 }
1402 Self::emit_locked(
1403 &mut state,
1404 SpawnEventKind::Exited,
1405 module_id.to_string(),
1406 spawn_generation,
1407 pid,
1408 exit_code,
1409 exit_signal,
1410 );
1411 }
1412
1413 #[allow(clippy::too_many_arguments)]
1414 fn emit_locked(
1415 state: &mut SpawnEventState,
1416 kind: SpawnEventKind,
1417 module_id: String,
1418 spawn_generation: u64,
1419 pid: u32,
1420 exit_code: Option<i32>,
1421 exit_signal: Option<i32>,
1422 ) {
1423 state.seq = state
1424 .seq
1425 .checked_add(1)
1426 .expect("spawn event sequence exhausted");
1427 let event = SpawnEvent {
1428 cursor: Self::cursor(state),
1429 kind,
1430 module_id,
1431 spawn_generation,
1432 pid,
1433 exit_code,
1434 exit_signal,
1435 };
1436 state.events.push_back(event.clone());
1437 while state.events.len() > state.capacity {
1438 state.events.pop_front();
1439 }
1440 let body = match serde_json::to_vec(&event) {
1441 Ok(body) => body,
1442 Err(error) => {
1443 error!(%error, "failed to serialize supervisor spawn event");
1444 return;
1445 }
1446 };
1447 state.subscribers.retain(|(connection_id, corr), subscriber| {
1448 let frame = Frame::build_with_version(
1449 subscriber.version,
1450 FrameType::StreamData,
1451 control_flags(),
1452 0,
1453 0,
1454 *corr,
1455 body.clone(),
1456 );
1457 match frame {
1458 Ok(frame) => {
1459 if subscriber.frames.try_send(frame).is_ok() {
1460 true
1461 } else {
1462 warn!(connection_id = connection_id.get(), corr, "dropping lagged supervisor spawn subscriber");
1463 if let Some(lagged) = subscriber.lagged.take() {
1464 let _ = lagged.send(event.cursor.clone());
1465 }
1466 false
1467 }
1468 }
1469 Err(error) => {
1470 warn!(connection_id = connection_id.get(), corr, %error, "dropping supervisor spawn subscriber after frame build failure");
1471 false
1472 }
1473 }
1474 });
1475 }
1476
1477 fn subscribe(
1478 &self,
1479 connection_id: ConnectionId,
1480 corr: u64,
1481 version: u8,
1482 since: Option<SpawnCursor>,
1483 sink: FrameSink,
1484 ) -> Result<(), SpawnSubscribeRefusal> {
1485 let (frames, mut receiver) = mpsc::channel(SPAWN_SUBSCRIBER_BUFFER);
1486 let (lagged, mut lagged_rx) = oneshot::channel::<SpawnCursor>();
1487 {
1488 let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1489 let replay = if let Some(since) = since {
1490 if since.daemon_incarnation != state.daemon_incarnation {
1491 return Err(SpawnSubscribeRefusal::ForeignIncarnation {
1492 current: state.daemon_incarnation.clone(),
1493 });
1494 }
1495 if let Some(oldest) = state.events.front().map(|event| event.cursor.clone()) {
1496 if since.seq < oldest.seq.saturating_sub(1) {
1497 return Err(SpawnSubscribeRefusal::TooOld { oldest });
1498 }
1499 }
1500 state
1501 .events
1502 .iter()
1503 .filter(|event| event.cursor.seq > since.seq)
1504 .cloned()
1505 .collect::<Vec<_>>()
1506 } else {
1507 Vec::new()
1508 };
1509 for event in replay {
1510 let body = serde_json::to_vec(&event)
1511 .map_err(|error| SpawnSubscribeRefusal::Frame(error.to_string()))?;
1512 let frame = Frame::build_with_version(
1513 version,
1514 FrameType::StreamData,
1515 control_flags(),
1516 0,
1517 0,
1518 corr,
1519 body,
1520 )
1521 .map_err(|error| SpawnSubscribeRefusal::Frame(error.to_string()))?;
1522 frames
1523 .try_send(frame)
1524 .map_err(|error| SpawnSubscribeRefusal::Frame(error.to_string()))?;
1525 }
1526 state.subscribers.insert(
1527 (connection_id, corr),
1528 SpawnSubscriber {
1529 version,
1530 frames,
1531 lagged: Some(lagged),
1532 },
1533 );
1534 }
1535 tokio::spawn(async move {
1546 while let Some(frame) = receiver.recv().await {
1547 if sink.send(frame).await.is_err() {
1548 return;
1549 }
1550 }
1551 let Ok(first_undelivered) = lagged_rx.try_recv() else {
1552 return;
1553 };
1554 match spawn_subscriber_lagged_frame(version, corr, first_undelivered) {
1555 Ok(frame) => {
1556 let _ = sink.send(frame).await;
1557 }
1558 Err(error) => {
1559 error!(%error, corr, "failed to build lagged spawn subscriber terminal frame");
1560 }
1561 }
1562 });
1563 Ok(())
1564 }
1565
1566 fn cancel(&self, connection_id: ConnectionId, corr: u64) -> bool {
1567 let Some(subscriber) = self
1568 .0
1569 .lock()
1570 .unwrap_or_else(|p| p.into_inner())
1571 .subscribers
1572 .remove(&(connection_id, corr))
1573 else {
1574 return false;
1575 };
1576 if let Ok(frame) = Frame::build_with_version(
1577 subscriber.version,
1578 FrameType::StreamEnd,
1579 control_flags(),
1580 0,
1581 0,
1582 corr,
1583 Vec::new(),
1584 ) {
1585 tokio::spawn(async move {
1586 let _ = subscriber.frames.send(frame).await;
1587 });
1588 }
1589 true
1590 }
1591
1592 fn remove_connection(&self, connection_id: ConnectionId) {
1593 self.0
1594 .lock()
1595 .unwrap_or_else(|p| p.into_inner())
1596 .subscribers
1597 .retain(|(subscriber_connection, _), _| *subscriber_connection != connection_id);
1598 }
1599
1600 #[cfg(any(test, feature = "test-support"))]
1601 fn set_capacity(&self, capacity: usize) {
1602 self.0.lock().unwrap_or_else(|p| p.into_inner()).capacity = capacity;
1603 }
1604
1605 #[cfg(any(test, feature = "test-support"))]
1606 fn subscriber_count(&self) -> usize {
1607 self.0
1608 .lock()
1609 .unwrap_or_else(|p| p.into_inner())
1610 .subscribers
1611 .len()
1612 }
1613}
1614
1615fn spawn_subscriber_lagged_frame(
1618 version: u8,
1619 corr: u64,
1620 first_undelivered: SpawnCursor,
1621) -> Result<Frame, String> {
1622 let body = serde_json::to_vec(&subc_protocol::ErrorBody {
1623 code: SPAWN_SUBSCRIBER_LAGGED_CODE.to_string(),
1624 message: "spawn subscriber fell behind and was dropped; resubscribe from the last cursor received"
1625 .to_string(),
1626 detail: Some(serde_json::json!({
1627 "first_undelivered_cursor": first_undelivered
1628 })),
1629 })
1630 .map_err(|error| error.to_string())?;
1631 Frame::build_with_version(version, FrameType::Error, control_flags(), 0, 0, corr, body)
1632 .map_err(|error| error.to_string())
1633}
1634
1635pub trait ModuleProcessLiveness: Send + Sync {
1636 fn process_live(&self, module_id: &str) -> Option<bool>;
1637
1638 fn process_replacing(&self, _module_id: &str) -> bool {
1644 false
1645 }
1646}
1647
1648#[derive(Debug, Clone, Default)]
1650pub struct SupervisorProcessLiveness {
1651 snapshots: Arc<Mutex<HashMap<String, SharedSnapshot>>>,
1652}
1653
1654impl SupervisorProcessLiveness {
1655 pub fn new() -> Self {
1656 Self::default()
1657 }
1658
1659 fn track(&self, module_id: String, snapshot: SharedSnapshot) {
1660 let mut snapshots = self
1661 .snapshots
1662 .lock()
1663 .unwrap_or_else(|poisoned| poisoned.into_inner());
1664 snapshots.insert(module_id, snapshot);
1665 }
1666
1667 fn untrack_if_current(&self, module_id: &str, snapshot: &SharedSnapshot) {
1668 let mut snapshots = self
1669 .snapshots
1670 .lock()
1671 .unwrap_or_else(|poisoned| poisoned.into_inner());
1672 let is_current = snapshots
1673 .get(module_id)
1674 .map(|tracked| Arc::ptr_eq(tracked, snapshot))
1675 .unwrap_or(false);
1676 if is_current {
1677 snapshots.remove(module_id);
1678 }
1679 }
1680}
1681
1682impl ModuleProcessLiveness for SupervisorProcessLiveness {
1683 fn process_live(&self, module_id: &str) -> Option<bool> {
1684 let snapshot = {
1685 let snapshots = self
1686 .snapshots
1687 .lock()
1688 .unwrap_or_else(|poisoned| poisoned.into_inner());
1689 snapshots.get(module_id).cloned()
1690 }?;
1691 let snapshot = snapshot
1692 .lock()
1693 .unwrap_or_else(|poisoned| poisoned.into_inner());
1694 Some(snapshot.state == ModuleState::Running && snapshot.process_alive)
1695 }
1696
1697 fn process_replacing(&self, module_id: &str) -> bool {
1698 let Some(snapshot) = self
1699 .snapshots
1700 .lock()
1701 .unwrap_or_else(|poisoned| poisoned.into_inner())
1702 .get(module_id)
1703 .cloned()
1704 else {
1705 return false;
1706 };
1707 let snapshot = snapshot
1708 .lock()
1709 .unwrap_or_else(|poisoned| poisoned.into_inner());
1710 snapshot.enabled
1711 && match snapshot.state {
1712 ModuleState::Restarting => true,
1713 ModuleState::Draining => snapshot.draining_to_replace,
1714 ModuleState::Starting
1715 | ModuleState::Running
1716 | ModuleState::Unresponsive
1717 | ModuleState::Stopped
1718 | ModuleState::Failed
1719 | ModuleState::Disabled => false,
1720 }
1721 }
1722}
1723
1724#[cfg(test)]
1725#[derive(Debug, Default)]
1726struct ReloadExitRecordGate {
1727 reached: tokio::sync::Notify,
1728 resume: tokio::sync::Notify,
1729}
1730
1731#[derive(Debug, Clone, Copy)]
1732enum RespawnKind {
1733 Spawn,
1734 Reload,
1735}
1736
1737#[derive(Debug, Clone, Copy)]
1738struct PendingRespawn {
1739 deadline: Instant,
1740 kind: RespawnKind,
1741}
1742
1743type ReloadReply = oneshot::Sender<Result<(), SuperviseError>>;
1744
1745#[derive(Debug, Clone)]
1746struct SupervisorRuntimeConfig {
1747 scheduled_respawn: Arc<Mutex<Option<PendingRespawn>>>,
1749 deferred_reload_reply: Arc<Mutex<Option<ReloadReply>>>,
1751 restart_policy: RestartPolicy,
1752 drain_timeout: Duration,
1755 effective_drain_timeout: Arc<Mutex<Duration>>,
1758 default_drain_timeout: Duration,
1761 health: HealthConfig,
1762 connection_file_path: Option<PathBuf>,
1763 capture_logs_dir: Option<PathBuf>,
1764 forwarding: Option<Arc<ForwardingTable>>,
1765 supervisor_handle: Option<SupervisorHandle>,
1768 stderr_ring: Arc<Mutex<StderrRing>>,
1775 terminal_ring: Arc<Mutex<TerminalRing>>,
1776 spawn_events: SpawnEventFeed,
1777 child_roster: ChildRoster,
1778 #[cfg(target_os = "linux")]
1779 cgroup_placement: Option<subc_cgroup::Placement>,
1780 #[cfg(test)]
1781 test_seed_stale_facts_before_enable_spawn: bool,
1782 #[cfg(test)]
1783 test_reload_exit_record_gate: Option<Arc<ReloadExitRecordGate>>,
1784}
1785
1786#[derive(Debug, Clone, PartialEq, Eq)]
1787struct SupervisedConfiguration {
1788 spec: ModuleSpec,
1789 health: HealthConfig,
1790}
1791
1792#[derive(Debug, Clone, Default)]
1798pub struct SupervisorHandle {
1799 modules: Arc<Mutex<HashMap<String, SupervisedModule>>>,
1800 configured_ids: Arc<Mutex<HashSet<String>>>,
1812 spawn_events: SpawnEventFeed,
1813 reserved_nonces: Arc<Mutex<HashMap<String, Option<String>>>>,
1824 removal_tombstones: Arc<Mutex<HashMap<String, u64>>>,
1830 spawn_nonces: Arc<Mutex<HashMap<String, String>>>,
1834 reserved_prefix_owners: Arc<Mutex<HashMap<String, String>>>,
1842 swaps: Arc<Mutex<HashMap<String, OpenSwap>>>,
1848 promotion_observer: PromotionObserverSlot,
1850 operation_lock: Arc<AsyncMutex<()>>,
1854}
1855
1856pub(crate) trait SwapPromotionObserver: Send + Sync {
1865 fn swap_promoted(&self, registration: &crate::registry::ModuleRegistration);
1866}
1867
1868#[derive(Clone, Default)]
1872struct PromotionObserverSlot(Arc<Mutex<Option<std::sync::Weak<dyn SwapPromotionObserver>>>>);
1873
1874impl fmt::Debug for PromotionObserverSlot {
1875 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1876 f.write_str("PromotionObserverSlot")
1877 }
1878}
1879
1880#[derive(Debug, Clone)]
1882struct OpenSwap {
1883 candidate_nonce: String,
1886 incumbent_nonce: Option<String>,
1891 candidate_admitted: bool,
1895}
1896
1897#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1900pub(crate) enum SwapHelloAdmission {
1901 NotSwapping,
1904 Candidate,
1906 Refused,
1909}
1910
1911#[derive(Debug, Clone, PartialEq, Eq)]
1912pub(crate) enum ReservedHelloRejection {
1913 Exact {
1914 module_id: String,
1915 },
1916 Prefix {
1917 prefix: String,
1918 owner_module_id: String,
1919 },
1920}
1921
1922impl SupervisorHandle {
1923 pub fn new() -> Self {
1924 Self::default()
1925 }
1926
1927 pub(crate) fn spawn_snapshot(&self) -> SpawnSnapshot {
1928 self.spawn_events.snapshot()
1929 }
1930
1931 pub(crate) fn subscribe_spawns(
1932 &self,
1933 connection_id: ConnectionId,
1934 corr: u64,
1935 version: u8,
1936 since: Option<SpawnCursor>,
1937 sink: FrameSink,
1938 ) -> Result<(), SpawnSubscribeRefusal> {
1939 self.spawn_events
1940 .subscribe(connection_id, corr, version, since, sink)
1941 }
1942
1943 pub(crate) fn cancel_spawn_subscription(&self, connection_id: ConnectionId, corr: u64) -> bool {
1944 self.spawn_events.cancel(connection_id, corr)
1945 }
1946
1947 pub(crate) fn remove_spawn_subscribers(&self, connection_id: ConnectionId) {
1948 self.spawn_events.remove_connection(connection_id);
1949 }
1950
1951 #[cfg(any(test, feature = "test-support"))]
1952 pub fn set_spawn_event_capacity_for_test(&self, capacity: usize) {
1953 assert!(capacity > 0, "spawn event capacity must be non-zero");
1954 self.spawn_events.set_capacity(capacity);
1955 }
1956
1957 #[cfg(any(test, feature = "test-support"))]
1958 pub fn spawn_subscriber_count_for_test(&self) -> usize {
1959 self.spawn_events.subscriber_count()
1960 }
1961
1962 pub fn set_spawn_nonce(&self, module_id: &str, nonce: String) {
1965 self.spawn_nonces
1966 .lock()
1967 .unwrap_or_else(|poisoned| poisoned.into_inner())
1968 .insert(module_id.to_string(), nonce);
1969 }
1970
1971 pub fn set_reserved_nonce(&self, module_id: &str, nonce: String) {
1974 self.reserved_nonces
1975 .lock()
1976 .unwrap_or_else(|poisoned| poisoned.into_inner())
1977 .insert(module_id.to_string(), Some(nonce));
1978 }
1979
1980 pub fn set_reserved_prefixes(&self, owner_module_id: &str, prefixes: &[String]) {
1982 let mut owners = self
1983 .reserved_prefix_owners
1984 .lock()
1985 .unwrap_or_else(|poisoned| poisoned.into_inner());
1986 owners.retain(|_, owner| owner != owner_module_id);
1987 for prefix in prefixes {
1988 owners.insert(prefix.clone(), owner_module_id.to_string());
1989 }
1990 }
1991
1992 #[cfg(test)]
1994 pub(crate) fn spawn_nonce(&self, module_id: &str) -> Option<String> {
1995 self.spawn_nonces
1996 .lock()
1997 .unwrap_or_else(|poisoned| poisoned.into_inner())
1998 .get(module_id)
1999 .cloned()
2000 }
2001
2002 fn apply_identity_configuration(&self, spec: &ModuleSpec) {
2003 self.set_reserved_prefixes(&spec.module_id, &spec.reserved_prefixes);
2004 let spawn_nonce = self
2005 .spawn_nonces
2006 .lock()
2007 .unwrap_or_else(|poisoned| poisoned.into_inner())
2008 .get(&spec.module_id)
2009 .cloned();
2010 let mut reserved_nonces = self
2011 .reserved_nonces
2012 .lock()
2013 .unwrap_or_else(|poisoned| poisoned.into_inner());
2014 if spec.reserved {
2015 reserved_nonces.insert(spec.module_id.clone(), spawn_nonce);
2020 }
2021 drop(reserved_nonces);
2022 self.removal_tombstones
2026 .lock()
2027 .unwrap_or_else(|poisoned| poisoned.into_inner())
2028 .remove(&spec.module_id);
2029 }
2030
2031 pub fn reserved_hello_authorized(&self, module_id: &str, presented: Option<&str>) -> bool {
2036 self.reserved_hello_rejection(module_id, presented)
2037 .is_none()
2038 }
2039
2040 pub(crate) fn reserved_hello_rejection(
2041 &self,
2042 module_id: &str,
2043 presented: Option<&str>,
2044 ) -> Option<ReservedHelloRejection> {
2045 let nonces = self
2046 .reserved_nonces
2047 .lock()
2048 .unwrap_or_else(|poisoned| poisoned.into_inner());
2049 if let Some(expected) = nonces.get(module_id) {
2050 let authorized = match expected {
2054 Some(expected) => {
2055 presented.is_some_and(|p| constant_time_eq(expected.as_bytes(), p.as_bytes()))
2056 }
2057 None => false,
2058 };
2059 if authorized {
2060 return None;
2061 }
2062 return Some(ReservedHelloRejection::Exact {
2063 module_id: module_id.to_string(),
2064 });
2065 }
2066 drop(nonces);
2067
2068 let matched_prefix = self
2069 .reserved_prefix_owners
2070 .lock()
2071 .unwrap_or_else(|poisoned| poisoned.into_inner())
2072 .iter()
2073 .filter(|(prefix, _)| module_id.starts_with(prefix.as_str()))
2074 .max_by_key(|(prefix, _)| prefix.len())
2075 .map(|(prefix, owner)| (prefix.clone(), owner.clone()));
2076 let (prefix, owner_module_id) = matched_prefix?;
2077
2078 let authorized = presented.is_some_and(|presented| {
2079 self.spawn_nonces
2080 .lock()
2081 .unwrap_or_else(|poisoned| poisoned.into_inner())
2082 .get(&owner_module_id)
2083 .is_some_and(|expected| constant_time_eq(expected.as_bytes(), presented.as_bytes()))
2084 || self.swap_nonce_matches(&owner_module_id, presented)
2087 });
2088 if authorized {
2089 None
2090 } else {
2091 Some(ReservedHelloRejection::Prefix {
2092 prefix,
2093 owner_module_id,
2094 })
2095 }
2096 }
2097
2098 pub fn spawned_consumer_authorized(&self, module_id: &str, presented: &str) -> bool {
2103 if presented.is_empty() {
2104 return false;
2105 }
2106 let nonces = self
2107 .spawn_nonces
2108 .lock()
2109 .unwrap_or_else(|poisoned| poisoned.into_inner());
2110 let current = nonces
2111 .get(module_id)
2112 .is_some_and(|expected| constant_time_eq(expected.as_bytes(), presented.as_bytes()));
2113 drop(nonces);
2114 current || self.swap_nonce_matches(module_id, presented)
2119 }
2120
2121 fn swap_nonce_matches(&self, module_id: &str, presented: &str) -> bool {
2123 let swaps = self
2124 .swaps
2125 .lock()
2126 .unwrap_or_else(|poisoned| poisoned.into_inner());
2127 swaps.get(module_id).is_some_and(|swap| {
2128 constant_time_eq(swap.candidate_nonce.as_bytes(), presented.as_bytes())
2129 || swap.incumbent_nonce.as_deref().is_some_and(|incumbent| {
2130 constant_time_eq(incumbent.as_bytes(), presented.as_bytes())
2131 })
2132 })
2133 }
2134
2135 pub(crate) fn open_swap(&self, module_id: &str, candidate_nonce: String) {
2138 let incumbent_nonce = self
2139 .spawn_nonces
2140 .lock()
2141 .unwrap_or_else(|poisoned| poisoned.into_inner())
2142 .get(module_id)
2143 .cloned();
2144 self.swaps
2145 .lock()
2146 .unwrap_or_else(|poisoned| poisoned.into_inner())
2147 .insert(
2148 module_id.to_string(),
2149 OpenSwap {
2150 candidate_nonce,
2151 incumbent_nonce,
2152 candidate_admitted: false,
2153 },
2154 );
2155 }
2156
2157 pub(crate) fn close_swap(&self, module_id: &str) {
2160 self.swaps
2161 .lock()
2162 .unwrap_or_else(|poisoned| poisoned.into_inner())
2163 .remove(module_id);
2164 }
2165
2166 pub(crate) fn set_swap_promotion_observer(
2169 &self,
2170 observer: std::sync::Weak<dyn SwapPromotionObserver>,
2171 ) {
2172 *self
2173 .promotion_observer
2174 .0
2175 .lock()
2176 .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(observer);
2177 }
2178
2179 fn notify_swap_promoted(&self, registration: &crate::registry::ModuleRegistration) {
2182 let observer = self
2183 .promotion_observer
2184 .0
2185 .lock()
2186 .unwrap_or_else(|poisoned| poisoned.into_inner())
2187 .as_ref()
2188 .and_then(std::sync::Weak::upgrade);
2189 if let Some(observer) = observer {
2190 observer.swap_promoted(registration);
2191 }
2192 }
2193
2194 pub(crate) fn swap_open(&self, module_id: &str) -> bool {
2196 self.swaps
2197 .lock()
2198 .unwrap_or_else(|poisoned| poisoned.into_inner())
2199 .contains_key(module_id)
2200 }
2201
2202 fn promote_swap_nonce(&self, module_id: &str, reserved: bool) {
2207 let candidate_nonce = self
2208 .swaps
2209 .lock()
2210 .unwrap_or_else(|poisoned| poisoned.into_inner())
2211 .get(module_id)
2212 .map(|swap| swap.candidate_nonce.clone());
2213 let Some(nonce) = candidate_nonce else {
2214 return;
2215 };
2216 self.set_spawn_nonce(module_id, nonce.clone());
2217 if reserved {
2218 self.set_reserved_nonce(module_id, nonce);
2219 }
2220 }
2221
2222 pub(crate) fn swap_hello_admission(
2237 &self,
2238 module_id: &str,
2239 presented: Option<&str>,
2240 ) -> SwapHelloAdmission {
2241 let swaps = self
2242 .swaps
2243 .lock()
2244 .unwrap_or_else(|poisoned| poisoned.into_inner());
2245 let Some(swap) = swaps.get(module_id) else {
2246 return SwapHelloAdmission::NotSwapping;
2247 };
2248 let Some(presented) = presented else {
2249 return SwapHelloAdmission::Refused;
2250 };
2251 if constant_time_eq(swap.candidate_nonce.as_bytes(), presented.as_bytes()) {
2252 return if swap.candidate_admitted {
2253 SwapHelloAdmission::Refused
2254 } else {
2255 SwapHelloAdmission::Candidate
2256 };
2257 }
2258 if swap
2259 .incumbent_nonce
2260 .as_deref()
2261 .is_some_and(|incumbent| constant_time_eq(incumbent.as_bytes(), presented.as_bytes()))
2262 {
2263 return SwapHelloAdmission::NotSwapping;
2264 }
2265 SwapHelloAdmission::Refused
2266 }
2267
2268 pub(crate) fn mark_swap_candidate_admitted(&self, module_id: &str) {
2271 if let Some(swap) = self
2272 .swaps
2273 .lock()
2274 .unwrap_or_else(|poisoned| poisoned.into_inner())
2275 .get_mut(module_id)
2276 {
2277 swap.candidate_admitted = true;
2278 }
2279 }
2280
2281 pub fn spawn_launch_nonce_for(&self, module_id: &str) -> Option<String> {
2283 self.spawn_nonces
2284 .lock()
2285 .unwrap_or_else(|poisoned| poisoned.into_inner())
2286 .get(module_id)
2287 .cloned()
2288 }
2289
2290 pub fn reserved_launch_nonce_for(&self, module_id: &str) -> Option<String> {
2292 self.reserved_nonces
2293 .lock()
2294 .unwrap_or_else(|poisoned| poisoned.into_inner())
2295 .get(module_id)
2296 .cloned()
2297 .flatten()
2298 }
2299
2300 pub fn insert(&self, module: SupervisedModule) -> Option<SupervisedModule> {
2301 self.mark_configured(module.module_id());
2305 let mut modules = self
2306 .modules
2307 .lock()
2308 .unwrap_or_else(|poisoned| poisoned.into_inner());
2309 modules.insert(module.module_id().to_string(), module)
2310 }
2311
2312 fn mark_configured(&self, module_id: &str) {
2316 self.configured_ids
2317 .lock()
2318 .unwrap_or_else(|poisoned| poisoned.into_inner())
2319 .insert(module_id.to_string());
2320 }
2321
2322 fn unmark_configured_unless_rostered(&self, module_id: &str) {
2326 let modules = self
2327 .modules
2328 .lock()
2329 .unwrap_or_else(|poisoned| poisoned.into_inner());
2330 if !modules.contains_key(module_id) {
2331 self.configured_ids
2332 .lock()
2333 .unwrap_or_else(|poisoned| poisoned.into_inner())
2334 .remove(module_id);
2335 }
2336 }
2337
2338 pub(crate) fn is_configured(&self, module_id: &str) -> bool {
2346 self.configured_ids
2347 .lock()
2348 .unwrap_or_else(|poisoned| poisoned.into_inner())
2349 .contains(module_id)
2350 }
2351
2352 pub fn get(&self, module_id: &str) -> Option<SupervisedModule> {
2353 let modules = self
2354 .modules
2355 .lock()
2356 .unwrap_or_else(|poisoned| poisoned.into_inner());
2357 modules.get(module_id).cloned()
2358 }
2359
2360 pub(crate) fn record_late_health_answer(
2361 &self,
2362 module_id: &str,
2363 latency_ms: u64,
2364 ) -> Result<bool, SuperviseError> {
2365 let Some(module) = self.get(module_id) else {
2366 return Ok(false);
2367 };
2368 update_snapshot(&module.inner.snapshot, Some(module_id), |state| {
2369 state.health.late_answer_count = state.health.late_answer_count.saturating_add(1);
2370 state.health.last_late_answer_latency_ms = Some(latency_ms);
2371 state.health.consecutive_failures = 0;
2379 })?;
2380 Ok(true)
2381 }
2382
2383 pub fn record_deliberate_severance(&self, module_id: &str) -> Result<bool, SuperviseError> {
2389 let Some(module) = self.get(module_id) else {
2390 return Ok(false);
2391 };
2392 let snapshot = lock_snapshot(&module.inner.snapshot)?;
2393 let Some((pid, start_time)) = snapshot.pid.zip(snapshot.process_start_time) else {
2394 return Ok(false);
2395 };
2396 drop(snapshot);
2397 module.record_deliberate_severance(ProcessIdentity { pid, start_time })
2398 }
2399
2400 pub fn list(&self) -> Vec<SupervisedModule> {
2401 let modules = self
2402 .modules
2403 .lock()
2404 .unwrap_or_else(|poisoned| poisoned.into_inner());
2405 let mut modules = modules.values().cloned().collect::<Vec<_>>();
2406 modules.sort_by(|left, right| left.module_id().cmp(right.module_id()));
2407 modules
2408 }
2409
2410 pub(crate) fn retire(&self, module_id: &str) -> Option<SupervisedModule> {
2411 self.spawn_nonces
2412 .lock()
2413 .unwrap_or_else(|poisoned| poisoned.into_inner())
2414 .remove(module_id);
2415 self.close_swap(module_id);
2416 let mut reserved_nonces = self
2417 .reserved_nonces
2418 .lock()
2419 .unwrap_or_else(|poisoned| poisoned.into_inner());
2420 if reserved_nonces.contains_key(module_id) {
2421 reserved_nonces.insert(module_id.to_string(), None);
2424 }
2425 drop(reserved_nonces);
2426 self.reserved_prefix_owners
2427 .lock()
2428 .unwrap_or_else(|poisoned| poisoned.into_inner())
2429 .retain(|_, owner| owner != module_id);
2430 let removed = self
2431 .modules
2432 .lock()
2433 .unwrap_or_else(|poisoned| poisoned.into_inner())
2434 .remove(module_id);
2435 self.configured_ids
2436 .lock()
2437 .unwrap_or_else(|poisoned| poisoned.into_inner())
2438 .remove(module_id);
2439 removed
2440 }
2441
2442 pub(crate) fn record_rescan_removal(&self, module_id: &str) {
2445 self.removal_tombstones
2446 .lock()
2447 .unwrap_or_else(|poisoned| poisoned.into_inner())
2448 .insert(module_id.to_string(), unix_ms_now());
2449 }
2450
2451 pub(crate) fn removal_tombstone_age_ms(&self, module_id: &str) -> Option<u64> {
2453 self.removal_tombstones
2454 .lock()
2455 .unwrap_or_else(|poisoned| poisoned.into_inner())
2456 .get(module_id)
2457 .copied()
2458 .map(|removed_at_ms| unix_ms_now().saturating_sub(removed_at_ms))
2459 }
2460
2461 pub(crate) fn release_retained_reserved_gate(&self, module_id: &str) -> bool {
2466 if self.get(module_id).is_some() {
2467 return false;
2468 }
2469 let mut reserved_nonces = self
2470 .reserved_nonces
2471 .lock()
2472 .unwrap_or_else(|poisoned| poisoned.into_inner());
2473 if !matches!(reserved_nonces.get(module_id), Some(None)) {
2474 return false;
2475 }
2476 reserved_nonces.remove(module_id);
2477 true
2478 }
2479
2480 pub(crate) fn operation_lock(&self) -> Arc<AsyncMutex<()>> {
2481 Arc::clone(&self.operation_lock)
2482 }
2483}
2484
2485#[derive(Debug, Clone)]
2487pub struct Supervisor {
2488 registry: Arc<Registry>,
2489 restart_policy: RestartPolicy,
2490 drain_timeout: Duration,
2491 connection_file_path: Option<PathBuf>,
2492 capture_logs_dir: Option<PathBuf>,
2493 forwarding: Option<Arc<ForwardingTable>>,
2494 process_liveness: Arc<SupervisorProcessLiveness>,
2495 supervisor_handle: Option<SupervisorHandle>,
2496 health: HealthConfig,
2497 daemon_start_clock: crate::clock::StartClock,
2498 terminal_journal: Option<Arc<crate::terminal_journal::TerminalJournal>>,
2499 spawn_events: SpawnEventFeed,
2500 provenance_probe: ExecutableIdentityProbe,
2501 child_roster: ChildRoster,
2504 #[cfg(target_os = "linux")]
2505 cgroup_placement: Option<subc_cgroup::Placement>,
2506 #[cfg(test)]
2507 test_after_first_spawn: AfterFirstSpawnHook,
2508}
2509
2510#[cfg(test)]
2516#[derive(Clone, Default)]
2517struct AfterFirstSpawnHook(Option<AfterFirstSpawnFn>);
2518
2519#[cfg(test)]
2520type AfterFirstSpawnFn = Arc<dyn Fn(&str) + Send + Sync>;
2521
2522#[cfg(test)]
2523impl fmt::Debug for AfterFirstSpawnHook {
2524 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2525 f.write_str("AfterFirstSpawnHook")
2526 }
2527}
2528
2529#[cfg(test)]
2530impl AfterFirstSpawnHook {
2531 fn run(&self, module_id: &str) {
2532 if let Some(hook) = &self.0 {
2533 hook(module_id);
2534 }
2535 }
2536}
2537
2538impl Supervisor {
2539 #[cfg(test)]
2540 pub(crate) fn new_for_test(registry: Arc<Registry>, policy: RestartPolicy) -> Self {
2541 let supervisor = Self::new(registry, policy);
2542 #[cfg(target_os = "macos")]
2543 let supervisor = supervisor.with_privacy_trampoline(test_privacy_trampoline());
2544 supervisor
2545 }
2546 pub fn with_privacy_trampoline(self, path: impl Into<PathBuf>) -> Self {
2551 let path = path.into();
2552 #[cfg(target_os = "macos")]
2553 {
2554 let result = probe_privacy_trampoline(&path).map(|()| path);
2555 if let Err(cause) = &result {
2556 error!(%cause, "macOS privacy identity unavailable; supervised launches will refuse");
2557 }
2558 self.child_roster.set_privacy_trampoline(result);
2559 }
2560 #[cfg(not(target_os = "macos"))]
2561 let _ = path;
2562 self
2563 }
2564 #[cfg(unix)]
2575 pub(crate) fn begin_daemon_shutdown(&self) {
2576 self.child_roster.close();
2577 if let Some(journal) = &self.terminal_journal {
2578 journal.stamp_shutdown();
2579 }
2580 }
2581
2582 #[cfg(unix)]
2586 pub(crate) async fn drain_for_daemon_shutdown(&self) -> Result<(), SuperviseError> {
2587 const NOTICE_BUDGET: Duration = Duration::from_millis(500);
2588 const DRAIN_BUDGET: Duration = Duration::from_secs(2);
2589 let Some(forwarding) = &self.forwarding else {
2590 return Ok(());
2591 };
2592 let module_ids = forwarding
2593 .begin_daemon_drain()
2594 .map_err(SuperviseError::Forwarding)?;
2595 let deadline_ms =
2596 unix_ms_now().saturating_add((NOTICE_BUDGET + DRAIN_BUDGET).as_millis() as u64);
2597 let mut notices = tokio::task::JoinSet::new();
2598 let mut drains = Vec::new();
2599 for module_id in module_ids {
2600 let Some(target) = forwarding
2601 .begin_module_drain(&module_id, RouteCloseReason::Restart)
2602 .map_err(SuperviseError::Forwarding)?
2603 else {
2604 continue;
2605 };
2606 let routes = forwarding
2607 .endpoint_routes(target.endpoint)
2608 .map_err(SuperviseError::Forwarding)?;
2609 let command = serde_json::to_vec(&ModuleControlCommand::Draining {
2615 reason: RouteCloseReason::Restart,
2616 deadline_ms,
2617 })
2618 .expect("module draining serializes");
2619 let mut recipients = vec![(target.sink.clone(), target.negotiated_ver, command)];
2620 let mut clients: Vec<(crate::forwarding::GoodbyeTarget, Vec<u16>)> = Vec::new();
2621 for route in routes {
2622 let client = route.goodbye_target;
2623 if let Some((_, channels)) = clients
2624 .iter_mut()
2625 .find(|(existing, _)| existing.connection_id == client.connection_id)
2626 {
2627 channels.push(client.channel);
2628 } else {
2629 let channel = client.channel;
2630 clients.push((client, vec![channel]));
2631 }
2632 }
2633 for (client, mut channels) in clients {
2634 channels.sort_unstable();
2635 channels.dedup();
2636 let closing = serde_json::to_vec(&ClientControlPush::RouteClosing {
2637 module_id: module_id.clone(),
2638 channels,
2639 reason: RouteCloseReason::Restart,
2640 })
2641 .expect("route closing serializes");
2642 recipients.push((client.sink, client.negotiated_ver, closing));
2643 }
2644 for (sink, version, body) in recipients {
2645 notices.spawn(async move {
2646 let frame = Frame::build_with_version(
2647 version,
2648 FrameType::Push,
2649 control_flags(),
2650 0,
2651 0,
2652 0,
2653 body,
2654 )
2655 .expect("bounded lifecycle notice frame builds");
2656 sink.send_flushed(frame).await
2657 });
2658 }
2659 let gauges = declared_busy_gauges(&self.registry, &module_id)?;
2660 drains.push((module_id, target.endpoint, gauges));
2661 }
2662 let notice_deadline = Instant::now() + NOTICE_BUDGET;
2665 while let Ok(Some(result)) = timeout_at(notice_deadline, notices.join_next()).await {
2666 if !matches!(result, Ok(Ok(()))) {
2667 warn!(?result, "daemon shutdown notice delivery failed");
2668 }
2669 }
2670 notices.abort_all();
2671 let deadline = Instant::now() + DRAIN_BUDGET;
2672 let mut waits = tokio::task::JoinSet::new();
2673 for (module_id, endpoint, gauges) in drains {
2674 let forwarding = Arc::clone(forwarding);
2675 let mut runtime = self.runtime_config();
2676 runtime.health.cadence = Duration::from_millis(100);
2677 waits.spawn(async move {
2678 wait_for_forwarding_quiescence(
2679 &forwarding,
2680 &module_id,
2681 &runtime,
2682 endpoint,
2683 deadline,
2684 &gauges,
2685 DrainScope::Active,
2686 )
2687 .await
2688 });
2689 }
2690 while let Ok(Some(result)) = timeout_at(deadline, waits.join_next()).await {
2691 if !matches!(result, Ok(Ok(true))) {
2692 warn!(?result, "daemon shutdown drain did not reach quiescence");
2693 }
2694 }
2695 Ok(())
2696 }
2697
2698 #[cfg(unix)]
2710 pub(crate) async fn end_children_for_daemon_shutdown(
2711 &self,
2712 already_escalated: bool,
2713 escalate: impl std::future::Future<Output = ()>,
2714 ) {
2715 tokio::pin!(escalate);
2716 let mut escalated = already_escalated;
2717 if let Some(forwarding) = &self.forwarding {
2718 let reason = CloseReason::new(
2719 "daemon_shutdown",
2720 "the daemon is exiting after its shutdown notice and drain",
2721 );
2722 if escalated {
2723 send_module_goodbyes_for_daemon_shutdown(forwarding, &reason, false).await;
2726 } else {
2727 tokio::select! {
2728 biased;
2729 _ = escalate.as_mut() => {
2730 info!("second SIGTERM: abandoning module GOODBYE delivery");
2731 escalated = true;
2732 }
2733 _ = send_module_goodbyes_for_daemon_shutdown(forwarding, &reason, true) => {}
2734 }
2735 }
2736 let closed = forwarding.close_all_connections(&reason);
2737 debug!(closed, "closed established connections for daemon shutdown");
2738 }
2739 let escalated_here = escalated && !already_escalated;
2743 let remaining_escalate = async move {
2744 if escalated_here {
2745 std::future::pending::<()>().await;
2746 } else {
2747 escalate.await;
2748 }
2749 };
2750 crate::child_roster::end_children_for_daemon_shutdown(
2751 &self.child_roster,
2752 escalated,
2753 remaining_escalate,
2754 )
2755 .await;
2756 }
2757
2758 pub fn new(registry: Arc<Registry>, restart_policy: RestartPolicy) -> Self {
2759 Self {
2760 registry,
2761 restart_policy,
2762 drain_timeout: DEFAULT_DRAIN_TIMEOUT,
2763 connection_file_path: None,
2764 capture_logs_dir: None,
2765 forwarding: None,
2766 process_liveness: Arc::new(SupervisorProcessLiveness::default()),
2767 supervisor_handle: None,
2768 health: HealthConfig::default(),
2769 daemon_start_clock: crate::clock::StartClock::capture(),
2770 terminal_journal: None,
2771 spawn_events: SpawnEventFeed::default(),
2772 provenance_probe: ExecutableIdentityProbe::default(),
2773 child_roster: ChildRoster::default(),
2774 #[cfg(target_os = "linux")]
2775 cgroup_placement: None,
2776 #[cfg(test)]
2777 test_after_first_spawn: AfterFirstSpawnHook::default(),
2778 }
2779 }
2780
2781 pub fn with_drain_timeout(mut self, drain_timeout: Duration) -> Self {
2782 self.drain_timeout = drain_timeout;
2783 self
2784 }
2785
2786 pub fn with_process_liveness(
2787 mut self,
2788 process_liveness: Arc<SupervisorProcessLiveness>,
2789 ) -> Self {
2790 self.process_liveness = process_liveness;
2791 self
2792 }
2793
2794 pub fn with_connection_file_path(mut self, connection_file_path: impl Into<PathBuf>) -> Self {
2795 self.connection_file_path = Some(connection_file_path.into());
2796 self
2797 }
2798
2799 pub fn with_capture_logs_dir(mut self, logs_dir: impl Into<PathBuf>) -> Self {
2801 self.capture_logs_dir = Some(logs_dir.into());
2802 self
2803 }
2804
2805 pub fn with_daemon_incarnation(self, daemon_incarnation: String) -> Self {
2808 self.spawn_events.configure_incarnation(daemon_incarnation);
2812 self
2813 }
2814
2815 pub fn with_terminal_journal(self, path: PathBuf, daemon_incarnation: String) -> Self {
2818 let mut this = self.with_daemon_incarnation(daemon_incarnation.clone());
2819 this.terminal_journal = Some(Arc::new(crate::terminal_journal::TerminalJournal::open(
2820 path,
2821 daemon_incarnation,
2822 )));
2823 this
2824 }
2825
2826 pub fn with_forwarding(mut self, forwarding: Arc<ForwardingTable>) -> Self {
2827 self.forwarding = Some(forwarding);
2828 self
2829 }
2830
2831 pub fn with_handle(mut self, supervisor_handle: SupervisorHandle) -> Self {
2832 self.spawn_events = supervisor_handle.spawn_events.clone();
2833 self.supervisor_handle = Some(supervisor_handle);
2834 self
2835 }
2836
2837 pub fn with_health_config(mut self, health: HealthConfig) -> Self {
2838 self.health = health;
2839 self
2840 }
2841
2842 pub fn with_live_children_record(self, path: impl Into<PathBuf>) -> Self {
2846 self.child_roster.record_to(path.into());
2847 self
2848 }
2849
2850 #[cfg(target_os = "linux")]
2851 pub fn with_cgroup_placement(
2852 mut self,
2853 cgroup_placement: Option<subc_cgroup::Placement>,
2854 ) -> Self {
2855 self.cgroup_placement = cgroup_placement;
2856 self
2857 }
2858
2859 pub fn spawn(&self, spec: ModuleSpec) -> Result<SupervisedModule, SuperviseError> {
2865 validate_spec(&spec)?;
2866 self.establish_identity(&spec);
2867
2868 let runtime = self.runtime_config();
2869 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
2870 let spawned = spawn_child(
2871 &spec,
2872 runtime.connection_file_path.as_deref(),
2873 self.supervisor_handle.as_ref(),
2874 &runtime.stderr_ring,
2875 runtime.capture_logs_dir.as_deref(),
2876 &runtime.child_roster,
2877 #[cfg(target_os = "linux")]
2878 runtime.cgroup_placement.as_ref(),
2879 );
2880 #[cfg(test)]
2881 self.test_after_first_spawn.run(&spec.module_id);
2882 let child = match spawned {
2883 Ok(child) => child,
2884 Err(err) => {
2885 self.abandon_unrostered(&spec.module_id);
2888 return Err(err);
2889 }
2890 };
2891 self.mark_first_process_running(&spec, &runtime, &snapshot, &child)?;
2892
2893 Ok(self.supervised_module(spec, runtime, snapshot, Some(child)))
2894 }
2895
2896 fn establish_identity(&self, spec: &ModuleSpec) {
2909 if let Some(supervisor_handle) = &self.supervisor_handle {
2910 supervisor_handle.apply_identity_configuration(spec);
2911 supervisor_handle.mark_configured(&spec.module_id);
2912 }
2913 }
2914
2915 fn abandon_unrostered(&self, module_id: &str) {
2918 if let Some(supervisor_handle) = &self.supervisor_handle {
2919 supervisor_handle.unmark_configured_unless_rostered(module_id);
2920 }
2921 }
2922
2923 fn mark_first_process_running(
2926 &self,
2927 spec: &ModuleSpec,
2928 runtime: &SupervisorRuntimeConfig,
2929 snapshot: &SharedSnapshot,
2930 child: &SupervisedChild,
2931 ) -> Result<(), SuperviseError> {
2932 if let Err(err) = set_running(snapshot, child, &spec.module_id, &runtime.spawn_events) {
2933 self.abandon_unrostered(&spec.module_id);
2934 return Err(err);
2935 }
2936 self.process_liveness
2937 .track(spec.module_id.clone(), Arc::clone(snapshot));
2938 Ok(())
2939 }
2940
2941 pub fn supervise_configured(
2947 &self,
2948 spec: ModuleSpec,
2949 enabled: bool,
2950 ) -> Result<SupervisedModule, SuperviseError> {
2951 validate_spec(&spec)?;
2952 self.establish_identity(&spec);
2953
2954 let runtime = self.runtime_config();
2955 if !enabled {
2956 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::disabled()));
2957 return Ok(self.supervised_module(spec, runtime, snapshot, None));
2958 }
2959
2960 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
2961 let spawned = spawn_child(
2962 &spec,
2963 runtime.connection_file_path.as_deref(),
2964 self.supervisor_handle.as_ref(),
2965 &runtime.stderr_ring,
2966 runtime.capture_logs_dir.as_deref(),
2967 &runtime.child_roster,
2968 #[cfg(target_os = "linux")]
2969 runtime.cgroup_placement.as_ref(),
2970 );
2971 #[cfg(test)]
2972 self.test_after_first_spawn.run(&spec.module_id);
2973 match spawned {
2974 Ok(child) => {
2975 self.mark_first_process_running(&spec, &runtime, &snapshot, &child)?;
2976 Ok(self.supervised_module(spec, runtime, snapshot, Some(child)))
2977 }
2978 Err(err) => {
2979 error!(
2980 module_id = %spec.module_id,
2981 program = %spec.program.display(),
2982 error = %err,
2983 "configured module failed to spawn; marking failed and continuing"
2984 );
2985 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::failed()));
2986 Ok(self.supervised_module(spec, runtime, snapshot, None))
2987 }
2988 }
2989 }
2990
2991 pub fn supervise_configured_with_health(
2997 &self,
2998 spec: ModuleSpec,
2999 enabled: bool,
3000 health: HealthConfig,
3001 drain_timeout_ms: Option<u64>,
3002 restart_policy: RestartPolicy,
3003 ) -> Result<SupervisedModule, SuperviseError> {
3004 validate_spec(&spec)?;
3005 self.establish_identity(&spec);
3006
3007 let mut runtime = self.runtime_config();
3008 runtime.health = health.clone();
3009 runtime.restart_policy = restart_policy;
3010 if let Some(ms) = drain_timeout_ms {
3011 runtime.drain_timeout = Duration::from_millis(ms);
3012 *runtime
3013 .effective_drain_timeout
3014 .lock()
3015 .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.drain_timeout;
3016 }
3017 if !enabled {
3018 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::disabled()));
3019 return Ok(self.supervised_module(spec, runtime, snapshot, None));
3020 }
3021
3022 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
3023 let spawned = spawn_child(
3024 &spec,
3025 runtime.connection_file_path.as_deref(),
3026 self.supervisor_handle.as_ref(),
3027 &runtime.stderr_ring,
3028 runtime.capture_logs_dir.as_deref(),
3029 &runtime.child_roster,
3030 #[cfg(target_os = "linux")]
3031 runtime.cgroup_placement.as_ref(),
3032 );
3033 #[cfg(test)]
3034 self.test_after_first_spawn.run(&spec.module_id);
3035 match spawned {
3036 Ok(child) => {
3037 self.mark_first_process_running(&spec, &runtime, &snapshot, &child)?;
3038 Ok(self.supervised_module(spec, runtime, snapshot, Some(child)))
3039 }
3040 Err(err) => {
3041 if health.critical {
3042 error!(
3043 module_id = %spec.module_id,
3044 program = %spec.program.display(),
3045 error = %err,
3046 "critical configured module failed to spawn; marking failed and alerting"
3047 );
3048 } else {
3049 error!(
3050 module_id = %spec.module_id,
3051 program = %spec.program.display(),
3052 error = %err,
3053 "configured module failed to spawn; marking failed and continuing"
3054 );
3055 }
3056 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::failed()));
3057 Ok(self.supervised_module(spec, runtime, snapshot, None))
3058 }
3059 }
3060 }
3061
3062 fn runtime_config(&self) -> SupervisorRuntimeConfig {
3063 let effective_drain_timeout = Arc::new(Mutex::new(self.drain_timeout));
3064 SupervisorRuntimeConfig {
3065 scheduled_respawn: Arc::default(),
3066 deferred_reload_reply: Arc::default(),
3067 restart_policy: self.restart_policy,
3068 drain_timeout: self.drain_timeout,
3069 child_roster: self
3072 .child_roster
3073 .for_module(Arc::clone(&effective_drain_timeout)),
3074 effective_drain_timeout,
3075 default_drain_timeout: self.drain_timeout,
3076 health: self.health.clone(),
3077 connection_file_path: self.connection_file_path.clone(),
3078 capture_logs_dir: self.capture_logs_dir.clone(),
3079 forwarding: self.forwarding.clone(),
3080 supervisor_handle: self.supervisor_handle.clone(),
3081 stderr_ring: Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default()))),
3082 terminal_ring: Arc::new(Mutex::new(
3083 TerminalRing::new(
3084 TerminalRingConfig::default(),
3085 self.daemon_start_clock.started_at_ms(),
3086 )
3087 .with_start_clock(self.daemon_start_clock)
3088 .with_journal(self.terminal_journal.clone())
3089 .with_daemon_shutdown(self.child_roster.shutdown_flag()),
3090 )),
3091 spawn_events: self.spawn_events.clone(),
3092 #[cfg(target_os = "linux")]
3093 cgroup_placement: self.cgroup_placement.clone(),
3094 #[cfg(test)]
3095 test_seed_stale_facts_before_enable_spawn: false,
3096 #[cfg(test)]
3097 test_reload_exit_record_gate: None,
3098 }
3099 }
3100
3101 fn supervised_module(
3102 &self,
3103 spec: ModuleSpec,
3104 runtime: SupervisorRuntimeConfig,
3105 snapshot: SharedSnapshot,
3106 child: Option<SupervisedChild>,
3107 ) -> SupervisedModule {
3108 let configuration = Arc::new(Mutex::new(SupervisedConfiguration {
3109 spec: spec.clone(),
3110 health: runtime.health.clone(),
3111 }));
3112 let stderr_ring = Arc::clone(&runtime.stderr_ring);
3113 let terminal_ring = Arc::clone(&runtime.terminal_ring);
3114 let restart_policy = runtime.restart_policy;
3118 let effective_drain_timeout = Arc::clone(&runtime.effective_drain_timeout);
3119 let (tx, rx) = mpsc::channel(4);
3120 let monitor = tokio::spawn(supervise_loop(
3121 spec.clone(),
3122 runtime,
3123 Arc::clone(&self.registry),
3124 Arc::clone(&self.process_liveness),
3125 Arc::clone(&snapshot),
3126 child,
3127 rx,
3128 ));
3129
3130 let module_id = spec.module_id.clone();
3131 let module = SupervisedModule {
3132 inner: Arc::new(SupervisedModuleInner {
3133 module_id: module_id.clone(),
3134 registry: Arc::clone(&self.registry),
3135 snapshot,
3136 configuration,
3137 stderr_ring,
3138 terminal_ring,
3139 commands: tx,
3140 monitor: Mutex::new(Some(monitor)),
3141 restart_policy,
3142 effective_drain_timeout,
3143 provenance_probe: self.provenance_probe.clone(),
3144 }),
3145 };
3146 if let Some(supervisor_handle) = &self.supervisor_handle {
3150 supervisor_handle.insert(module.clone());
3151 }
3152 module
3153 }
3154}
3155
3156impl Default for Supervisor {
3157 fn default() -> Self {
3158 Self::new(Arc::new(Registry::default()), RestartPolicy::default())
3159 }
3160}
3161
3162#[derive(Clone)]
3164pub struct SupervisedModule {
3165 inner: Arc<SupervisedModuleInner>,
3166}
3167
3168struct SupervisedModuleInner {
3169 module_id: String,
3170 registry: Arc<Registry>,
3171 snapshot: SharedSnapshot,
3172 configuration: Arc<Mutex<SupervisedConfiguration>>,
3173 stderr_ring: Arc<Mutex<StderrRing>>,
3174 terminal_ring: Arc<Mutex<TerminalRing>>,
3175 commands: mpsc::Sender<SupervisorCommand>,
3176 monitor: Mutex<Option<JoinHandle<()>>>,
3177 restart_policy: RestartPolicy,
3181 effective_drain_timeout: Arc<Mutex<Duration>>,
3182 provenance_probe: ExecutableIdentityProbe,
3183}
3184
3185impl fmt::Debug for SupervisedModule {
3186 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3187 f.debug_struct("SupervisedModule")
3188 .field("module_id", &self.inner.module_id)
3189 .field("status", &self.status())
3190 .finish_non_exhaustive()
3191 }
3192}
3193
3194impl SupervisedModule {
3195 pub fn module_id(&self) -> &str {
3196 &self.inner.module_id
3197 }
3198
3199 #[cfg(test)]
3203 pub(crate) fn record_health_probe_failure_for_test(
3204 &self,
3205 detail: &str,
3206 ) -> Result<(), SuperviseError> {
3207 update_snapshot(&self.inner.snapshot, Some(&self.inner.module_id), |state| {
3208 state.health.consecutive_failures = state.health.consecutive_failures.saturating_add(1);
3209 state.health.detail = Some(detail.to_string());
3210 })
3211 }
3212
3213 pub fn state(&self) -> Result<ModuleState, SuperviseError> {
3214 Ok(lock_snapshot(&self.inner.snapshot)?.state)
3215 }
3216
3217 pub(crate) fn registration_end_reason(
3218 &self,
3219 ) -> Result<Option<RegistrationEndReason>, SuperviseError> {
3220 let snapshot = lock_snapshot(&self.inner.snapshot)?;
3221 Ok(match snapshot.state {
3222 ModuleState::Draining if snapshot.draining_to_replace => {
3223 Some(RegistrationEndReason::SupervisorRestart)
3224 }
3225 ModuleState::Draining => Some(RegistrationEndReason::SupervisorStop),
3226 ModuleState::Restarting if snapshot.draining_to_replace => {
3227 Some(RegistrationEndReason::SupervisorRestart)
3228 }
3229 _ => None,
3230 })
3231 }
3232
3233 pub fn stderr_tail(
3240 &self,
3241 max_lines: Option<usize>,
3242 max_bytes: Option<usize>,
3243 ) -> StderrTailSnapshot {
3244 self.inner
3245 .stderr_ring
3246 .lock()
3247 .unwrap_or_else(|poisoned| poisoned.into_inner())
3248 .snapshot(max_lines, max_bytes)
3249 }
3250
3251 pub fn terminal_history(&self) -> TerminalHistorySnapshot {
3256 self.inner
3257 .terminal_ring
3258 .lock()
3259 .unwrap_or_else(|poisoned| poisoned.into_inner())
3260 .snapshot()
3261 }
3262
3263 pub fn durable_terminal_history(&self) -> subc_control::TerminalHistory {
3268 durable_terminal_history_of(&self.inner.terminal_ring, &self.inner.module_id)
3269 }
3270
3271 pub(crate) async fn read_durable_terminal_history(
3276 &self,
3277 ) -> Result<subc_control::TerminalHistory, tokio::task::JoinError> {
3278 let terminal_ring = Arc::clone(&self.inner.terminal_ring);
3279 let module_id = self.inner.module_id.clone();
3280 tokio::task::spawn_blocking(move || durable_terminal_history_of(&terminal_ring, &module_id))
3281 .await
3282 }
3283
3284 pub fn status(&self) -> Result<ModuleStatus, SuperviseError> {
3285 self.status_with_snapshot_lock(&self.inner.snapshot, None)
3286 .map(|(status, _)| status)
3287 }
3288
3289 pub(crate) fn record_deliberate_severance(
3290 &self,
3291 identity: ProcessIdentity,
3292 ) -> Result<bool, SuperviseError> {
3293 let mut snapshot = lock_snapshot(&self.inner.snapshot)?;
3294 if snapshot.pid != Some(identity.pid)
3295 || snapshot.process_start_time != Some(identity.start_time)
3296 {
3297 return Ok(false);
3298 }
3299 snapshot.deliberate_severance = Some(identity);
3300 Ok(true)
3301 }
3302
3303 pub(crate) fn status_for_control(
3308 &self,
3309 caller: &'static str,
3310 ) -> Result<ModuleStatus, SuperviseError> {
3311 self.status_with_snapshot_lock(&self.inner.snapshot, Some(caller))
3312 .map(|(status, _)| status)
3313 }
3314
3315 fn status_with_snapshot_lock(
3316 &self,
3317 snapshot: &SharedSnapshot,
3318 caller: Option<&'static str>,
3319 ) -> Result<(ModuleStatus, Option<SpawnedFileIdentity>), SuperviseError> {
3320 let mut guard = match caller {
3321 Some(caller) => lock_snapshot_for_control(snapshot, &self.inner.module_id, caller)?,
3322 None => lock_snapshot(snapshot)?,
3323 };
3324 let restart_count =
3327 guard.crash_restarts_in_window(self.inner.restart_policy.window, Instant::now());
3328 let snapshot = guard.clone();
3329 drop(guard);
3330 let drain_timeout = *self.inner.effective_drain_timeout.lock().map_err(|_| {
3331 SuperviseError::StatePoisoned {
3332 module_id: Some(self.inner.module_id.clone()),
3333 }
3334 })?;
3335 let registration_active = self
3336 .inner
3337 .registry
3338 .get_module(&self.inner.module_id)
3339 .map_err(SuperviseError::Registry)?
3340 .is_some();
3341 let protocol = snapshot
3342 .spawned_protocol
3343 .unwrap_or(self.declared_protocol()?);
3344 let running_process =
3345 snapshot.enabled && snapshot.state == ModuleState::Running && snapshot.process_alive;
3346 let live = match protocol {
3352 ModuleProtocol::Subc => running_process && registration_active,
3353 ModuleProtocol::None => running_process,
3354 };
3355
3356 Ok((
3357 ModuleStatus {
3358 module_id: self.inner.module_id.clone(),
3359 state: snapshot.state,
3360 enabled: snapshot.enabled,
3361 process_alive: snapshot.process_alive,
3362 registration_active,
3363 protocol,
3364 live,
3365 restart_count,
3366 lifetime_restarts: snapshot.lifetime_restarts,
3367 spawn_generation: snapshot.spawn_generation,
3368 max_restarts: self.inner.restart_policy.max_restarts,
3369 restart_window: self.inner.restart_policy.window,
3370 drain_timeout,
3371 restart_backoff: self.inner.restart_policy.backoff,
3372 restart_max_backoff: self.inner.restart_policy.max_backoff,
3373 pid: snapshot.reported_pid(),
3374 spawned_at_ms: snapshot.spawned_at_ms,
3375 spawned_from: snapshot.spawned_from,
3376 process_start_time: snapshot.process_start_time,
3377 last_exit: snapshot.last_exit,
3378 health: snapshot.health,
3379 },
3380 snapshot.spawned_file_identity,
3381 ))
3382 }
3383
3384 #[cfg(test)]
3385 pub(crate) fn hold_snapshot_for_test(
3386 &self,
3387 acquired: std::sync::mpsc::Sender<()>,
3388 hold: Duration,
3389 ) -> std::thread::JoinHandle<()> {
3390 let snapshot = Arc::clone(&self.inner.snapshot);
3391 std::thread::spawn(move || {
3392 let _guard = snapshot.lock().expect("test snapshot lock is not poisoned");
3393 acquired
3394 .send(())
3395 .expect("test receiver waits for snapshot lock");
3396 std::thread::sleep(hold);
3397 })
3398 }
3399
3400 pub(crate) async fn status_and_running_image_agreement(
3406 &self,
3407 ) -> Result<(ModuleStatus, subc_control::RunningImageAgreement), SuperviseError> {
3408 let (status, identity) = self.status_with_snapshot_lock(&self.inner.snapshot, None)?;
3409 let image = self
3410 .inner
3411 .provenance_probe
3412 .observe(
3413 status.pid,
3414 status.spawned_from.as_deref(),
3415 identity,
3416 status.process_start_time,
3417 )
3418 .await;
3419 Ok((status, image))
3420 }
3421
3422 pub(crate) async fn running_image_agreement(&self) -> subc_control::RunningImageAgreement {
3423 let snapshot = match lock_snapshot(&self.inner.snapshot) {
3424 Ok(snapshot) => snapshot.clone(),
3425 Err(_) => {
3426 return subc_control::RunningImageAgreement::Unavailable {
3427 reason: subc_control::RunningImageUnavailableReason::NotRunning,
3428 };
3429 }
3430 };
3431 self.inner
3432 .provenance_probe
3433 .observe(
3434 snapshot.reported_pid(),
3435 snapshot.spawned_from.as_deref(),
3436 snapshot.spawned_file_identity,
3437 snapshot.process_start_time,
3438 )
3439 .await
3440 }
3441
3442 pub(crate) fn child_resource_usage(&self) -> subc_control::ChildResourceUsage {
3445 let (pid, start_time) = match lock_snapshot(&self.inner.snapshot) {
3446 Ok(snapshot) => (snapshot.reported_pid(), snapshot.process_start_time),
3447 Err(_) => {
3448 return subc_control::ChildResourceUsage::Unavailable {
3449 reason: subc_control::ChildResourceUnavailableReason::Unreadable,
3450 }
3451 }
3452 };
3453 crate::child_resources::read(pid, start_time)
3454 }
3455
3456 pub(crate) fn will_recover_after_connection_loss(&self) -> Result<bool, SuperviseError> {
3457 let mut snapshot = lock_snapshot(&self.inner.snapshot)?;
3458 Ok(match snapshot.state {
3459 ModuleState::Restarting => true,
3460 ModuleState::Failed | ModuleState::Disabled => false,
3461 _ => daemon_will_restart(&mut snapshot, &self.inner.restart_policy, Instant::now()),
3462 })
3463 }
3464
3465 #[cfg(test)]
3466 pub(crate) fn is_warming(&self) -> Result<bool, SuperviseError> {
3467 self.is_warming_with_snapshot_lock(None)
3468 }
3469
3470 pub(crate) fn is_warming_for_control(
3471 &self,
3472 caller: &'static str,
3473 ) -> Result<bool, SuperviseError> {
3474 self.is_warming_with_snapshot_lock(Some(caller))
3475 }
3476
3477 fn is_warming_with_snapshot_lock(
3478 &self,
3479 caller: Option<&'static str>,
3480 ) -> Result<bool, SuperviseError> {
3481 let snapshot = match caller {
3482 Some(caller) => {
3483 lock_snapshot_for_control(&self.inner.snapshot, &self.inner.module_id, caller)?
3484 }
3485 None => lock_snapshot(&self.inner.snapshot)?,
3486 }
3487 .clone();
3488 Ok(matches!(
3489 snapshot.state,
3490 ModuleState::Starting | ModuleState::Running | ModuleState::Restarting
3491 ))
3492 }
3493
3494 pub async fn drain(&self) -> Result<(), SuperviseError> {
3496 self.stop().await
3497 }
3498
3499 pub(crate) async fn retire(&self) -> Result<(), SuperviseError> {
3500 match self.state()? {
3501 ModuleState::Stopped | ModuleState::Failed => return Ok(()),
3502 ModuleState::Starting
3503 | ModuleState::Running
3504 | ModuleState::Unresponsive
3505 | ModuleState::Restarting
3506 | ModuleState::Draining
3507 | ModuleState::Disabled => {}
3508 }
3509
3510 let (reply_tx, reply_rx) = oneshot::channel();
3511 self.inner
3512 .commands
3513 .send(SupervisorCommand::Retire { reply: reply_tx })
3514 .await
3515 .map_err(|_| SuperviseError::CommandClosed {
3516 module_id: self.inner.module_id.clone(),
3517 })?;
3518 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3519 module_id: self.inner.module_id.clone(),
3520 })?
3521 }
3522
3523 pub async fn stop(&self) -> Result<(), SuperviseError> {
3524 match self.state()? {
3525 ModuleState::Stopped | ModuleState::Failed => return Ok(()),
3526 ModuleState::Starting
3527 | ModuleState::Running
3528 | ModuleState::Unresponsive
3529 | ModuleState::Restarting
3530 | ModuleState::Draining
3531 | ModuleState::Disabled => {}
3532 }
3533
3534 let (reply_tx, reply_rx) = oneshot::channel();
3535 self.inner
3536 .commands
3537 .send(SupervisorCommand::Drain { reply: reply_tx })
3538 .await
3539 .map_err(|_| SuperviseError::CommandClosed {
3540 module_id: self.inner.module_id.clone(),
3541 })?;
3542 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3543 module_id: self.inner.module_id.clone(),
3544 })?
3545 }
3546
3547 pub async fn restart(&self, drain_timeout_ms: Option<u64>) -> Result<(), SuperviseError> {
3548 let received_at_generation = lock_snapshot(&self.inner.snapshot)?.spawn_generation;
3549 let (reply_tx, reply_rx) = oneshot::channel();
3550 self.inner
3551 .commands
3552 .send(SupervisorCommand::Restart {
3553 drain_timeout_ms,
3554 received_at_generation,
3555 queued_at: Instant::now(),
3556 reply: reply_tx,
3557 })
3558 .await
3559 .map_err(|_| SuperviseError::CommandClosed {
3560 module_id: self.inner.module_id.clone(),
3561 })?;
3562 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3563 module_id: self.inner.module_id.clone(),
3564 })?
3565 }
3566
3567 pub async fn swap(&self, ready_timeout: Option<Duration>) -> Result<(), SuperviseError> {
3572 let (reply_tx, reply_rx) = oneshot::channel();
3573 self.inner
3574 .commands
3575 .send(SupervisorCommand::Swap {
3576 ready_timeout,
3577 reply: reply_tx,
3578 })
3579 .await
3580 .map_err(|_| SuperviseError::CommandClosed {
3581 module_id: self.inner.module_id.clone(),
3582 })?;
3583 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3584 module_id: self.inner.module_id.clone(),
3585 })?
3586 }
3587
3588 pub async fn reload(&self) -> Result<(), SuperviseError> {
3589 let (reply_tx, reply_rx) = oneshot::channel();
3590 self.inner
3591 .commands
3592 .send(SupervisorCommand::Reload { reply: reply_tx })
3593 .await
3594 .map_err(|_| SuperviseError::CommandClosed {
3595 module_id: self.inner.module_id.clone(),
3596 })?;
3597 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3598 module_id: self.inner.module_id.clone(),
3599 })?
3600 }
3601
3602 pub async fn set_enabled(&self, enabled: bool) -> Result<bool, SuperviseError> {
3603 let (reply_tx, reply_rx) = oneshot::channel();
3604 self.inner
3605 .commands
3606 .send(SupervisorCommand::SetEnabled {
3607 enabled,
3608 reply: reply_tx,
3609 })
3610 .await
3611 .map_err(|_| SuperviseError::CommandClosed {
3612 module_id: self.inner.module_id.clone(),
3613 })?;
3614 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3615 module_id: self.inner.module_id.clone(),
3616 })?
3617 }
3618
3619 pub(crate) fn declared_protocol(&self) -> Result<ModuleProtocol, SuperviseError> {
3623 let configured = self
3624 .inner
3625 .configuration
3626 .lock()
3627 .map_err(|_| SuperviseError::StatePoisoned {
3628 module_id: Some(self.inner.module_id.clone()),
3629 })?
3630 .spec
3631 .protocol;
3632 let state = lock_snapshot(&self.inner.snapshot)?;
3633 Ok(state.spawned_protocol.unwrap_or(configured))
3634 }
3635
3636 pub(crate) fn configuration(&self) -> Result<(ModuleSpec, HealthConfig), SuperviseError> {
3637 let configuration =
3638 self.inner
3639 .configuration
3640 .lock()
3641 .map_err(|_| SuperviseError::StatePoisoned {
3642 module_id: Some(self.inner.module_id.clone()),
3643 })?;
3644 Ok((configuration.spec.clone(), configuration.health.clone()))
3645 }
3646
3647 #[cfg(any(test, feature = "test-support"))]
3651 pub async fn update_spec_for_test(&self, spec: ModuleSpec) -> Result<(), SuperviseError> {
3652 let (_, health) = self.configuration()?;
3653 let drain_timeout_ms = u64::try_from(
3654 self.inner
3655 .effective_drain_timeout
3656 .lock()
3657 .unwrap_or_else(|poisoned| poisoned.into_inner())
3658 .as_millis(),
3659 )
3660 .ok();
3661 self.update_configuration(spec, health, drain_timeout_ms)
3662 .await
3663 }
3664
3665 pub(crate) async fn update_configuration(
3666 &self,
3667 spec: ModuleSpec,
3668 health: HealthConfig,
3669 drain_timeout_ms: Option<u64>,
3670 ) -> Result<(), SuperviseError> {
3671 if spec.module_id != self.inner.module_id {
3672 return Err(SuperviseError::InvalidSpec {
3673 reason: "a supervised module's module_id cannot be changed".to_string(),
3674 });
3675 }
3676 validate_spec(&spec)?;
3677 let (reply_tx, reply_rx) = oneshot::channel();
3678 self.inner
3679 .commands
3680 .send(SupervisorCommand::UpdateConfiguration {
3681 spec: spec.clone(),
3682 health: health.clone(),
3683 drain_timeout_ms,
3684 reply: reply_tx,
3685 })
3686 .await
3687 .map_err(|_| SuperviseError::CommandClosed {
3688 module_id: self.inner.module_id.clone(),
3689 })?;
3690 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3691 module_id: self.inner.module_id.clone(),
3692 })?;
3693 let mut configuration =
3694 self.inner
3695 .configuration
3696 .lock()
3697 .map_err(|_| SuperviseError::StatePoisoned {
3698 module_id: Some(self.inner.module_id.clone()),
3699 })?;
3700 configuration.spec = spec;
3701 configuration.health = health;
3702 Ok(())
3703 }
3704}
3705
3706impl Drop for SupervisedModuleInner {
3707 fn drop(&mut self) {
3708 let Ok(mut monitor) = self.monitor.lock() else {
3709 return;
3710 };
3711 if let Some(monitor) = monitor.as_ref().filter(|monitor| !monitor.is_finished()) {
3712 let _ = update_snapshot(&self.snapshot, Some(&self.module_id), |state| {
3713 state.state = ModuleState::Stopped;
3714 clear_current_process_facts(state);
3715 });
3716 monitor.abort();
3717 }
3718 let _ = monitor.take();
3719 }
3720}
3721
3722#[derive(Debug)]
3723enum SupervisorCommand {
3724 Drain {
3725 reply: oneshot::Sender<Result<(), SuperviseError>>,
3726 },
3727 Retire {
3728 reply: oneshot::Sender<Result<(), SuperviseError>>,
3729 },
3730 Restart {
3731 drain_timeout_ms: Option<u64>,
3736 received_at_generation: u64,
3740 queued_at: Instant,
3743 reply: oneshot::Sender<Result<(), SuperviseError>>,
3744 },
3745 Reload {
3746 reply: oneshot::Sender<Result<(), SuperviseError>>,
3747 },
3748 SetEnabled {
3749 enabled: bool,
3750 reply: oneshot::Sender<Result<bool, SuperviseError>>,
3751 },
3752 UpdateConfiguration {
3753 spec: ModuleSpec,
3754 health: HealthConfig,
3755 drain_timeout_ms: Option<u64>,
3758 reply: oneshot::Sender<()>,
3759 },
3760 Swap {
3761 ready_timeout: Option<Duration>,
3764 reply: oneshot::Sender<Result<(), SuperviseError>>,
3766 },
3767}
3768
3769#[derive(Debug)]
3770pub enum SuperviseError {
3771 InvalidSpec {
3772 reason: String,
3773 },
3774 Spawn {
3775 program: PathBuf,
3776 source: io::Error,
3777 cgroup_path: Option<PathBuf>,
3778 },
3779 Cgroup {
3780 module_id: String,
3781 source: io::Error,
3782 },
3783 LaunchNonce {
3786 reason: String,
3787 },
3788 Wait {
3789 module_id: String,
3790 source: io::Error,
3791 },
3792 Kill {
3793 module_id: String,
3794 source: io::Error,
3795 },
3796 Forwarding(ForwardingError),
3797 Registry(RegistryError),
3798 ReloadUnavailable {
3799 module_id: String,
3800 reason: String,
3801 },
3802 Disabled {
3807 module_id: String,
3808 },
3809 ReloadFailed {
3810 module_id: String,
3811 reason: String,
3812 },
3813 RegistrationStillActive {
3814 module_id: String,
3815 waited: Duration,
3816 },
3817 StatePoisoned {
3818 module_id: Option<String>,
3819 },
3820 CommandClosed {
3821 module_id: String,
3822 },
3823 SwapInProgress {
3827 module_id: String,
3828 },
3829 SwapRefused {
3831 module_id: String,
3832 reason: SwapRefusal,
3833 },
3834 SwapFailed {
3838 module_id: String,
3839 arm: SwapFailureArm,
3840 detail: String,
3841 candidate_exit: Option<ExitReport>,
3844 },
3845}
3846
3847#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3849pub enum SwapRefusal {
3850 OverlapExclusive,
3852 NotRegistered,
3855 ProtocolNone,
3858 NotConfigured,
3861 AlreadySwapping,
3863}
3864
3865impl SwapRefusal {
3866 pub fn as_str(self) -> &'static str {
3867 match self {
3868 Self::OverlapExclusive => "overlap_exclusive",
3869 Self::NotRegistered => "not_registered",
3870 Self::ProtocolNone => "protocol_none",
3871 Self::NotConfigured => "not_configured",
3872 Self::AlreadySwapping => "already_swapping",
3873 }
3874 }
3875}
3876
3877#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3880pub enum SwapFailureArm {
3881 SpawnFailed,
3883 NeverRegistered,
3885 NeverReady,
3887 CandidateExited,
3889 CandidateUnhealthy,
3891 Interrupted,
3895 CutoverLost,
3900}
3901
3902impl SwapFailureArm {
3903 pub fn as_str(self) -> &'static str {
3904 match self {
3905 Self::SpawnFailed => "spawn_failed",
3906 Self::NeverRegistered => "never_registered",
3907 Self::NeverReady => "never_ready",
3908 Self::CandidateExited => "candidate_exited",
3909 Self::CandidateUnhealthy => "candidate_unhealthy",
3910 Self::Interrupted => "interrupted",
3911 Self::CutoverLost => "cutover_lost",
3912 }
3913 }
3914}
3915
3916impl fmt::Display for SuperviseError {
3917 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3918 match self {
3919 Self::InvalidSpec { reason } => write!(f, "invalid module spec: {reason}"),
3920 Self::Spawn {
3921 program,
3922 source,
3923 cgroup_path: Some(cgroup_path),
3924 } => write!(
3925 f,
3926 "failed to place module in cgroup '{}' while spawning '{}': {source}",
3927 cgroup_path.display(),
3928 program.display()
3929 ),
3930 Self::Spawn {
3931 program,
3932 source,
3933 cgroup_path: None,
3934 } => write!(
3935 f,
3936 "failed to spawn module '{}': {source}",
3937 program.display()
3938 ),
3939 Self::Cgroup { module_id, source } => {
3940 write!(
3941 f,
3942 "failed to prepare cgroup for module '{module_id}': {source}"
3943 )
3944 }
3945 Self::LaunchNonce { reason } => {
3946 write!(
3947 f,
3948 "failed to generate reserved-module launch nonce: {reason}"
3949 )
3950 }
3951 Self::Wait { module_id, source } => {
3952 write!(f, "failed to wait for module '{module_id}': {source}")
3953 }
3954 Self::Kill { module_id, source } => {
3955 write!(f, "failed to kill module '{module_id}': {source}")
3956 }
3957 Self::Forwarding(err) => write!(f, "forwarding error: {err}"),
3958 Self::Registry(err) => write!(f, "registry error: {err}"),
3959 Self::ReloadUnavailable { module_id, reason } => {
3960 write!(f, "reload unavailable for module '{module_id}': {reason}")
3961 }
3962 Self::Disabled { module_id } => {
3963 write!(
3964 f,
3965 "module '{module_id}' is disabled; enable it before restart or reload"
3966 )
3967 }
3968 Self::ReloadFailed { module_id, reason } => {
3969 write!(f, "reload failed for module '{module_id}': {reason}")
3970 }
3971 Self::RegistrationStillActive { module_id, waited } => write!(
3972 f,
3973 "module '{module_id}' registration remained active after waiting {waited:?}"
3974 ),
3975 Self::StatePoisoned { module_id } => match module_id {
3976 Some(module_id) => {
3977 write!(f, "supervisor state for module '{module_id}' was poisoned")
3978 }
3979 None => write!(f, "supervisor state was poisoned"),
3980 },
3981 Self::CommandClosed { module_id } => {
3982 write!(
3983 f,
3984 "supervisor command channel for module '{module_id}' is closed"
3985 )
3986 }
3987 Self::SwapInProgress { module_id } => write!(
3988 f,
3989 "module '{module_id}' is being swapped; retry once the swap has cut over or failed, or stop the module to abort the swap"
3990 ),
3991 Self::SwapRefused { module_id, reason } => match reason {
3992 SwapRefusal::OverlapExclusive => write!(
3993 f,
3994 "module '{module_id}' is declared overlap: \"exclusive\" (the default): two processes of it must not run at once, so it cannot be swapped; use a plain restart, or declare overlap: \"safe\" in its config if it really tolerates a second process"
3995 ),
3996 SwapRefusal::NotRegistered => write!(
3997 f,
3998 "module '{module_id}' is not registered, so there is no serving process to keep while a replacement warms; use a plain restart"
3999 ),
4000 SwapRefusal::ProtocolNone => write!(
4001 f,
4002 "module '{module_id}' is protocol: \"none\" and never registers, so a swap could never see its replacement become ready; use a plain restart"
4003 ),
4004 SwapRefusal::NotConfigured => write!(
4005 f,
4006 "module '{module_id}' cannot be swapped: the supervisor was built without the forwarding table or shared handle a swap needs"
4007 ),
4008 SwapRefusal::AlreadySwapping => {
4009 write!(f, "module '{module_id}' is already being swapped")
4010 }
4011 },
4012 Self::SwapFailed {
4013 module_id,
4014 arm,
4015 detail,
4016 ..
4017 } => write!(
4018 f,
4019 "swap of module '{module_id}' failed ({}): {detail}; the running process was left serving",
4020 arm.as_str()
4021 ),
4022 }
4023 }
4024}
4025
4026impl Error for SuperviseError {
4027 fn source(&self) -> Option<&(dyn Error + 'static)> {
4028 match self {
4029 Self::Spawn { source, .. }
4030 | Self::Cgroup { source, .. }
4031 | Self::Wait { source, .. }
4032 | Self::Kill { source, .. } => Some(source),
4033 Self::Forwarding(err) => Some(err),
4034 Self::Registry(err) => Some(err),
4035 Self::LaunchNonce { .. }
4036 | Self::InvalidSpec { .. }
4037 | Self::ReloadUnavailable { .. }
4038 | Self::Disabled { .. }
4039 | Self::ReloadFailed { .. }
4040 | Self::RegistrationStillActive { .. }
4041 | Self::StatePoisoned { .. }
4042 | Self::CommandClosed { .. }
4043 | Self::SwapInProgress { .. }
4044 | Self::SwapRefused { .. }
4045 | Self::SwapFailed { .. } => None,
4046 }
4047 }
4048}
4049
4050pub(crate) fn validate_spec(spec: &ModuleSpec) -> Result<(), SuperviseError> {
4051 if spec.module_id.trim().is_empty() {
4052 return Err(SuperviseError::InvalidSpec {
4053 reason: "module_id must not be empty".to_string(),
4054 });
4055 }
4056
4057 Ok(())
4058}
4059
4060#[derive(Debug, Default)]
4061struct HealthProbeRuntime {
4062 configured_health: Option<HealthConfig>,
4063 registered_connection: Option<crate::ConnectionId>,
4064 advertised: bool,
4065 next_probe_at: Option<Instant>,
4066 probe_index: u64,
4067}
4068
4069fn running_protocol(spec: &ModuleSpec, snapshot: &SharedSnapshot) -> ModuleProtocol {
4070 lock_snapshot(snapshot)
4071 .ok()
4072 .and_then(|state| state.spawned_protocol)
4073 .unwrap_or(spec.protocol)
4074}
4075
4076impl HealthProbeRuntime {
4077 fn refresh_registration(
4078 &mut self,
4079 spec: &ModuleSpec,
4080 runtime: &SupervisorRuntimeConfig,
4081 registry: &Registry,
4082 snapshot: &SharedSnapshot,
4083 ) {
4084 if self.configured_health.as_ref() != Some(&runtime.health) {
4085 self.configured_health = Some(runtime.health.clone());
4086 self.next_probe_at = None;
4087 self.registered_connection = None;
4088 self.probe_index = 0;
4089 }
4090 if running_protocol(spec, snapshot) == ModuleProtocol::None {
4094 self.registered_connection = None;
4095 self.advertised = runtime.health.http.is_some();
4096 if !self.advertised {
4097 self.next_probe_at = None;
4098 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4099 let unknown = ModuleHealthStatus::default();
4100 if state.health != unknown {
4101 state.health = unknown;
4102 }
4103 });
4104 } else if self.next_probe_at.is_none() {
4105 self.next_probe_at = Some(
4106 Instant::now()
4107 + jittered_health_delay(&spec.module_id, 0, runtime.health.cadence),
4108 );
4109 }
4110 return;
4111 }
4112
4113 let registration = match registry.get_module(&spec.module_id) {
4114 Ok(registration) => registration,
4115 Err(err) => {
4116 warn!(module_id = %spec.module_id, error = %err, "health prober could not read registry");
4117 self.advertised = false;
4118 self.next_probe_at = None;
4119 return;
4120 }
4121 };
4122
4123 let Some(registration) = registration else {
4124 self.registered_connection = None;
4125 self.advertised = false;
4126 self.next_probe_at = None;
4127 return;
4128 };
4129
4130 let advertised = registration
4131 .control_ops
4132 .iter()
4133 .any(|op| op == MODULE_CONTROL_OP_HEALTH_CHECK);
4134 if !advertised {
4135 self.registered_connection = Some(registration.connection_id);
4136 self.advertised = false;
4137 self.next_probe_at = None;
4138 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4139 if state.health.status != SupervisorHealthStatus::Unknown
4140 || state.health.consecutive_failures != 0
4141 || state.health.last_probe_ms.is_some()
4142 || state.health.detail.is_some()
4143 || state.health.metrics.is_some()
4144 {
4145 state.health.status = SupervisorHealthStatus::Unknown;
4146 state.health.consecutive_failures = 0;
4147 state.health.last_probe_ms = None;
4148 state.health.detail = None;
4149 state.health.metrics = None;
4150 }
4151 });
4152 return;
4153 }
4154
4155 let reregistered = self.registered_connection != Some(registration.connection_id);
4156 self.registered_connection = Some(registration.connection_id);
4157 self.advertised = true;
4158 if reregistered || self.next_probe_at.is_none() {
4159 self.probe_index = 0;
4160 self.next_probe_at = Some(
4161 Instant::now() + jittered_health_delay(&spec.module_id, 0, runtime.health.cadence),
4162 );
4163 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4164 state.health.status = SupervisorHealthStatus::Unknown;
4165 state.health.consecutive_failures = 0;
4166 state.health.detail = None;
4167 state.health.metrics = None;
4168 });
4169 }
4170 }
4171
4172 fn wake_after(&self) -> Option<Duration> {
4173 if !self.advertised {
4174 return None;
4175 }
4176 self.next_probe_at
4177 .map(|next| next.saturating_duration_since(Instant::now()))
4178 }
4179
4180 fn due(&self) -> bool {
4181 self.advertised
4182 && self
4183 .next_probe_at
4184 .is_some_and(|next| Instant::now() >= next)
4185 }
4186
4187 fn schedule_next(&mut self, spec: &ModuleSpec, cadence: Duration) {
4188 self.probe_index = self.probe_index.wrapping_add(1);
4189 self.next_probe_at = Some(
4190 Instant::now() + jittered_health_delay(&spec.module_id, self.probe_index, cadence),
4191 );
4192 }
4193}
4194
4195#[derive(Debug)]
4230enum HealthProbeEvidence {
4231 LaneDead,
4233 NoAnswer,
4235 BadAnswer,
4237 Misconfigured,
4239}
4240
4241#[derive(Debug)]
4242struct HealthProbeError {
4243 evidence: HealthProbeEvidence,
4244 message: String,
4245}
4246
4247impl HealthProbeError {
4248 fn lane_dead(message: impl Into<String>) -> Self {
4249 Self::with(HealthProbeEvidence::LaneDead, message)
4250 }
4251
4252 fn no_answer(message: impl Into<String>) -> Self {
4253 Self::with(HealthProbeEvidence::NoAnswer, message)
4254 }
4255
4256 fn bad_answer(message: impl Into<String>) -> Self {
4257 Self::with(HealthProbeEvidence::BadAnswer, message)
4258 }
4259
4260 fn misconfigured(message: impl Into<String>) -> Self {
4261 Self::with(HealthProbeEvidence::Misconfigured, message)
4262 }
4263
4264 fn with(evidence: HealthProbeEvidence, message: impl Into<String>) -> Self {
4265 Self {
4266 evidence,
4267 message: message.into(),
4268 }
4269 }
4270
4271 #[allow(dead_code)]
4285 fn is_proof_of_death(&self) -> bool {
4286 matches!(self.evidence, HealthProbeEvidence::LaneDead)
4287 }
4288
4289 fn label(&self) -> &'static str {
4297 match self.evidence {
4298 HealthProbeEvidence::LaneDead => "lane-dead",
4299 HealthProbeEvidence::NoAnswer => "no-answer",
4300 HealthProbeEvidence::BadAnswer => "bad-answer",
4301 HealthProbeEvidence::Misconfigured => "daemon-misconfigured",
4302 }
4303 }
4304}
4305
4306impl fmt::Display for HealthProbeError {
4307 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4308 f.write_str(&self.message)
4309 }
4310}
4311
4312async fn run_health_probe_cycle(
4313 spec: &ModuleSpec,
4314 runtime: &SupervisorRuntimeConfig,
4315 registry: &Registry,
4316 process_liveness: &SupervisorProcessLiveness,
4317 snapshot: &SharedSnapshot,
4318 child: &mut Option<SupervisedChild>,
4319) {
4320 let now_ms = unix_ms_now();
4321 let http = (running_protocol(spec, snapshot) == ModuleProtocol::None)
4322 .then_some(runtime.health.http.as_deref())
4323 .flatten();
4324 let result = match http {
4325 Some(url) => probe_http_health(url, runtime.health.deadline).await,
4326 None => probe_module_health(&spec.module_id, runtime, None).await,
4327 };
4328 match result {
4329 Ok(report) => {
4330 handle_health_report(
4331 spec,
4332 runtime,
4333 registry,
4334 process_liveness,
4335 snapshot,
4336 child,
4337 report,
4338 now_ms,
4339 )
4340 .await;
4341 }
4342 Err(err) => {
4343 if http.is_some() {
4344 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4345 state.health.status = SupervisorHealthStatus::Failing;
4346 });
4347 }
4348 handle_health_probe_failure(
4349 spec,
4350 runtime,
4351 registry,
4352 process_liveness,
4353 snapshot,
4354 child,
4355 err,
4356 now_ms,
4357 )
4358 .await;
4359 }
4360 }
4361}
4362
4363pub(crate) struct HttpProbeTarget<'a> {
4364 address: std::net::SocketAddr,
4365 localhost: bool,
4366 authority: &'a str,
4367 path: String,
4368}
4369
4370pub(crate) fn parse_http_probe_url(url: &str) -> Result<HttpProbeTarget<'_>, String> {
4373 if url.bytes().any(|b| b <= b' ' || b == 127) || url.contains('#') {
4374 return Err("must not contain whitespace, controls, or a fragment".into());
4375 }
4376 let rest = url
4377 .strip_prefix("http://")
4378 .ok_or("must use plain http://")?;
4379 let split = rest.find(['/', '?']).unwrap_or(rest.len());
4380 let (authority, suffix) = rest.split_at(split);
4381 let (host, port) = if let Some(rest) = authority.strip_prefix("[::1]") {
4382 ("::1", rest)
4383 } else {
4384 let split = authority.find(':').unwrap_or(authority.len());
4385 authority.split_at(split)
4386 };
4387 let ip: std::net::IpAddr = match host {
4388 "127.0.0.1" | "localhost" => std::net::Ipv4Addr::LOCALHOST.into(),
4389 "::1" => std::net::Ipv6Addr::LOCALHOST.into(),
4390 _ => return Err("host must be 127.0.0.1, [::1], or localhost".into()),
4391 };
4392 let port = if port.is_empty() {
4393 80
4394 } else {
4395 port.strip_prefix(':')
4396 .and_then(|p| p.parse::<u16>().ok())
4397 .filter(|p| *p > 0)
4398 .ok_or("must have a valid nonzero TCP port")?
4399 };
4400 let path = if suffix.is_empty() {
4401 "/".into()
4402 } else if suffix.starts_with('?') {
4403 format!("/{suffix}")
4404 } else {
4405 suffix.into()
4406 };
4407 Ok(HttpProbeTarget {
4408 address: std::net::SocketAddr::new(ip, port),
4409 localhost: host == "localhost",
4410 authority,
4411 path,
4412 })
4413}
4414
4415async fn probe_http_health(
4416 url: &str,
4417 deadline: Duration,
4418) -> Result<HealthReport, HealthProbeError> {
4419 use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader};
4420 let target = parse_http_probe_url(url).map_err(HealthProbeError::misconfigured)?;
4421 let mut response_status = String::new();
4424 let mut body = Vec::new();
4425 let probe = async {
4426 let connection = match tokio::net::TcpStream::connect(target.address).await {
4429 Err(_) if target.localhost => {
4430 tokio::net::TcpStream::connect((
4431 std::net::Ipv6Addr::LOCALHOST,
4432 target.address.port(),
4433 ))
4434 .await
4435 }
4436 result => result,
4437 };
4438 let mut stream = connection.map_err(|error| {
4439 HealthProbeError::no_answer(format!("HTTP connect failed: {error}"))
4440 })?;
4441 stream.write_all(format!("GET {} HTTP/1.1\r\nHost: {}\r\nAccept-Encoding: identity\r\nConnection: close\r\n\r\n", target.path, target.authority).as_bytes()).await
4442 .map_err(|error| HealthProbeError::no_answer(format!("HTTP write failed: {error}")))?;
4443 let mut reader = BufReader::new(stream);
4444 let mut budget = 16 * 1024;
4445 let status = http_line(&mut reader, &mut budget).await?;
4446 let mut words = status.split_ascii_whitespace();
4447 let version = words.next();
4448 let code = words
4449 .next()
4450 .filter(|word| word.len() == 3)
4451 .and_then(|word| word.parse::<u16>().ok());
4452 if !matches!(version, Some("HTTP/1.1" | "HTTP/1.0"))
4453 || !code.is_some_and(|code| (100..600).contains(&code))
4454 {
4455 return Err(HealthProbeError::bad_answer(format!(
4456 "invalid HTTP status: {status}"
4457 )));
4458 }
4459 let code = code.expect("validated status code");
4460 response_status = status.clone();
4461 let mut length = None;
4462 let mut chunked = false;
4463 loop {
4464 let line = http_line(&mut reader, &mut budget).await?;
4465 if line.is_empty() {
4466 break;
4467 }
4468 if let Some((name, value)) = line.split_once(':') {
4469 if name.eq_ignore_ascii_case("content-length") {
4470 length = Some(value.trim().parse::<u64>().map_err(|_| {
4471 HealthProbeError::bad_answer("invalid HTTP Content-Length")
4472 })?);
4473 } else if name.eq_ignore_ascii_case("transfer-encoding") {
4474 chunked = value.trim().eq_ignore_ascii_case("chunked");
4475 }
4476 }
4477 }
4478 if chunked {
4479 while body.len() < 200 {
4480 let line = http_line(&mut reader, &mut budget).await?;
4481 let size = u64::from_str_radix(line.split(';').next().unwrap_or("").trim(), 16)
4482 .map_err(|_| HealthProbeError::bad_answer("invalid HTTP chunk size"))?;
4483 if size == 0 {
4484 break;
4485 }
4486 let count = size.min((200 - body.len()) as u64) as usize;
4487 let start = body.len();
4488 (&mut reader)
4489 .take(count as u64)
4490 .read_to_end(&mut body)
4491 .await
4492 .map_err(|error| {
4493 HealthProbeError::no_answer(format!("HTTP body read failed: {error}"))
4494 })?;
4495 if body.len() - start != count {
4496 return Err(HealthProbeError::bad_answer("truncated HTTP chunk"));
4497 }
4498 if size > count as u64 || body.len() == 200 {
4499 break;
4500 }
4501 if !http_line(&mut reader, &mut budget).await?.is_empty() {
4502 return Err(HealthProbeError::bad_answer("invalid HTTP chunk delimiter"));
4503 }
4504 }
4505 } else {
4506 reader
4507 .take(length.unwrap_or(200).min(200))
4508 .read_to_end(&mut body)
4509 .await
4510 .map_err(|error| {
4511 HealthProbeError::no_answer(format!("HTTP body read failed: {error}"))
4512 })?;
4513 }
4514 if (200..300).contains(&code) {
4515 Ok(HealthReport::ok())
4516 } else {
4517 Err(HealthProbeError::bad_answer(
4518 "HTTP health endpoint returned non-2xx",
4519 ))
4520 }
4521 };
4522 let mut result = timeout(deadline, probe).await.unwrap_or_else(|_| {
4523 Err(HealthProbeError::no_answer(format!(
4524 "HTTP probe timed out after {deadline:?}"
4525 )))
4526 });
4527 if let Err(error) = &mut result {
4528 if !response_status.is_empty() {
4529 error.message = format!(
4530 "{}; {response_status}: {}",
4531 error.message,
4532 String::from_utf8_lossy(&body)
4533 );
4534 }
4535 }
4536 result
4537}
4538
4539async fn http_line(
4540 reader: &mut tokio::io::BufReader<tokio::net::TcpStream>,
4541 remaining: &mut usize,
4542) -> Result<String, HealthProbeError> {
4543 use tokio::io::{AsyncBufReadExt, AsyncReadExt};
4544 let mut line = Vec::new();
4545 (&mut *reader)
4546 .take(*remaining as u64)
4547 .read_until(b'\n', &mut line)
4548 .await
4549 .map_err(|error| {
4550 HealthProbeError::no_answer(format!("HTTP header read failed: {error}"))
4551 })?;
4552 *remaining -= line.len();
4553 if !line.ends_with(b"\r\n") {
4554 return Err(HealthProbeError::bad_answer(
4555 "HTTP headers are incomplete or exceed 16 KiB",
4556 ));
4557 }
4558 line.truncate(line.len() - 2);
4559 String::from_utf8(line).map_err(|_| HealthProbeError::bad_answer("HTTP header is not UTF-8"))
4560}
4561
4562async fn probe_module_health(
4563 module_id: &str,
4564 runtime: &SupervisorRuntimeConfig,
4565 drain_deadline: Option<Instant>,
4566) -> Result<HealthReport, HealthProbeError> {
4567 let Some(forwarding) = runtime.forwarding.as_ref() else {
4568 return Err(HealthProbeError::misconfigured(
4569 "supervisor was not configured with a forwarding table",
4570 ));
4571 };
4572 let probe_started_at = Instant::now();
4573 let mut deadline = probe_started_at + runtime.health.deadline;
4574 if let Some(drain_deadline) = drain_deadline {
4575 deadline = deadline.min(drain_deadline);
4576 }
4577 let pending = if drain_deadline.is_some() {
4578 forwarding.begin_drain_health_probe_rpc_for(
4579 module_id,
4580 MODULE_CONTROL_OP_HEALTH_CHECK,
4581 probe_started_at,
4582 deadline,
4583 )
4584 } else {
4585 forwarding.begin_health_probe_rpc_for(
4586 module_id,
4587 MODULE_CONTROL_OP_HEALTH_CHECK,
4588 probe_started_at,
4589 deadline,
4590 )
4591 }
4592 .map_err(|err| {
4593 HealthProbeError::lane_dead(format!("failed to begin health.check RPC: {err}"))
4596 })?;
4597 await_health_probe(forwarding, pending, deadline, runtime.health.deadline).await
4598}
4599
4600async fn probe_endpoint_health(
4607 endpoint: crate::ModuleEndpointId,
4608 runtime: &SupervisorRuntimeConfig,
4609 deadline_cap: Option<Instant>,
4610) -> Result<HealthReport, HealthProbeError> {
4611 let Some(forwarding) = runtime.forwarding.as_ref() else {
4612 return Err(HealthProbeError::misconfigured(
4613 "supervisor was not configured with a forwarding table",
4614 ));
4615 };
4616 let probe_started_at = Instant::now();
4617 let mut deadline = probe_started_at + runtime.health.deadline;
4618 if let Some(cap) = deadline_cap {
4619 deadline = deadline.min(cap);
4620 }
4621 let pending = forwarding
4622 .begin_endpoint_health_probe_rpc_for(
4623 endpoint,
4624 MODULE_CONTROL_OP_HEALTH_CHECK,
4625 probe_started_at,
4626 deadline,
4627 )
4628 .map_err(|err| {
4629 HealthProbeError::lane_dead(format!("failed to begin health.check RPC: {err}"))
4630 })?;
4631 await_health_probe(forwarding, pending, deadline, runtime.health.deadline).await
4632}
4633
4634async fn await_health_probe(
4636 forwarding: &ForwardingTable,
4637 pending: PendingModuleControlRpc,
4638 deadline: Instant,
4639 probe_budget: Duration,
4640) -> Result<HealthReport, HealthProbeError> {
4641 let PendingModuleControlRpc {
4642 endpoint,
4643 module_sink,
4644 negotiated_ver,
4645 corr,
4646 receiver,
4647 } = pending;
4648 let body = serde_json::to_vec(&ModuleControlRequest::HealthCheck {}).map_err(|err| {
4649 HealthProbeError::misconfigured(format!("failed to encode health.check: {err}"))
4650 })?;
4651 let frame = Frame::build_with_version(
4652 negotiated_ver,
4653 FrameType::Request,
4654 control_flags(),
4655 0,
4656 0,
4657 corr,
4658 body,
4659 )
4660 .map_err(|err| {
4661 HealthProbeError::misconfigured(format!("failed to build health.check frame: {err}"))
4662 })?;
4663
4664 match timeout_at(deadline, module_sink.send(frame)).await {
4670 Ok(Ok(())) => {}
4671 Ok(Err(err)) => {
4672 let _ = forwarding.cancel_module_control_rpc(endpoint, corr);
4673 return Err(HealthProbeError::lane_dead(format!(
4676 "failed to send health.check: {err}"
4677 )));
4678 }
4679 Err(_elapsed) => {
4680 let _ = forwarding.cancel_module_control_rpc(endpoint, corr);
4681 return Err(HealthProbeError::no_answer(
4685 "health.check send timed out before enqueue (module egress full)",
4686 ));
4687 }
4688 }
4689
4690 match timeout_at(deadline, receiver).await {
4691 Ok(Ok(ModuleControlRpcOutcome::Response(response))) => {
4695 response.health_report().ok_or_else(|| {
4696 HealthProbeError::bad_answer("health.check RPC returned a non-health response")
4697 })
4698 }
4699 Ok(Ok(ModuleControlRpcOutcome::Rejected(body))) => Err(HealthProbeError::bad_answer(
4700 format!("health.check rejected: {}", body.message),
4701 )),
4702 Ok(Ok(ModuleControlRpcOutcome::ModuleGone(message))) => {
4703 Err(HealthProbeError::lane_dead(message))
4704 }
4705 Ok(Ok(ModuleControlRpcOutcome::MalformedResponse(message))) => {
4706 Err(HealthProbeError::bad_answer(message))
4707 }
4708 Ok(Ok(ModuleControlRpcOutcome::UnexpectedOp { expected, actual })) => {
4709 Err(HealthProbeError::bad_answer(format!(
4710 "expected module-control op '{expected}', got '{actual}'"
4711 )))
4712 }
4713 Ok(Ok(ModuleControlRpcOutcome::DeadlineElapsed)) => Err(HealthProbeError::bad_answer(
4717 "module answered health.check after its daemon deadline",
4718 )),
4719 Ok(Err(_)) => Err(HealthProbeError::misconfigured(
4720 "health.check waiter was canceled before the module responded",
4721 )),
4722 Err(_) => {
4723 let _ = forwarding.tombstone_health_probe_rpc(endpoint, corr);
4724 Err(HealthProbeError::no_answer(format!(
4725 "module did not answer health.check within {probe_budget:?}"
4726 )))
4727 }
4728 }
4729}
4730
4731#[allow(clippy::too_many_arguments)]
4732async fn handle_health_report(
4733 spec: &ModuleSpec,
4734 runtime: &SupervisorRuntimeConfig,
4735 registry: &Registry,
4736 process_liveness: &SupervisorProcessLiveness,
4737 snapshot: &SharedSnapshot,
4738 child: &mut Option<SupervisedChild>,
4739 report: HealthReport,
4740 now_ms: u64,
4741) {
4742 let status = supervisor_health_status(report.status);
4743 let detail = report.detail.clone();
4744 let metrics = truncate_health_metrics(report.metrics);
4745 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4746 state.health.status = status;
4747 state.health.last_probe_ms = Some(now_ms);
4748 state.health.detail = detail.clone();
4749 state.health.metrics = metrics.clone();
4750 state.health.consecutive_failures = 0;
4751 });
4752
4753 let action = match report.status {
4754 HealthStatus::Ok => return,
4755 HealthStatus::Degraded => runtime.health.on_degraded,
4756 HealthStatus::Failing => runtime.health.on_failing,
4757 };
4758 apply_l3_health_action(
4759 spec,
4760 runtime,
4761 registry,
4762 process_liveness,
4763 snapshot,
4764 child,
4765 status,
4766 detail.as_deref(),
4767 action,
4768 now_ms,
4769 )
4770 .await;
4771}
4772
4773#[allow(clippy::too_many_arguments)]
4774async fn handle_health_probe_failure(
4775 spec: &ModuleSpec,
4776 runtime: &SupervisorRuntimeConfig,
4777 registry: &Registry,
4778 process_liveness: &SupervisorProcessLiveness,
4779 snapshot: &SharedSnapshot,
4780 child: &mut Option<SupervisedChild>,
4781 err: HealthProbeError,
4782 now_ms: u64,
4783) {
4784 let threshold = runtime.health.failure_threshold.max(1);
4785 let mut failures = 0;
4786 let detail = format!("[{}] {err}", err.label());
4791 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4792 if state.spawned_protocol.unwrap_or(spec.protocol) == ModuleProtocol::Subc {
4795 state.health.status = SupervisorHealthStatus::Unknown;
4796 }
4797 state.health.last_probe_ms = Some(now_ms);
4798 state.health.consecutive_failures = state.health.consecutive_failures.saturating_add(1);
4799 state.health.detail = Some(detail.clone());
4800 state.health.metrics = None;
4801 failures = state.health.consecutive_failures;
4802 });
4803
4804 if failures < threshold {
4805 warn!(
4806 module_id = %spec.module_id,
4807 consecutive_failures = failures,
4808 threshold,
4809 evidence = err.label(),
4810 detail = %detail,
4811 "health.check probe failed"
4812 );
4813 return;
4814 }
4815
4816 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4817 state.state = ModuleState::Unresponsive;
4818 state.health.status = SupervisorHealthStatus::Unresponsive;
4819 });
4820 if runtime.health.critical {
4824 error!(
4825 module_id = %spec.module_id,
4826 status = "unresponsive",
4827 evidence = err.label(),
4828 detail = %detail,
4829 "critical module health alert"
4830 );
4831 } else {
4832 warn!(
4833 module_id = %spec.module_id,
4834 status = "unresponsive",
4835 evidence = err.label(),
4836 detail = %detail,
4837 "module health threshold breached"
4838 );
4839 }
4840 if let Err(err) = health_restart_child(
4841 spec,
4842 runtime,
4843 registry,
4844 process_liveness,
4845 snapshot,
4846 child,
4847 SupervisorHealthStatus::Unresponsive,
4848 Some(&detail),
4849 now_ms,
4850 )
4851 .await
4852 {
4853 error!(module_id = %spec.module_id, error = %err, "health-triggered restart failed");
4854 }
4855}
4856
4857#[allow(clippy::too_many_arguments)]
4858async fn apply_l3_health_action(
4859 spec: &ModuleSpec,
4860 runtime: &SupervisorRuntimeConfig,
4861 registry: &Registry,
4862 process_liveness: &SupervisorProcessLiveness,
4863 snapshot: &SharedSnapshot,
4864 child: &mut Option<SupervisedChild>,
4865 status: SupervisorHealthStatus,
4866 detail: Option<&str>,
4867 action: HealthAction,
4868 now_ms: u64,
4869) {
4870 record_health_action(snapshot, &spec.module_id, action.to_string(), now_ms);
4871 match action {
4872 HealthAction::Report => {
4873 info!(
4874 module_id = %spec.module_id,
4875 status = ?status,
4876 detail,
4877 "module reported non-ok health"
4878 );
4879 }
4880 HealthAction::Alert => {
4881 error!(
4882 module_id = %spec.module_id,
4883 status = ?status,
4884 detail,
4885 "module health alert"
4886 );
4887 }
4888 HealthAction::Restart => {
4889 if let Err(err) = health_restart_child(
4890 spec,
4891 runtime,
4892 registry,
4893 process_liveness,
4894 snapshot,
4895 child,
4896 status,
4897 detail,
4898 now_ms,
4899 )
4900 .await
4901 {
4902 error!(module_id = %spec.module_id, error = %err, "health-triggered restart failed");
4903 }
4904 }
4905 }
4906}
4907
4908#[allow(clippy::too_many_arguments)]
4909async fn health_restart_child(
4910 spec: &ModuleSpec,
4911 runtime: &SupervisorRuntimeConfig,
4912 registry: &Registry,
4913 process_liveness: &SupervisorProcessLiveness,
4914 snapshot: &SharedSnapshot,
4915 child: &mut Option<SupervisedChild>,
4916 status: SupervisorHealthStatus,
4917 detail: Option<&str>,
4918 now_ms: u64,
4919) -> Result<(), SuperviseError> {
4920 let (enabled, schedule) = {
4921 let mut state = lock_snapshot(snapshot)?;
4922 let enabled = state.enabled;
4923 let schedule = if enabled {
4924 state.next_crash_restart(&runtime.restart_policy, Instant::now())
4925 } else {
4926 None
4927 };
4928 (enabled, schedule)
4929 };
4930
4931 if !enabled {
4932 return Err(SuperviseError::Disabled {
4933 module_id: spec.module_id.clone(),
4934 });
4935 }
4936
4937 if schedule.is_none() {
4938 record_health_action(snapshot, &spec.module_id, "failed".to_string(), now_ms);
4939 error!(
4940 module_id = %spec.module_id,
4941 status = ?status,
4942 detail,
4943 max_restarts = runtime.restart_policy.max_restarts,
4944 window_secs = runtime.restart_policy.window.as_secs(),
4945 reason = %runtime.restart_policy.budget_exhausted_detail(),
4946 "health restart budget exhausted; marking module failed"
4947 );
4948 let stop_notice = begin_forwarding_drain_if_configured(
4949 spec,
4950 runtime,
4951 registry,
4952 snapshot,
4953 Some(true),
4954 RouteCloseReason::Disable,
4955 )
4956 .await?;
4957 update_snapshot(snapshot, Some(&spec.module_id), |state| {
4958 state.drain_disposition_detail = Some(runtime.restart_policy.budget_exhausted_detail());
4959 })?;
4960 drain_optional_child(
4961 &spec.module_id,
4962 spec.protocol,
4963 stop_notice,
4964 registry,
4965 runtime.forwarding.as_deref(),
4966 snapshot,
4967 &runtime.terminal_ring,
4968 &runtime.spawn_events,
4969 child,
4970 runtime.drain_timeout,
4971 ModuleState::Failed,
4972 Some(true),
4973 )
4974 .await?;
4975 process_liveness.untrack_if_current(&spec.module_id, snapshot);
4976 return Ok(());
4977 }
4978
4979 let schedule = schedule.expect("a health restart must have a crash-restart schedule");
4980 let mut restart_count = 0;
4981 update_snapshot(snapshot, Some(&spec.module_id), |state| {
4982 restart_count = state.crash_restarts.len();
4983 state.state = ModuleState::Unresponsive;
4984 state.health.status = status;
4985 state.health.last_action = Some(HealthAction::Restart.to_string());
4986 state.health.last_action_ms = Some(now_ms);
4987 })?;
4988 warn!(
4989 module_id = %spec.module_id,
4990 status = ?status,
4991 detail,
4992 restart_count,
4993 restart_in_window = schedule.restart_in_window,
4994 delay_ms = schedule.delay.as_millis() as u64,
4995 "health-triggered module restart"
4996 );
4997
4998 let stop_notice = begin_forwarding_drain_if_configured(
4999 spec,
5000 runtime,
5001 registry,
5002 snapshot,
5003 Some(true),
5004 RouteCloseReason::Restart,
5005 )
5006 .await?;
5007 drain_optional_child(
5008 &spec.module_id,
5009 spec.protocol,
5010 stop_notice,
5011 registry,
5012 runtime.forwarding.as_deref(),
5013 snapshot,
5014 &runtime.terminal_ring,
5015 &runtime.spawn_events,
5016 child,
5017 runtime.drain_timeout,
5018 ModuleState::Restarting,
5019 Some(true),
5020 )
5021 .await?;
5022 schedule_respawn(
5023 runtime,
5024 snapshot,
5025 &spec.module_id,
5026 schedule.delay,
5027 RespawnKind::Spawn,
5028 )
5029}
5030
5031fn cancel_deferred_reload(runtime: &SupervisorRuntimeConfig, module_id: &str, reason: &str) {
5032 if let Some(reply) = runtime
5033 .deferred_reload_reply
5034 .lock()
5035 .unwrap_or_else(|p| p.into_inner())
5036 .take()
5037 {
5038 let _ = reply.send(Err(SuperviseError::ReloadFailed {
5039 module_id: module_id.to_string(),
5040 reason: reason.to_string(),
5041 }));
5042 }
5043}
5044
5045fn schedule_respawn(
5046 runtime: &SupervisorRuntimeConfig,
5047 snapshot: &SharedSnapshot,
5048 module_id: &str,
5049 delay: Duration,
5050 kind: RespawnKind,
5051) -> Result<(), SuperviseError> {
5052 cancel_deferred_reload(runtime, module_id, "respawn superseded by another restart");
5053 update_snapshot(snapshot, Some(module_id), |state| {
5054 state.respawn_pending = true
5055 })?;
5056 *runtime
5057 .scheduled_respawn
5058 .lock()
5059 .unwrap_or_else(|p| p.into_inner()) = Some(PendingRespawn {
5060 deadline: Instant::now() + delay,
5061 kind,
5062 });
5063 Ok(())
5064}
5065
5066fn record_health_action(snapshot: &SharedSnapshot, module_id: &str, action: String, now_ms: u64) {
5067 let _ = update_snapshot(snapshot, Some(module_id), |state| {
5068 state.health.last_action = Some(action);
5069 state.health.last_action_ms = Some(now_ms);
5070 });
5071}
5072
5073fn supervisor_health_status(status: HealthStatus) -> SupervisorHealthStatus {
5074 match status {
5075 HealthStatus::Ok => SupervisorHealthStatus::Ok,
5076 HealthStatus::Degraded => SupervisorHealthStatus::Degraded,
5077 HealthStatus::Failing => SupervisorHealthStatus::Failing,
5078 }
5079}
5080
5081fn truncate_health_metrics(metrics: Option<Value>) -> Option<Value> {
5093 let metrics = metrics?;
5094 match serde_json::to_vec(&metrics) {
5095 Ok(encoded) if encoded.len() > MAX_HEALTH_METRICS_BYTES => Some(serde_json::json!({
5096 "truncated": true,
5097 "original_bytes": encoded.len(),
5098 })),
5099 Ok(_) | Err(_) => Some(metrics),
5100 }
5101}
5102
5103fn jittered_health_delay(module_id: &str, probe_index: u64, cadence: Duration) -> Duration {
5109 if cadence.is_zero() {
5110 return Duration::ZERO;
5111 }
5112 let cadence_ms = cadence.as_millis() as u64;
5113 if cadence_ms == 0 {
5129 return cadence;
5130 }
5131 let jitter_span = (cadence_ms / 10).max(1);
5146 let hash = module_id.as_bytes().iter().fold(
5147 probe_index.wrapping_mul(0x9E37_79B9_7F4A_7C15),
5148 |acc, byte| {
5149 acc.wrapping_mul(1099511628211)
5150 .wrapping_add(u64::from(*byte))
5151 },
5152 );
5153 cadence + Duration::from_millis(hash % jitter_span)
5154}
5155
5156#[cfg(test)]
5157mod tests {
5158 use super::*;
5159
5160 #[test]
5161 fn readding_a_module_clears_its_rescan_removal_tombstone() {
5162 let handle = SupervisorHandle::new();
5163 let module_id = "readded-tombstone";
5164 handle.record_rescan_removal(module_id);
5165 assert!(handle.removal_tombstone_age_ms(module_id).is_some());
5166
5167 handle.apply_identity_configuration(&ModuleSpec {
5168 module_id: module_id.to_string(),
5169 program: PathBuf::from("/test/module"),
5170 args: Vec::new(),
5171 env: Vec::new(),
5172 reserved: false,
5173 reserved_prefixes: Vec::new(),
5174 protocol: ModuleProtocol::Subc,
5175 overlap: Default::default(),
5176 });
5177
5178 assert!(
5179 handle.removal_tombstone_age_ms(module_id).is_none(),
5180 "a re-added module must not retain a stale removal tombstone"
5181 );
5182 }
5183
5184 #[derive(Debug, PartialEq, Eq)]
5187 struct OwnerInSpawnWindow {
5188 module_id: String,
5189 configured: bool,
5190 on_roster: bool,
5191 admission_refusal: Option<&'static str>,
5192 }
5193
5194 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5205 async fn a_new_module_is_configured_before_its_first_process_can_register() {
5206 use crate::scopes::ScopeTable;
5207 use subc_protocol::{error_codes, scope::ScopeSelector, Principal};
5208
5209 let dir = cortexkit_test_support::ScratchDir::new("configured-before-spawn");
5210 let stub = |module_id: &str, program: PathBuf| ModuleSpec {
5211 module_id: module_id.to_string(),
5212 program,
5213 args: Vec::new(),
5214 env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
5215 .into_iter()
5216 .map(|key| (key.to_string(), dir.path().display().to_string()))
5217 .chain([("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string())])
5218 .collect(),
5219 reserved: false,
5220 reserved_prefixes: Vec::new(),
5221 protocol: ModuleProtocol::Subc,
5222 overlap: Default::default(),
5223 };
5224 let live = super::terminal_history_tests::fake_aft_stub_path();
5225 let missing = dir.path().join("definitely-missing-module");
5226
5227 let handle = SupervisorHandle::new();
5228 let mut supervisor =
5229 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5230 .with_handle(handle.clone());
5231 let observed = Arc::new(Mutex::new(Vec::<OwnerInSpawnWindow>::new()));
5232 let hook_handle = handle.clone();
5233 let hook_observed = Arc::clone(&observed);
5234 supervisor.test_after_first_spawn = AfterFirstSpawnHook(Some(Arc::new(move |module_id| {
5235 let configured = hook_handle.is_configured(module_id);
5238 let selector = ScopeSelector {
5239 owner: Principal::Reserved {
5240 module_id: module_id.to_string(),
5241 },
5242 scope_ref: "s".to_string(),
5243 scope_epoch: Some(1),
5244 };
5245 let carrier = Principal::Reserved {
5246 module_id: "carrier".to_string(),
5247 };
5248 let admission_refusal = match ScopeTable::new(Vec::<String>::new())
5249 .admit(&carrier, module_id, &selector, configured)
5250 {
5251 Ok(_) => None,
5252 Err(refusal) => Some(refusal.code),
5253 };
5254 hook_observed.lock().unwrap().push(OwnerInSpawnWindow {
5255 module_id: module_id.to_string(),
5256 configured,
5257 on_roster: hook_handle.get(module_id).is_some(),
5258 admission_refusal,
5259 });
5260 })));
5261
5262 let plain = supervisor.spawn(stub("plain", live.clone())).unwrap();
5263 let configured = supervisor
5264 .supervise_configured(stub("configured", live.clone()), true)
5265 .unwrap();
5266 let with_health = supervisor
5267 .supervise_configured_with_health(
5268 stub("with-health", live.clone()),
5269 true,
5270 HealthConfig::default(),
5271 None,
5272 RestartPolicy::default(),
5273 )
5274 .unwrap();
5275 let failed = supervisor
5278 .supervise_configured_with_health(
5279 stub("failed-spawn", missing.clone()),
5280 true,
5281 HealthConfig::default(),
5282 None,
5283 RestartPolicy::default(),
5284 )
5285 .unwrap();
5286 assert!(supervisor.spawn(stub("spawn-error", missing)).is_err());
5289
5290 let expected = [
5291 "plain",
5292 "configured",
5293 "with-health",
5294 "failed-spawn",
5295 "spawn-error",
5296 ]
5297 .into_iter()
5298 .map(|module_id| OwnerInSpawnWindow {
5299 module_id: module_id.to_string(),
5300 configured: true,
5301 on_roster: false,
5302 admission_refusal: Some(error_codes::SCOPE_NOT_SYNCED),
5303 })
5304 .collect::<Vec<_>>();
5305 assert_eq!(*observed.lock().unwrap(), expected);
5306
5307 for module_id in ["plain", "configured", "with-health", "failed-spawn"] {
5308 assert!(
5309 handle.get(module_id).is_some(),
5310 "{module_id} is on the roster"
5311 );
5312 assert!(
5313 handle.is_configured(module_id),
5314 "{module_id} stays configured"
5315 );
5316 }
5317 assert!(handle.get("spawn-error").is_none());
5318 assert!(
5319 !handle.is_configured("spawn-error"),
5320 "a plain spawn that failed must not leave its module marked configured"
5321 );
5322
5323 handle.retire("failed-spawn");
5325 assert!(!handle.is_configured("failed-spawn"));
5326
5327 for module in [plain, configured, with_health] {
5328 module.stop().await.unwrap();
5329 }
5330 drop(failed);
5331 }
5332
5333 fn stale_process_snapshot(state: ModuleState, enabled: bool) -> SharedSnapshot {
5334 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(state, enabled)));
5335 update_snapshot(&snapshot, Some("stale-process-facts"), |snapshot| {
5336 snapshot.process_alive = true;
5337 snapshot.pid = Some(41);
5338 snapshot.spawned_at_ms = Some(42);
5339 snapshot.spawned_from = Some(PathBuf::from("/spawned/module"));
5340 snapshot.spawned_file_identity = Some(SpawnedFileIdentity {
5341 device: 43,
5342 inode: 44,
5343 });
5344 })
5345 .unwrap();
5346 snapshot
5347 }
5348
5349 fn assert_snapshot_process_facts_cleared(snapshot: &SharedSnapshot) {
5350 let snapshot = lock_snapshot(snapshot).unwrap();
5351 assert!(!snapshot.process_alive);
5352 assert_eq!(snapshot.pid, None);
5353 assert_eq!(snapshot.spawned_at_ms, None);
5354 assert_eq!(snapshot.spawned_from, None);
5355 assert_eq!(snapshot.spawned_file_identity, None);
5356 }
5357
5358 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5359 async fn failed_enable_spawn_clears_preexisting_current_process_facts() {
5360 let supervisor =
5361 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5362 let mut runtime = supervisor.runtime_config();
5363 runtime.test_seed_stale_facts_before_enable_spawn = true;
5364 let snapshot = stale_process_snapshot(ModuleState::Disabled, false);
5365 let mut child = None;
5366 let spec = ModuleSpec {
5367 module_id: "failed-enable-clears-facts".to_string(),
5368 program: PathBuf::from("/definitely/missing/failed-enable-module"),
5369 args: Vec::new(),
5370 env: Vec::new(),
5371 reserved: false,
5372 reserved_prefixes: Vec::new(),
5373 protocol: ModuleProtocol::Subc,
5374 overlap: Default::default(),
5375 };
5376
5377 let result = set_child_enabled(
5378 &spec,
5379 &runtime,
5380 &supervisor.registry,
5381 &supervisor.process_liveness,
5382 &snapshot,
5383 &mut child,
5384 true,
5385 )
5386 .await;
5387
5388 assert!(matches!(result, Err(SuperviseError::Spawn { .. })));
5389 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
5390 assert_snapshot_process_facts_cleared(&snapshot);
5391 }
5392
5393 #[tokio::test]
5394 async fn start_revives_stranded_restarting_but_not_pending_backoff() {
5395 let supervisor =
5396 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5397 let runtime = supervisor.runtime_config();
5398 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(
5399 ModuleState::Restarting,
5400 true,
5401 )));
5402 let spec = ModuleSpec {
5403 module_id: "start-stranded-restarting".to_string(),
5404 program: super::terminal_history_tests::fake_aft_stub_path(),
5405 args: Vec::new(),
5406 env: vec![("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string())],
5407 reserved: false,
5408 reserved_prefixes: Vec::new(),
5409 protocol: ModuleProtocol::None,
5410 overlap: Default::default(),
5411 };
5412 let mut child = None;
5413 lock_snapshot(&snapshot).unwrap().respawn_pending = true;
5414 assert!(!super::set_child_enabled(
5415 &spec,
5416 &runtime,
5417 &Registry::default(),
5418 &supervisor.process_liveness,
5419 &snapshot,
5420 &mut child,
5421 true
5422 )
5423 .await
5424 .unwrap());
5425 assert!(child.is_none());
5426 lock_snapshot(&snapshot).unwrap().respawn_pending = false;
5427 assert!(super::set_child_enabled(
5428 &spec,
5429 &runtime,
5430 &Registry::default(),
5431 &supervisor.process_liveness,
5432 &snapshot,
5433 &mut child,
5434 true
5435 )
5436 .await
5437 .unwrap());
5438 assert_eq!(
5439 lock_snapshot(&snapshot).unwrap().state,
5440 ModuleState::Running
5441 );
5442 let mut child = child.unwrap();
5443 child.start_kill().unwrap();
5444 child.wait().await.unwrap();
5445 }
5446
5447 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5448 async fn failed_reload_spawn_clears_current_process_facts() {
5449 let supervisor =
5450 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5451 let mut runtime = supervisor.runtime_config();
5452 runtime.restart_policy = RestartPolicy::new(0, Duration::ZERO);
5453 let snapshot = stale_process_snapshot(ModuleState::Running, true);
5454 let mut child = None;
5455 let spec = ModuleSpec {
5456 module_id: "failed-reload-clears-facts".to_string(),
5457 program: PathBuf::from("/unused/failed-reload-module"),
5458 args: Vec::new(),
5459 env: Vec::new(),
5460 reserved: false,
5461 reserved_prefixes: Vec::new(),
5462 protocol: ModuleProtocol::Subc,
5463 overlap: Default::default(),
5464 };
5465
5466 let result = handle_reload_spawn_failure(
5467 &spec,
5468 &runtime,
5469 &supervisor.process_liveness,
5470 &snapshot,
5471 &mut child,
5472 "forced reload spawn failure".to_string(),
5473 )
5474 .await;
5475
5476 assert!(matches!(result, Err(SuperviseError::ReloadFailed { .. })));
5477 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
5478 assert_snapshot_process_facts_cleared(&snapshot);
5479 }
5480
5481 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5482 async fn dropping_a_module_with_an_active_monitor_clears_current_process_facts() {
5483 let supervisor =
5484 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5485 let snapshot = stale_process_snapshot(ModuleState::Running, true);
5486 let module = supervisor.supervised_module(
5487 ModuleSpec {
5488 module_id: "drop-clears-facts".to_string(),
5489 program: PathBuf::from("/unused/drop-module"),
5490 args: Vec::new(),
5491 env: Vec::new(),
5492 reserved: false,
5493 reserved_prefixes: Vec::new(),
5494 protocol: ModuleProtocol::Subc,
5495 overlap: Default::default(),
5496 },
5497 supervisor.runtime_config(),
5498 Arc::clone(&snapshot),
5499 None,
5500 );
5501 assert!(!module
5502 .inner
5503 .monitor
5504 .lock()
5505 .unwrap()
5506 .as_ref()
5507 .unwrap()
5508 .is_finished());
5509
5510 drop(module);
5511
5512 assert_eq!(
5513 lock_snapshot(&snapshot).unwrap().state,
5514 ModuleState::Stopped
5515 );
5516 assert_snapshot_process_facts_cleared(&snapshot);
5517 }
5518
5519 #[cfg(unix)]
5520 #[tokio::test]
5521 async fn rescan_preserves_running_protocol_until_respawn() {
5522 let dir = cortexkit_test_support::ScratchDir::new("rescan-protocol");
5523 let initial = ModuleSpec {
5524 module_id: "rescan-protocol".into(),
5525 program: PathBuf::from("/bin/sleep"),
5526 args: vec!["60".into()],
5527 env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
5528 .into_iter()
5529 .map(|key| (key.into(), dir.path().to_string_lossy().into_owned()))
5530 .collect(),
5531 reserved: false,
5532 reserved_prefixes: vec![],
5533 protocol: ModuleProtocol::None,
5534 overlap: Default::default(),
5535 };
5536 let supervisor =
5537 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5538 let module = supervisor.spawn(initial.clone()).unwrap();
5539 assert!(module.status().unwrap().live);
5540 let mut next = initial;
5541 next.protocol = ModuleProtocol::Subc;
5542 module
5543 .update_configuration(next.clone(), HealthConfig::default(), None)
5544 .await
5545 .unwrap();
5546 assert!(
5547 module.status().unwrap().live,
5548 "rescan must not require HELLO from the old non-wire process"
5549 );
5550 let runtime = supervisor.runtime_config();
5551 let action = on_child_exit(
5552 &next,
5553 RestartPolicy::default(),
5554 &supervisor.registry,
5555 &module.inner.snapshot,
5556 &runtime.terminal_ring,
5557 &runtime.spawn_events,
5558 &runtime.child_roster,
5559 ExitReport {
5560 kind: ExitKind::Clean,
5561 code: Some(0),
5562 signal: None,
5563 at_ms: unix_ms_now(),
5564 },
5565 )
5566 .await;
5567 assert!(
5568 matches!(action, NextAction::Restart { .. }),
5569 "the old non-wire process's clean exit must restart"
5570 );
5571 module.drain().await.unwrap();
5572 }
5573
5574 #[cfg(unix)]
5575 #[tokio::test]
5576 async fn orphan_identity_matches_path_names_and_shebang_interpreters() {
5577 use std::os::unix::fs::PermissionsExt;
5578 let dir = cortexkit_test_support::ScratchDir::new("spawn-image-identity");
5579 let script = dir.join("module.sh");
5580 std::fs::write(&script, "#!/bin/sh\nwhile :; do sleep 0.1; done\n").unwrap();
5581 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o700)).unwrap();
5582 let record_path = dir.join("live-children.json");
5583 let supervisor =
5584 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5585 .with_live_children_record(&record_path);
5586 for (program, args) in [
5587 (PathBuf::from("sleep"), vec!["60".into()]),
5588 (script, vec![]),
5589 ] {
5590 let spec = ModuleSpec {
5591 module_id: "image-identity".into(),
5592 program: program.clone(),
5593 args,
5594 env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
5595 .into_iter()
5596 .map(|key| (key.into(), dir.to_string_lossy().into_owned()))
5597 .collect(),
5598 reserved: false,
5599 reserved_prefixes: vec![],
5600 protocol: ModuleProtocol::None,
5601 overlap: Default::default(),
5602 };
5603 let module = supervisor.spawn(spec).unwrap();
5604 #[cfg(target_os = "macos")]
5605 {
5606 let deadline = Instant::now() + Duration::from_secs(5);
5609 while crate::live_children::read_record(&record_path)
5610 .unwrap()
5611 .iter()
5612 .all(|entry| entry.executable.is_none())
5613 {
5614 assert!(Instant::now() < deadline, "module image was not confirmed");
5615 tokio::time::sleep(Duration::from_millis(5)).await;
5616 }
5617 }
5618 let entry = crate::live_children::read_record(&record_path)
5619 .unwrap()
5620 .pop()
5621 .unwrap();
5622 let observed = subc_os::Process::open(entry.pid)
5623 .unwrap()
5624 .unwrap()
5625 .observe()
5626 .unwrap();
5627 let verdict = crate::live_children::identity_verdict(&entry, entry.pid, &observed);
5628 module.drain().await.unwrap();
5629 assert_eq!(
5630 verdict,
5631 crate::live_children::IdentityVerdict::Matches,
5632 "program {program:?}: recorded {entry:?}, observed {observed:?}"
5633 );
5634 }
5635 }
5636
5637 #[cfg(unix)]
5638 fn http_fixture(
5639 dir: &std::path::Path,
5640 url: &str,
5641 threshold: u32,
5642 ) -> crate::daemon_config::ConfiguredModule {
5643 let path = dir.join("subc.jsonc");
5644 std::fs::write(&path, serde_json::json!({"version": 1, "modules": {"http-process": {
5645 "program": "/bin/sleep", "args": ["60"], "protocol": "none",
5646 "env": {"XDG_DATA_HOME":dir,"XDG_RUNTIME_DIR":dir,"XDG_CONFIG_HOME":dir},
5647 "health": {"http":url,"cadence_ms":20,"deadline_ms":200,"failure_threshold":threshold},
5648 "restart": {"backoff_ms":1,"max_backoff_ms":1}, "drain_timeout_ms":10
5649 }}}).to_string()).unwrap();
5650 crate::daemon_config::load(&path)
5651 .unwrap()
5652 .unwrap()
5653 .modules
5654 .pop()
5655 .unwrap()
5656 }
5657
5658 #[cfg(unix)]
5659 async fn wait_http_health(module: &SupervisedModule, status: SupervisorHealthStatus) {
5660 timeout(Duration::from_secs(5), async {
5661 loop {
5662 if module.status().unwrap().health.status == status {
5663 break;
5664 }
5665 sleep(Duration::from_millis(5)).await;
5666 }
5667 })
5668 .await
5669 .unwrap_or_else(|_| {
5670 panic!(
5671 "expected {status:?}, got {:?}",
5672 module.status().unwrap().health
5673 )
5674 });
5675 }
5676
5677 #[cfg(unix)]
5678 #[tokio::test]
5679 async fn http_health_status_flips_ok_failing_ok() {
5680 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5681 let dir = cortexkit_test_support::ScratchDir::new("http-status-flips");
5682 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5683 let status = Arc::new(std::sync::atomic::AtomicU16::new(200));
5684 let serving_status = status.clone();
5685 let url = format!("http://{}/healthz", listener.local_addr().unwrap());
5686 let server = tokio::spawn(async move {
5687 loop {
5688 let (mut stream, _) = listener.accept().await.unwrap();
5689 let mut request = [0u8; 2048];
5690 let count = stream.read(&mut request).await.unwrap();
5691 assert!(count > 0, "a probe must send an HTTP request");
5692 let code = serving_status.load(std::sync::atomic::Ordering::SeqCst);
5693 let body = if code == 200 {
5694 "ready"
5695 } else {
5696 "{\"status\":\"unavailable\",\"error\":\"scratch failure\"}"
5697 };
5698 let response = format!("HTTP/1.1 {code} scratch\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());
5699 let _ = stream.write_all(response.as_bytes()).await;
5700 }
5701 });
5702 let configured = http_fixture(&dir, &url, 1000);
5703 let module =
5704 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5705 .supervise_configured_with_health(
5706 configured.module_spec(),
5707 true,
5708 configured.health,
5709 configured.drain_timeout_ms,
5710 configured.restart,
5711 )
5712 .unwrap();
5713 wait_http_health(&module, SupervisorHealthStatus::Ok).await;
5714 status.store(503, std::sync::atomic::Ordering::SeqCst);
5715 wait_http_health(&module, SupervisorHealthStatus::Failing).await;
5716 assert!(module
5717 .status()
5718 .unwrap()
5719 .health
5720 .detail
5721 .unwrap()
5722 .contains("scratch failure"));
5723 status.store(200, std::sync::atomic::Ordering::SeqCst);
5724 wait_http_health(&module, SupervisorHealthStatus::Ok).await;
5725 assert_eq!(module.status().unwrap().health.consecutive_failures, 0);
5726 let before = module.status().unwrap();
5727 let (spec, mut health) = module.configuration().unwrap();
5728 health.http = None;
5729 module
5730 .update_configuration(spec.clone(), health.clone(), Some(10))
5731 .await
5732 .unwrap();
5733 wait_http_health(&module, SupervisorHealthStatus::Unknown).await;
5734 health.http = Some(url);
5735 module
5736 .update_configuration(spec, health, Some(10))
5737 .await
5738 .unwrap();
5739 wait_http_health(&module, SupervisorHealthStatus::Ok).await;
5740 assert_eq!(
5741 module.status().unwrap().pid,
5742 before.pid,
5743 "changing a probe must apply live, not restart its process"
5744 );
5745 let (spec, mut health) = module.configuration().unwrap();
5746 health.failure_threshold = 2;
5747 module
5748 .update_configuration(spec, health, Some(10))
5749 .await
5750 .unwrap();
5751 status.store(503, std::sync::atomic::Ordering::SeqCst);
5752 timeout(Duration::from_secs(5), async {
5753 while module.status().unwrap().spawn_generation == before.spawn_generation {
5754 sleep(Duration::from_millis(5)).await;
5755 }
5756 })
5757 .await
5758 .expect("sustained HTTP 503 responses must trigger the health restart policy");
5759 module.drain().await.unwrap();
5760 server.abort();
5761 }
5762
5763 #[cfg(unix)]
5764 #[tokio::test]
5765 async fn http_health_no_listener_restarts_after_consecutive_failures() {
5766 let dir = cortexkit_test_support::ScratchDir::new("http-refused");
5767 let unused = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5768 let url = format!("http://{}/healthz", unused.local_addr().unwrap());
5769 drop(unused);
5770 let configured = http_fixture(&dir, &url, 2);
5771 let module =
5772 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5773 .supervise_configured_with_health(
5774 configured.module_spec(),
5775 true,
5776 configured.health,
5777 configured.drain_timeout_ms,
5778 configured.restart,
5779 )
5780 .unwrap();
5781 let before = module.status().unwrap().spawn_generation;
5782 timeout(Duration::from_secs(5), async {
5783 loop {
5784 let status = module.status().unwrap();
5785 if status.spawn_generation > before {
5786 assert!(status.lifetime_restarts > 0);
5787 break;
5788 }
5789 sleep(Duration::from_millis(5)).await;
5790 }
5791 })
5792 .await
5793 .expect("sustained HTTP refusal must trigger the health restart policy");
5794 module.drain().await.unwrap();
5795 }
5796
5797 #[cfg(unix)]
5798 #[tokio::test]
5799 async fn http_health_timeout_honours_deadline() {
5800 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5801 let url = format!("http://{}/healthz", listener.local_addr().unwrap());
5802 let server = tokio::spawn(async move {
5803 let _held = listener.accept().await.unwrap();
5804 std::future::pending::<()>().await;
5805 });
5806 let error = timeout(
5807 Duration::from_secs(1),
5808 probe_http_health(&url, Duration::from_millis(10)),
5809 )
5810 .await
5811 .expect("the probe must enforce its own deadline")
5812 .unwrap_err();
5813 server.abort();
5814 assert!(error.to_string().contains("timed out"));
5815 }
5816
5817 #[cfg(unix)]
5818 #[tokio::test]
5819 async fn http_health_localhost_reaches_an_ipv6_only_listener() {
5820 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5821 let listener = tokio::net::TcpListener::bind("[::1]:0").await.unwrap();
5822 let url = format!(
5823 "http://localhost:{}/healthz",
5824 listener.local_addr().unwrap().port()
5825 );
5826 let server = tokio::spawn(async move {
5827 let (mut stream, _) = listener.accept().await.unwrap();
5828 let mut request = [0u8; 2048];
5829 assert!(stream.read(&mut request).await.unwrap() > 0);
5830 stream
5831 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
5832 .await
5833 .unwrap();
5834 });
5835 assert_eq!(
5840 probe_http_health(&url, Duration::from_secs(10))
5841 .await
5842 .unwrap()
5843 .status,
5844 HealthStatus::Ok
5845 );
5846 server.await.unwrap();
5847 }
5848
5849 #[cfg(unix)]
5850 #[tokio::test]
5851 async fn http_health_timeout_keeps_partial_status_and_body() {
5852 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5853 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5854 let url = format!("http://{}/healthz", listener.local_addr().unwrap());
5855 let server = tokio::spawn(async move {
5856 let (mut stream, _) = listener.accept().await.unwrap();
5857 let mut request = [0u8; 2048];
5858 assert!(stream.read(&mut request).await.unwrap() > 0);
5859 stream
5860 .write_all(
5861 b"HTTP/1.1 503 Unavailable\r\nContent-Length: 100\r\n\r\npartial diagnostic",
5862 )
5863 .await
5864 .unwrap();
5865 std::future::pending::<()>().await;
5866 });
5867 let error = probe_http_health(&url, Duration::from_secs(1))
5868 .await
5869 .unwrap_err()
5870 .to_string();
5871 server.abort();
5872 assert!(
5873 error.contains("timed out")
5874 && error.contains("503 Unavailable")
5875 && error.contains("partial diagnostic"),
5876 "{error}"
5877 );
5878 }
5879
5880 #[cfg(unix)]
5881 #[tokio::test]
5882 async fn http_health_diagnostic_body_is_bounded_to_200_bytes() {
5883 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5884 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5885 let url = format!("http://{}/healthz", listener.local_addr().unwrap());
5886 let server = tokio::spawn(async move {
5887 let (mut stream, _) = listener.accept().await.unwrap();
5888 let mut request = [0u8; 2048];
5889 assert!(stream.read(&mut request).await.unwrap() > 0);
5890 let body = format!("{}not-in-diagnostic", "x".repeat(400));
5891 let response = format!(
5892 "HTTP/1.1 503 Unavailable\r\nContent-Length: {}\r\n\r\n{body}",
5893 body.len()
5894 );
5895 stream.write_all(response.as_bytes()).await.unwrap();
5896 });
5897 let error = probe_http_health(&url, Duration::from_secs(1))
5898 .await
5899 .unwrap_err()
5900 .to_string();
5901 server.await.unwrap();
5902 assert!(error.ends_with(&"x".repeat(200)), "{error}");
5903 assert_eq!(error.split(": ").last().unwrap().len(), 200);
5904 assert!(!error.contains("not-in-diagnostic"));
5905 }
5906
5907 #[cfg(unix)]
5908 #[tokio::test]
5909 async fn http_health_real_nats_server_monitoring() {
5910 if std::process::Command::new("nats-server")
5911 .arg("--version")
5912 .env("XDG_DATA_HOME", std::env::temp_dir())
5913 .env("XDG_RUNTIME_DIR", std::env::temp_dir())
5914 .env("XDG_CONFIG_HOME", std::env::temp_dir())
5915 .output()
5916 .is_err()
5917 {
5918 eprintln!(
5919 "skipped: http_health_real_nats_server_monitoring (nats-server is not on PATH)"
5920 );
5921 return;
5922 }
5923 let dir = cortexkit_test_support::ScratchDir::new("nats-http-monitoring");
5924 let monitor = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5925 let port = monitor.local_addr().unwrap().port();
5926 let client = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5927 let client_port = client.local_addr().unwrap().port();
5928 let config = dir.join("server.conf");
5929 std::fs::write(&config, format!("listen: 127.0.0.1:{client_port}\nhttp: 127.0.0.1:{port}\njetstream {{ store_dir: \"{}\" }}\n", dir.join("jetstream").display())).unwrap();
5930 let mut configured = http_fixture(&dir, &format!("http://127.0.0.1:{port}/healthz"), 1000);
5931 configured.program = PathBuf::from("nats-server");
5932 configured.args = vec!["-c".into(), config.to_string_lossy().into_owned()];
5933 drop(monitor);
5934 drop(client);
5935 let module =
5936 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5937 .supervise_configured_with_health(
5938 configured.module_spec(),
5939 true,
5940 configured.health,
5941 configured.drain_timeout_ms,
5942 configured.restart,
5943 )
5944 .unwrap();
5945 wait_http_health(&module, SupervisorHealthStatus::Ok).await;
5946 module.drain().await.unwrap();
5947 }
5948
5949 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5950 async fn configuration_update_does_not_replace_captured_running_process_facts() {
5951 let supervisor =
5952 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5953 let snapshot = stale_process_snapshot(ModuleState::Running, true);
5954 let initial = ModuleSpec {
5955 module_id: "rescan-preserves-spawn-facts".to_string(),
5956 program: PathBuf::from("/spawned/module"),
5957 args: Vec::new(),
5958 env: Vec::new(),
5959 reserved: false,
5960 reserved_prefixes: Vec::new(),
5961 protocol: ModuleProtocol::Subc,
5962 overlap: Default::default(),
5963 };
5964 let module = supervisor.supervised_module(
5965 initial.clone(),
5966 supervisor.runtime_config(),
5967 snapshot,
5968 None,
5969 );
5970 let before = module.status().unwrap();
5971 let mut replacement = initial;
5972 replacement.program = PathBuf::from("/rescanned/replacement-module");
5973
5974 module
5975 .update_configuration(replacement, HealthConfig::default(), None)
5976 .await
5977 .unwrap();
5978
5979 let after = module.status().unwrap();
5980 assert_eq!(after.pid, before.pid);
5981 assert_eq!(after.spawned_at_ms, before.spawned_at_ms);
5982 assert_eq!(after.spawned_from, before.spawned_from);
5983 drop(module);
5984 }
5985}
5986
5987fn unix_ms_now() -> u64 {
5988 SystemTime::now()
5989 .duration_since(UNIX_EPOCH)
5990 .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64)
5991 .unwrap_or(0)
5992}
5993
5994async fn supervise_loop(
5995 mut spec: ModuleSpec,
5996 mut runtime: SupervisorRuntimeConfig,
5997 registry: Arc<Registry>,
5998 process_liveness: Arc<SupervisorProcessLiveness>,
5999 snapshot: SharedSnapshot,
6000 mut child: Option<SupervisedChild>,
6001 mut commands: mpsc::Receiver<SupervisorCommand>,
6002) {
6003 let mut health_probe = HealthProbeRuntime::default();
6004 let mut registration_changes = match registry.subscribe_module_changes(&spec.module_id) {
6008 Ok(changes) => Some(changes),
6009 Err(err) => {
6010 warn!(module_id = %spec.module_id, error = %err, "health prober could not subscribe to registry");
6013 None
6014 }
6015 };
6016 let mut pending_respawn: Option<PendingRespawn> = None;
6020 let mut requeued: VecDeque<SupervisorCommand> = VecDeque::new();
6023 loop {
6024 #[cfg(test)]
6025 {
6026 lock_snapshot(&snapshot).unwrap().actor_select = None;
6027 }
6028 #[cfg(target_os = "macos")]
6029 if let Some(active) = child.as_mut() {
6030 active.confirm_privacy_exec().await;
6031 }
6032 if let Some(scheduled) = runtime
6033 .scheduled_respawn
6034 .lock()
6035 .unwrap_or_else(|p| p.into_inner())
6036 .take()
6037 {
6038 pending_respawn = Some(scheduled);
6039 }
6040 if pending_respawn.is_some() && (child.is_some() || !respawn_still_pending(&snapshot)) {
6041 pending_respawn = None;
6042 cancel_deferred_reload(
6043 &runtime,
6044 &spec.module_id,
6045 "respawn cancelled by a supervisor command",
6046 );
6047 }
6048 if child.is_none() && pending_respawn.is_none() {
6049 cancel_deferred_reload(
6050 &runtime,
6051 &spec.module_id,
6052 "respawn cancelled before a replacement was spawned",
6053 );
6054 let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| {
6055 state.respawn_pending = false;
6056 state.coalesced_restart_pending = false;
6057 if matches!(
6058 state.state,
6059 ModuleState::Restarting
6060 | ModuleState::Starting
6061 | ModuleState::Draining
6062 | ModuleState::Unresponsive
6063 ) {
6064 error!(module_id = %spec.module_id, state = ?state.state,
6065 "supervision operation ended without a child or pending respawn; marking failed so start can retry");
6066 state.state = ModuleState::Failed;
6067 clear_current_process_facts(state);
6068 }
6069 });
6070 }
6071 if let Some(command) = requeued.pop_front() {
6072 if !handle_supervisor_command(
6073 command,
6074 &mut spec,
6075 &mut runtime,
6076 ®istry,
6077 &process_liveness,
6078 &snapshot,
6079 &mut child,
6080 &mut commands,
6081 &mut requeued,
6082 )
6083 .await
6084 {
6085 return;
6086 }
6087 if child.is_some() || !respawn_still_pending(&snapshot) {
6088 pending_respawn = None;
6089 let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| {
6090 state.respawn_pending = false
6091 });
6092 }
6093 continue;
6094 }
6095 if child.is_some() {
6096 #[cfg(test)]
6097 {
6098 lock_snapshot(&snapshot).unwrap().actor_turns += 1;
6099 }
6100 health_probe.refresh_registration(&spec, &runtime, ®istry, &snapshot);
6101 let wake_after = health_probe.wake_after();
6102 let probe_sleep = async move {
6103 match wake_after {
6104 Some(delay) => sleep(delay).await,
6105 None => std::future::pending().await,
6106 }
6107 };
6108 tokio::pin!(probe_sleep);
6109 let wire_child = running_protocol(&spec, &snapshot) == ModuleProtocol::Subc;
6114 #[cfg(test)]
6115 {
6116 let registration_pending = wire_child
6117 && registration_changes
6118 .as_ref()
6119 .is_some_and(|changes| changes.has_changed().unwrap());
6120 let mut state = lock_snapshot(&snapshot).unwrap();
6121 state.actor_select = (!registration_pending).then_some(ActorSelectCheckpoint {
6122 generation: state.spawn_generation,
6123 turn: state.actor_turns,
6124 registered_connection: health_probe.registered_connection,
6125 next_probe_at: health_probe.next_probe_at,
6126 wake_after,
6127 });
6128 }
6129 let active_child = child.as_mut().expect("child checked above");
6130 tokio::select! {
6131 wait_result = active_child.wait() => {
6132 #[cfg(test)]
6133 { lock_snapshot(&snapshot).unwrap().actor_select = None; }
6134 let exit_report = match wait_result {
6143 Ok(status) => classify_reaped_child_exit(&snapshot, active_child, &status),
6144 Err(err) => {
6145 active_child.drain_stderr(&spec.module_id).await;
6146 fail_snapshot(&snapshot, Some(&spec.module_id), None);
6147 record_wait_error_terminal(
6153 &spec.module_id,
6154 &runtime.terminal_ring,
6155 &runtime.spawn_events,
6156 );
6157 untrack_if_registration_released(
6158 &process_liveness,
6159 ®istry,
6160 &spec.module_id,
6161 &snapshot,
6162 );
6163 error!(module_id = %spec.module_id, error = %err, "failed to wait for supervised module");
6164 child = None;
6165 continue;
6166 }
6167 };
6168 active_child.drain_stderr(&spec.module_id).await;
6169
6170 let next = on_child_exit(
6171 &spec,
6172 runtime.restart_policy,
6173 ®istry,
6174 &snapshot,
6175 &runtime.terminal_ring,
6176 &runtime.spawn_events,
6177 &runtime.child_roster,
6178 exit_report,
6179 ).await;
6180 active_child.release_roster();
6183 match next {
6184 NextAction::Stop { registration_released } => {
6185 if registration_released {
6186 process_liveness.untrack_if_current(&spec.module_id, &snapshot);
6187 }
6188 child = None;
6189 }
6190 NextAction::Restart { schedule } => {
6191 let delay = schedule.map_or(
6192 runtime.restart_policy.delay_for_restart(0),
6193 |schedule| schedule.delay,
6194 );
6195 if let Some(schedule) = schedule {
6196 log_crash_respawn(&spec.module_id, schedule);
6197 }
6198 child = None;
6206 pending_respawn = Some(PendingRespawn { deadline: Instant::now() + delay, kind: RespawnKind::Spawn });
6207 let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| state.respawn_pending = true);
6208 }
6209 }
6210 }
6211 command = commands.recv() => {
6212 #[cfg(test)]
6213 { lock_snapshot(&snapshot).unwrap().actor_select = None; }
6214 let Some(command) = command else {
6215 return;
6216 };
6217 if !handle_supervisor_command(
6218 command,
6219 &mut spec,
6220 &mut runtime,
6221 ®istry,
6222 &process_liveness,
6223 &snapshot,
6224 &mut child,
6225 &mut commands,
6226 &mut requeued,
6227 ).await {
6228 return;
6229 }
6230 }
6231 _ = async {
6232 match registration_changes.as_mut() {
6233 Some(changes) => { let _ = changes.changed().await; }
6234 None => std::future::pending().await,
6235 }
6236 }, if wire_child => {
6237 #[cfg(test)]
6238 { lock_snapshot(&snapshot).unwrap().actor_select = None; }
6239 }
6240 _ = &mut probe_sleep => {
6241 #[cfg(test)]
6242 { lock_snapshot(&snapshot).unwrap().actor_select = None; }
6243 if health_probe.due() {
6244 run_health_probe_cycle(
6245 &spec,
6246 &runtime,
6247 ®istry,
6248 &process_liveness,
6249 &snapshot,
6250 &mut child,
6251 ).await;
6252 if child.is_some() {
6253 health_probe.schedule_next(&spec, runtime.health.cadence);
6254 }
6255 }
6256 }
6257 }
6258 } else if let Some(pending) = pending_respawn {
6259 tokio::select! {
6260 _ = sleep_until(pending.deadline) => {
6261 pending_respawn = None;
6262 let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| state.respawn_pending = false);
6263 if !respawn_still_pending(&snapshot) {
6267 continue;
6268 }
6269 if runtime.child_roster.is_closed() {
6274 let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| {
6275 state.state = ModuleState::Stopped;
6276 });
6277 debug!(module_id = %spec.module_id, "crash respawn cancelled by daemon shutdown");
6278 continue;
6279 }
6280 if let Err(err) = release_dead_registration(
6281 ®istry,
6282 runtime.forwarding.as_deref(),
6283 &snapshot,
6284 &spec.module_id,
6285 ).await {
6286 fail_snapshot(&snapshot, Some(&spec.module_id), None);
6287 error!(module_id = %spec.module_id, error = %err, "registration did not release before restart");
6288 continue;
6289 }
6290
6291 if matches!(pending.kind, RespawnKind::Reload) {
6292 let reply = runtime.deferred_reload_reply.lock().unwrap_or_else(|p| p.into_inner()).take();
6293 let result = finish_reload_child(&spec, &runtime, ®istry, &process_liveness, &snapshot, &mut child).await;
6294 if let Some(reply) = reply { let _ = reply.send(result); }
6295 continue;
6296 }
6297 process_liveness.track(spec.module_id.clone(), Arc::clone(&snapshot));
6298 match spawn_and_mark_running(&spec, &runtime, &snapshot) {
6299 Ok(next_child) => {
6300 child = Some(next_child);
6301 debug!(module_id = %spec.module_id, "supervised module restarted after crash");
6302 }
6303 Err(err) => {
6304 fail_snapshot(&snapshot, Some(&spec.module_id), None);
6305 process_liveness.untrack_if_current(&spec.module_id, &snapshot);
6306 error!(module_id = %spec.module_id, error = %err, "failed to restart supervised module");
6307 }
6308 }
6309 }
6310 command = commands.recv() => {
6311 let Some(command) = command else {
6312 return;
6313 };
6314 if !handle_supervisor_command(
6315 command,
6316 &mut spec,
6317 &mut runtime,
6318 ®istry,
6319 &process_liveness,
6320 &snapshot,
6321 &mut child,
6322 &mut commands,
6323 &mut requeued,
6324 ).await {
6325 return;
6326 }
6327 if child.is_some() || !respawn_still_pending(&snapshot) {
6332 pending_respawn = None;
6333 let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| state.respawn_pending = false);
6334 }
6335 }
6336 }
6337 } else {
6338 let Some(command) = commands.recv().await else {
6339 return;
6340 };
6341 if !handle_supervisor_command(
6342 command,
6343 &mut spec,
6344 &mut runtime,
6345 ®istry,
6346 &process_liveness,
6347 &snapshot,
6348 &mut child,
6349 &mut commands,
6350 &mut requeued,
6351 )
6352 .await
6353 {
6354 return;
6355 }
6356 }
6357 }
6358}
6359
6360fn log_crash_respawn(module_id: &str, schedule: CrashRestartSchedule) {
6361 info!(
6362 module_id,
6363 restart_in_window = schedule.restart_in_window,
6364 delay_ms = schedule.delay.as_millis() as u64,
6365 "respawning after crash"
6366 );
6367}
6368
6369fn respawn_still_pending(snapshot: &SharedSnapshot) -> bool {
6375 matches!(
6376 lock_snapshot(snapshot),
6377 Ok(state) if state.enabled && state.state == ModuleState::Restarting
6378 )
6379}
6380
6381enum NextAction {
6382 Stop {
6383 registration_released: bool,
6384 },
6385 Restart {
6386 schedule: Option<CrashRestartSchedule>,
6387 },
6388}
6389
6390#[allow(clippy::too_many_arguments)]
6391async fn handle_supervisor_command(
6392 command: SupervisorCommand,
6393 spec: &mut ModuleSpec,
6394 runtime: &mut SupervisorRuntimeConfig,
6395 registry: &Arc<Registry>,
6396 process_liveness: &SupervisorProcessLiveness,
6397 snapshot: &SharedSnapshot,
6398 child: &mut Option<SupervisedChild>,
6399 commands: &mut mpsc::Receiver<SupervisorCommand>,
6400 requeued: &mut VecDeque<SupervisorCommand>,
6401) -> bool {
6402 match command {
6403 SupervisorCommand::Drain { reply } => {
6404 let result = drain_optional_child(
6407 &spec.module_id,
6408 spec.protocol,
6409 StopNotice::NotSent,
6410 registry,
6411 runtime.forwarding.as_deref(),
6412 snapshot,
6413 &runtime.terminal_ring,
6414 &runtime.spawn_events,
6415 child,
6416 runtime.drain_timeout,
6417 ModuleState::Stopped,
6418 None,
6419 )
6420 .await;
6421 let registration_released = result.is_ok();
6422 let _ = reply.send(result);
6423 if registration_released {
6424 process_liveness.untrack_if_current(&spec.module_id, snapshot);
6425 }
6426 false
6427 }
6428 SupervisorCommand::Retire { reply } => {
6429 let result = async {
6430 let stop_notice = begin_forwarding_drain_if_configured(
6431 spec,
6432 runtime,
6433 registry,
6434 snapshot,
6435 None,
6436 RouteCloseReason::Disable,
6437 )
6438 .await?;
6439 drain_optional_child(
6440 &spec.module_id,
6441 spec.protocol,
6442 stop_notice,
6443 registry,
6444 runtime.forwarding.as_deref(),
6445 snapshot,
6446 &runtime.terminal_ring,
6447 &runtime.spawn_events,
6448 child,
6449 runtime.drain_timeout,
6450 ModuleState::Stopped,
6451 None,
6452 )
6453 .await
6454 }
6455 .await;
6456 let registration_released = result.is_ok();
6457 let _ = reply.send(result);
6458 if registration_released {
6459 process_liveness.untrack_if_current(&spec.module_id, snapshot);
6460 }
6461 false
6462 }
6463 SupervisorCommand::Restart {
6464 drain_timeout_ms,
6465 received_at_generation,
6466 queued_at,
6467 reply,
6468 } => {
6469 info!(
6473 module_id = %spec.module_id,
6474 queued_ms = u64::try_from(queued_at.elapsed().as_millis()).unwrap_or(u64::MAX),
6475 "restart command dequeued"
6476 );
6477 let validation = match lock_snapshot(snapshot) {
6489 Ok(state) if !state.enabled => Err(SuperviseError::Disabled {
6490 module_id: spec.module_id.clone(),
6491 }),
6492 Ok(_) => Ok(()),
6493 Err(err) => Err(err),
6494 };
6495 let initiated = validation.is_ok();
6496 let _ = reply.send(validation);
6497 let satisfied_by_generation = if initiated && child.is_some() {
6508 lock_snapshot(snapshot).ok().and_then(|state| {
6509 (state.spawn_generation > received_at_generation
6510 && !state.configuration_updated_since_spawn)
6511 .then_some(state.spawn_generation)
6512 })
6513 } else {
6514 None
6515 };
6516 let satisfied_by_pending = initiated
6517 && child.is_none()
6518 && lock_snapshot(snapshot).ok().is_some_and(|mut state| {
6519 let pending = state.respawn_pending && !state.configuration_updated_since_spawn;
6520 if pending {
6521 state.coalesced_restart_pending = true;
6522 }
6523 pending
6524 });
6525 if satisfied_by_pending {
6526 debug!(module_id = %spec.module_id, "restart coalesced into the pending replacement");
6527 } else if let Some(generation) = satisfied_by_generation {
6528 info!(
6529 module_id = %spec.module_id,
6530 received_at_generation,
6531 "restart already satisfied by generation {generation}; not restarting again"
6532 );
6533 } else if initiated {
6534 let drain_timeout = drain_timeout_ms
6537 .map(Duration::from_millis)
6538 .unwrap_or(runtime.drain_timeout);
6539 if let Err(err) = restart_child(
6540 spec,
6541 runtime,
6542 registry,
6543 process_liveness,
6544 snapshot,
6545 child,
6546 drain_timeout,
6547 )
6548 .await
6549 {
6550 warn!(
6551 module_id = %spec.module_id,
6552 error = %err,
6553 "operator restart failed after initiation ack; module state carries the outcome"
6554 );
6555 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
6556 state.state = ModuleState::Failed;
6557 clear_current_process_facts(state);
6558 });
6559 }
6560 }
6561 true
6562 }
6563 SupervisorCommand::Reload { reply } => {
6564 let result =
6565 reload_child(spec, runtime, registry, process_liveness, snapshot, child).await;
6566 if result.is_ok()
6567 && runtime
6568 .scheduled_respawn
6569 .lock()
6570 .unwrap_or_else(|p| p.into_inner())
6571 .as_ref()
6572 .is_some_and(|pending| matches!(pending.kind, RespawnKind::Reload))
6573 {
6574 *runtime
6575 .deferred_reload_reply
6576 .lock()
6577 .unwrap_or_else(|p| p.into_inner()) = Some(reply);
6578 } else {
6579 let _ = reply.send(result);
6580 }
6581 true
6582 }
6583 SupervisorCommand::SetEnabled { enabled, reply } => {
6584 let result = set_child_enabled(
6585 spec,
6586 runtime,
6587 registry,
6588 process_liveness,
6589 snapshot,
6590 child,
6591 enabled,
6592 )
6593 .await;
6594 let _ = reply.send(result);
6595 true
6596 }
6597 SupervisorCommand::UpdateConfiguration {
6598 spec: next_spec,
6599 health,
6600 drain_timeout_ms,
6601 reply,
6602 } => {
6603 if let Some(handle) = &runtime.supervisor_handle {
6604 handle.apply_identity_configuration(&next_spec);
6605 }
6606 *spec = next_spec;
6607 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
6608 state.configuration_updated_since_spawn = true;
6609 });
6610 let health_changed = runtime.health != health;
6611 runtime.health = health;
6612 if health_changed {
6615 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
6616 state.health = ModuleHealthStatus::default();
6617 });
6618 }
6619 runtime.drain_timeout = drain_timeout_ms
6620 .map(Duration::from_millis)
6621 .unwrap_or(runtime.default_drain_timeout);
6622 *runtime
6623 .effective_drain_timeout
6624 .lock()
6625 .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.drain_timeout;
6626 let _ = reply.send(());
6627 true
6628 }
6629 SupervisorCommand::Swap {
6630 ready_timeout,
6631 reply,
6632 } => {
6633 let end = swap::run_swap(
6634 spec,
6635 runtime,
6636 registry,
6637 process_liveness,
6638 snapshot,
6639 child,
6640 commands,
6641 ready_timeout.unwrap_or(DEFAULT_SWAP_READY_TIMEOUT),
6642 reply,
6643 )
6644 .await;
6645 requeued.extend(end.requeue);
6646 true
6647 }
6648 }
6649}
6650
6651async fn restart_child(
6652 spec: &ModuleSpec,
6653 runtime: &SupervisorRuntimeConfig,
6654 registry: &Registry,
6655 process_liveness: &SupervisorProcessLiveness,
6656 snapshot: &SharedSnapshot,
6657 child: &mut Option<SupervisedChild>,
6658 drain_timeout: Duration,
6659) -> Result<(), SuperviseError> {
6660 if !lock_snapshot(snapshot)?.enabled {
6662 return Err(SuperviseError::Disabled {
6663 module_id: spec.module_id.clone(),
6664 });
6665 }
6666 let stop_notice = begin_forwarding_drain_with_timeout(
6667 spec,
6668 runtime,
6669 registry,
6670 snapshot,
6671 None,
6672 RouteCloseReason::Restart,
6673 drain_timeout,
6674 )
6675 .await?;
6676
6677 if child.is_some() {
6678 drain_optional_child(
6679 &spec.module_id,
6680 spec.protocol,
6681 stop_notice,
6682 registry,
6683 runtime.forwarding.as_deref(),
6684 snapshot,
6685 &runtime.terminal_ring,
6686 &runtime.spawn_events,
6687 child,
6688 drain_timeout,
6689 ModuleState::Restarting,
6690 Some(true),
6691 )
6692 .await?;
6693 } else {
6694 update_snapshot(snapshot, Some(&spec.module_id), |state| {
6695 state.enabled = true;
6696 state.state = ModuleState::Restarting;
6697 clear_current_process_facts(state);
6698 })?;
6699 release_dead_registration(
6700 registry,
6701 runtime.forwarding.as_deref(),
6702 snapshot,
6703 &spec.module_id,
6704 )
6705 .await?;
6706 }
6707
6708 reset_restart_count(snapshot, &spec.module_id)?;
6709 process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
6710 schedule_respawn(
6711 runtime,
6712 snapshot,
6713 &spec.module_id,
6714 runtime.restart_policy.backoff,
6715 RespawnKind::Spawn,
6716 )
6717}
6718
6719async fn reload_child(
6720 spec: &ModuleSpec,
6721 runtime: &SupervisorRuntimeConfig,
6722 registry: &Registry,
6723 process_liveness: &SupervisorProcessLiveness,
6724 snapshot: &SharedSnapshot,
6725 child: &mut Option<SupervisedChild>,
6726) -> Result<(), SuperviseError> {
6727 if !lock_snapshot(snapshot)?.enabled {
6729 return Err(SuperviseError::Disabled {
6730 module_id: spec.module_id.clone(),
6731 });
6732 }
6733 let stop_notice = begin_forwarding_drain(
6734 spec,
6735 runtime,
6736 registry,
6737 snapshot,
6738 Some(true),
6739 RouteCloseReason::Reload,
6740 )
6741 .await?;
6742
6743 if child.is_some() {
6744 drain_optional_child(
6745 &spec.module_id,
6746 spec.protocol,
6747 stop_notice,
6748 registry,
6749 runtime.forwarding.as_deref(),
6750 snapshot,
6751 &runtime.terminal_ring,
6752 &runtime.spawn_events,
6753 child,
6754 runtime.drain_timeout,
6755 ModuleState::Restarting,
6756 Some(true),
6757 )
6758 .await?;
6759 } else {
6760 update_snapshot(snapshot, Some(&spec.module_id), |state| {
6761 state.enabled = true;
6762 state.state = ModuleState::Restarting;
6763 clear_current_process_facts(state);
6764 })?;
6765 release_dead_registration(
6766 registry,
6767 runtime.forwarding.as_deref(),
6768 snapshot,
6769 &spec.module_id,
6770 )
6771 .await?;
6772 }
6773
6774 reset_restart_count(snapshot, &spec.module_id)?;
6775 process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
6776 schedule_respawn(
6777 runtime,
6778 snapshot,
6779 &spec.module_id,
6780 runtime.restart_policy.backoff,
6781 RespawnKind::Reload,
6782 )
6783}
6784
6785async fn finish_reload_child(
6786 spec: &ModuleSpec,
6787 runtime: &SupervisorRuntimeConfig,
6788 registry: &Registry,
6789 process_liveness: &SupervisorProcessLiveness,
6790 snapshot: &SharedSnapshot,
6791 child: &mut Option<SupervisedChild>,
6792) -> Result<(), SuperviseError> {
6793 process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
6794 let next_child = match spawn_and_mark_running(spec, runtime, snapshot) {
6795 Ok(next_child) => next_child,
6796 Err(err) => {
6797 return handle_reload_spawn_failure(
6798 spec,
6799 runtime,
6800 process_liveness,
6801 snapshot,
6802 child,
6803 format!("new child failed to spawn: {err}"),
6804 )
6805 .await;
6806 }
6807 };
6808 *child = Some(next_child);
6809
6810 let wait_outcome = {
6811 let active_child = child.as_mut().expect("new reload child was just stored");
6812 wait_for_registration_after_reload(
6813 registry,
6814 &spec.module_id,
6815 snapshot,
6816 active_child,
6817 REGISTRY_RELEASE_TIMEOUT,
6818 )
6819 .await?
6820 };
6821
6822 match wait_outcome {
6823 RegistrationWaitOutcome::Registered => {
6824 debug!(module_id = %spec.module_id, "supervised module reloaded and registered");
6825 Ok(())
6826 }
6827 RegistrationWaitOutcome::Exited(exit_report) => {
6828 if let Some(active_child) = child.as_mut() {
6829 active_child.drain_stderr(&spec.module_id).await;
6830 }
6831 let mut exited_child = child.take().expect("exited reload child is still stored");
6834 #[cfg(test)]
6835 if let Some(gate) = &runtime.test_reload_exit_record_gate {
6836 gate.reached.notify_one();
6837 gate.resume.notified().await;
6838 }
6839 let result = handle_reload_child_registration_failure(
6840 spec,
6841 runtime,
6842 registry,
6843 process_liveness,
6844 snapshot,
6845 child,
6846 ReloadRegistrationFailure {
6847 exit_report: registration_failure_exit_report(exit_report),
6848 reason: exited_child
6849 .spawn_failure
6850 .clone()
6851 .unwrap_or_else(|| "new child exited before registering".to_string()),
6852 },
6853 )
6854 .await;
6855 exited_child.release_roster();
6856 result
6857 }
6858 RegistrationWaitOutcome::TimedOut => {
6859 let mut timed_out_child = child
6860 .take()
6861 .expect("timed-out reload child is still running");
6862 timed_out_child
6863 .start_kill()
6864 .map_err(|source| SuperviseError::Kill {
6865 module_id: spec.module_id.clone(),
6866 source,
6867 })?;
6868 let status = timed_out_child
6869 .wait()
6870 .await
6871 .map_err(|source| SuperviseError::Wait {
6872 module_id: spec.module_id.clone(),
6873 source,
6874 })?;
6875 timed_out_child.drain_stderr(&spec.module_id).await;
6876 handle_reload_child_registration_failure(
6877 spec,
6878 runtime,
6879 registry,
6880 process_liveness,
6881 snapshot,
6882 child,
6883 ReloadRegistrationFailure {
6884 exit_report: registration_failure_exit_report(classify_reaped_child_exit(
6885 snapshot,
6886 &timed_out_child,
6887 &status,
6888 )),
6889 reason: format!(
6890 "new child did not register within {:?}",
6891 REGISTRY_RELEASE_TIMEOUT
6892 ),
6893 },
6894 )
6895 .await
6896 }
6897 }
6898}
6899
6900async fn set_child_enabled(
6901 spec: &ModuleSpec,
6902 runtime: &SupervisorRuntimeConfig,
6903 registry: &Registry,
6904 process_liveness: &SupervisorProcessLiveness,
6905 snapshot: &SharedSnapshot,
6906 child: &mut Option<SupervisedChild>,
6907 enabled: bool,
6908) -> Result<bool, SuperviseError> {
6909 let (current_enabled, current_state, respawn_pending) = {
6910 let state = lock_snapshot(snapshot)?;
6911 (state.enabled, state.state, state.respawn_pending)
6912 };
6913 let revive_terminal = enabled
6921 && current_enabled
6922 && child.is_none()
6923 && (matches!(current_state, ModuleState::Failed | ModuleState::Stopped)
6924 || (current_state == ModuleState::Restarting && !respawn_pending));
6925 if current_enabled == enabled && !revive_terminal {
6926 return Ok(false);
6927 }
6928
6929 if enabled {
6930 update_snapshot(snapshot, Some(&spec.module_id), |state| {
6931 state.enabled = true;
6932 state.state = ModuleState::Starting;
6933 clear_current_process_facts(state);
6934 })?;
6935 #[cfg(test)]
6936 if runtime.test_seed_stale_facts_before_enable_spawn {
6937 update_snapshot(snapshot, Some(&spec.module_id), |state| {
6938 state.process_alive = true;
6939 state.pid = Some(41);
6940 state.spawned_at_ms = Some(42);
6941 state.spawned_from = Some(PathBuf::from("/spawned/module"));
6942 state.spawned_file_identity = Some(SpawnedFileIdentity {
6943 device: 43,
6944 inode: 44,
6945 });
6946 })?;
6947 }
6948 release_dead_registration(
6949 registry,
6950 runtime.forwarding.as_deref(),
6951 snapshot,
6952 &spec.module_id,
6953 )
6954 .await?;
6955 reset_restart_count(snapshot, &spec.module_id)?;
6956 process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
6957 let next_child = match spawn_and_mark_running(spec, runtime, snapshot) {
6958 Ok(next_child) => next_child,
6959 Err(err) => {
6960 if let Err(state_err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
6961 state.state = ModuleState::Failed;
6962 clear_current_process_facts(state);
6963 }) {
6964 error!(module_id = %spec.module_id, error = %state_err, "failed to record enable spawn failure");
6965 }
6966 process_liveness.untrack_if_current(&spec.module_id, snapshot);
6967 return Err(err);
6968 }
6969 };
6970 *child = Some(next_child);
6971 debug!(module_id = %spec.module_id, "supervised module enabled");
6972 Ok(true)
6973 } else {
6974 let stop_notice = begin_forwarding_drain_if_configured(
6975 spec,
6976 runtime,
6977 registry,
6978 snapshot,
6979 Some(false),
6980 RouteCloseReason::Disable,
6981 )
6982 .await?;
6983 drain_optional_child(
6984 &spec.module_id,
6985 spec.protocol,
6986 stop_notice,
6987 registry,
6988 runtime.forwarding.as_deref(),
6989 snapshot,
6990 &runtime.terminal_ring,
6991 &runtime.spawn_events,
6992 child,
6993 runtime.drain_timeout,
6994 ModuleState::Disabled,
6995 Some(false),
6996 )
6997 .await?;
6998 debug!(module_id = %spec.module_id, "supervised module disabled");
6999 Ok(true)
7000 }
7001}
7002
7003#[allow(clippy::too_many_arguments)]
7004async fn on_child_exit(
7005 spec: &ModuleSpec,
7006 policy: RestartPolicy,
7007 registry: &Registry,
7008 snapshot: &SharedSnapshot,
7009 terminal_ring: &Arc<Mutex<TerminalRing>>,
7010 spawn_events: &SpawnEventFeed,
7011 roster: &ChildRoster,
7012 exit_report: ExitReport,
7013) -> NextAction {
7014 if roster.is_closed() {
7020 return on_child_exit_during_daemon_shutdown(
7021 spec,
7022 registry,
7023 snapshot,
7024 terminal_ring,
7025 spawn_events,
7026 exit_report,
7027 )
7028 .await;
7029 }
7030 let unrequested_clean_exit_of_protocol_none = exit_report.kind == ExitKind::Clean
7045 && running_protocol(spec, snapshot) == ModuleProtocol::None;
7046 match exit_report.kind {
7047 ExitKind::Clean if !unrequested_clean_exit_of_protocol_none => {
7048 info!(
7049 module_id = %spec.module_id,
7050 exit_code = ?exit_report.code,
7051 exit_signal = ?exit_report.signal,
7052 "supervised module exited cleanly"
7053 );
7054 if let Err(err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
7055 state.state = ModuleState::Stopped;
7056 clear_current_process_facts(state);
7057 state.last_exit = Some(exit_report.clone());
7058 }) {
7059 error!(module_id = %spec.module_id, error = %err, "failed to record clean module exit");
7060 }
7061 record_terminal(
7062 &spec.module_id,
7063 terminal_ring,
7064 spawn_events,
7065 &exit_report,
7066 TerminalDisposition::Stopped,
7067 );
7068 let registration_released = match wait_for_registration_release(
7069 registry,
7070 &spec.module_id,
7071 REGISTRY_RELEASE_TIMEOUT,
7072 )
7073 .await
7074 {
7075 Ok(()) => true,
7076 Err(err) => {
7077 warn!(module_id = %spec.module_id, error = %err, "registration still active after clean exit");
7078 false
7079 }
7080 };
7081 NextAction::Stop {
7082 registration_released,
7083 }
7084 }
7085 ExitKind::Clean | ExitKind::Crash => {
7086 if unrequested_clean_exit_of_protocol_none {
7087 warn!(
7088 module_id = %spec.module_id,
7089 exit_code = ?exit_report.code,
7090 exit_signal = ?exit_report.signal,
7091 "protocol-none module exited cleanly without a stop request; handling it as a crash"
7092 );
7093 } else {
7094 warn!(
7095 module_id = %spec.module_id,
7096 exit_code = ?exit_report.code,
7097 exit_signal = ?exit_report.signal,
7098 "supervised module exited abnormally (crash)"
7099 );
7100 }
7101 let mut restart_schedule = None;
7102 let mut disposition = TerminalDisposition::Disabled;
7103 let mut disposition_detail = lock_snapshot(snapshot)
7107 .ok()
7108 .and_then(|mut state| state.spawn_failure.take());
7109 let now = Instant::now();
7110 if let Err(err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
7111 clear_current_process_facts(state);
7112 state.last_exit = Some(exit_report.clone());
7113 if state.enabled {
7114 if let Some(schedule) = state.next_crash_restart(&policy, now) {
7115 state.state = ModuleState::Restarting;
7116 restart_schedule = Some(schedule);
7117 disposition = TerminalDisposition::Restarting;
7118 } else {
7119 disposition = TerminalDisposition::Failed;
7120 let budget = policy.budget_exhausted_detail();
7121 disposition_detail =
7122 Some(disposition_detail.take().map_or_else(
7123 || budget.clone(),
7124 |cause| format!("{cause}; {budget}"),
7125 ));
7126 }
7127 } else {
7128 state.state = ModuleState::Disabled;
7129 disposition = TerminalDisposition::Disabled;
7130 }
7131 }) {
7132 error!(module_id = %spec.module_id, error = %err, "failed to record crashed module exit");
7133 return NextAction::Stop {
7134 registration_released: false,
7135 };
7136 }
7137 if disposition == TerminalDisposition::Failed {
7138 error!(
7143 module_id = %spec.module_id,
7144 max_restarts = policy.max_restarts,
7145 window_secs = policy.window.as_secs(),
7146 "module stopped: {}",
7147 policy.budget_exhausted_detail()
7148 );
7149 }
7150 let budget_exhausted = disposition == TerminalDisposition::Failed;
7151 let record_exit = || {
7152 record_terminal_with_detail(
7153 &spec.module_id,
7154 terminal_ring,
7155 spawn_events,
7156 &exit_report,
7157 disposition,
7158 disposition_detail,
7159 );
7160 };
7161 if budget_exhausted {
7162 let recorded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(record_exit));
7174 if let Err(err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
7175 state.state = ModuleState::Failed;
7176 }) {
7177 error!(module_id = %spec.module_id, error = %err, "failed to publish exhausted restart budget");
7178 }
7179 if let Err(panic) = recorded {
7180 std::panic::resume_unwind(panic);
7181 }
7182 } else {
7183 record_exit();
7184 }
7185
7186 if let Some(schedule) = restart_schedule {
7187 NextAction::Restart {
7188 schedule: Some(schedule),
7189 }
7190 } else {
7191 let registration_released = match wait_for_registration_release(
7192 registry,
7193 &spec.module_id,
7194 REGISTRY_RELEASE_TIMEOUT,
7195 )
7196 .await
7197 {
7198 Ok(()) => true,
7199 Err(err) => {
7200 warn!(module_id = %spec.module_id, error = %err, "registration still active after failed module");
7201 false
7202 }
7203 };
7204 NextAction::Stop {
7205 registration_released,
7206 }
7207 }
7208 }
7209 ExitKind::DeliberateSeverance => {
7210 warn!(
7211 module_id = %spec.module_id,
7212 exit_code = ?exit_report.code,
7213 exit_signal = ?exit_report.signal,
7214 "supervised module exited after deliberate connection severance"
7215 );
7216 let mut should_restart = false;
7217 let mut disposition = TerminalDisposition::Disabled;
7218 if let Err(err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
7219 clear_current_process_facts(state);
7220 state.last_exit = Some(exit_report.clone());
7221 state.lifetime_restarts += 1;
7222 if state.enabled {
7223 state.state = ModuleState::Restarting;
7224 should_restart = true;
7225 disposition = TerminalDisposition::Restarting;
7226 } else {
7227 state.state = ModuleState::Disabled;
7228 }
7229 }) {
7230 error!(module_id = %spec.module_id, error = %err, "failed to record deliberately severed module exit");
7231 return NextAction::Stop {
7232 registration_released: false,
7233 };
7234 }
7235 record_terminal(
7236 &spec.module_id,
7237 terminal_ring,
7238 spawn_events,
7239 &exit_report,
7240 disposition,
7241 );
7242
7243 if should_restart {
7244 NextAction::Restart { schedule: None }
7245 } else {
7246 let registration_released = match wait_for_registration_release(
7247 registry,
7248 &spec.module_id,
7249 REGISTRY_RELEASE_TIMEOUT,
7250 )
7251 .await
7252 {
7253 Ok(()) => true,
7254 Err(err) => {
7255 warn!(module_id = %spec.module_id, error = %err, "registration still active after deliberately severed module exit");
7256 false
7257 }
7258 };
7259 NextAction::Stop {
7260 registration_released,
7261 }
7262 }
7263 }
7264 }
7265}
7266
7267async fn on_child_exit_during_daemon_shutdown(
7268 spec: &ModuleSpec,
7269 registry: &Registry,
7270 snapshot: &SharedSnapshot,
7271 terminal_ring: &Arc<Mutex<TerminalRing>>,
7272 spawn_events: &SpawnEventFeed,
7273 exit_report: ExitReport,
7274) -> NextAction {
7275 info!(
7276 module_id = %spec.module_id,
7277 exit_code = ?exit_report.code,
7278 exit_signal = ?exit_report.signal,
7279 exit_kind = ?exit_report.kind,
7280 "supervised module exited during daemon shutdown; not restarting it"
7281 );
7282 if let Err(err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
7283 state.state = ModuleState::Stopped;
7284 clear_current_process_facts(state);
7285 state.last_exit = Some(exit_report.clone());
7286 }) {
7287 error!(module_id = %spec.module_id, error = %err, "failed to record module exit during daemon shutdown");
7288 }
7289 record_terminal(
7290 &spec.module_id,
7291 terminal_ring,
7292 spawn_events,
7293 &exit_report,
7294 TerminalDisposition::DaemonShutdown,
7295 );
7296 let registration_released =
7297 wait_for_registration_release(registry, &spec.module_id, REGISTRY_RELEASE_TIMEOUT)
7298 .await
7299 .is_ok();
7300 NextAction::Stop {
7301 registration_released,
7302 }
7303}
7304
7305fn record_wait_error_terminal(
7306 module_id: &str,
7307 terminal_ring: &Arc<Mutex<TerminalRing>>,
7308 spawn_events: &SpawnEventFeed,
7309) {
7310 record_terminal(
7311 module_id,
7312 terminal_ring,
7313 spawn_events,
7314 &wait_error_exit_report(),
7315 TerminalDisposition::Failed,
7316 );
7317}
7318
7319fn record_terminal(
7320 module_id: &str,
7321 terminal_ring: &Arc<Mutex<TerminalRing>>,
7322 spawn_events: &SpawnEventFeed,
7323 exit_report: &ExitReport,
7324 disposition: TerminalDisposition,
7325) {
7326 record_terminal_with_detail(
7327 module_id,
7328 terminal_ring,
7329 spawn_events,
7330 exit_report,
7331 disposition,
7332 None,
7333 );
7334}
7335
7336fn durable_terminal_history_of(
7340 terminal_ring: &Mutex<TerminalRing>,
7341 module_id: &str,
7342) -> subc_control::TerminalHistory {
7343 let read = terminal_ring
7344 .lock()
7345 .unwrap_or_else(|p| p.into_inner())
7346 .capture_durable_history();
7347 read.read(module_id)
7348}
7349
7350fn record_terminal_with_detail(
7351 module_id: &str,
7352 terminal_ring: &Arc<Mutex<TerminalRing>>,
7353 spawn_events: &SpawnEventFeed,
7354 exit_report: &ExitReport,
7355 disposition: TerminalDisposition,
7356 disposition_detail: Option<String>,
7357) {
7358 spawn_events.emit_exited(module_id, exit_report.code, exit_report.signal);
7359 let record = TerminalRecord {
7360 exit_code: exit_report.code,
7361 exit_signal: exit_report.signal,
7362 at_ms: exit_report.at_ms,
7363 disposition,
7364 exit_kind: exit_report.kind.into(),
7365 disposition_detail,
7366 };
7367 terminal_ring
7368 .lock()
7369 .unwrap_or_else(|poisoned| poisoned.into_inner())
7370 .record_exit(module_id, record);
7371}
7372
7373fn untrack_if_registration_released(
7374 process_liveness: &SupervisorProcessLiveness,
7375 registry: &Registry,
7376 module_id: &str,
7377 snapshot: &SharedSnapshot,
7378) {
7379 match registry.get_module(module_id) {
7380 Ok(None) => process_liveness.untrack_if_current(module_id, snapshot),
7381 Ok(Some(_)) => {}
7382 Err(err) => {
7383 warn!(module_id, error = %err, "could not determine whether supervisor liveness can be untracked");
7384 }
7385 }
7386}
7387
7388#[cfg(test)]
7402fn apply_wire_spawn_args(
7403 command: &mut Command,
7404 spec: &ModuleSpec,
7405 connection_file_path: Option<&std::path::Path>,
7406 handle: Option<&SupervisorHandle>,
7407) -> Result<Option<NonceHandoff>, SuperviseError> {
7408 apply_wire_spawn_args_for_role(
7409 command,
7410 spec,
7411 connection_file_path,
7412 handle,
7413 SpawnRole::Plain,
7414 )
7415}
7416
7417#[cfg(unix)]
7422type NonceHandoff = subc_os::LaunchNonceHandoff;
7423#[cfg(not(unix))]
7424type NonceHandoff = std::convert::Infallible;
7425
7426fn apply_wire_spawn_args_for_role(
7443 command: &mut Command,
7444 spec: &ModuleSpec,
7445 connection_file_path: Option<&std::path::Path>,
7446 handle: Option<&SupervisorHandle>,
7447 role: SpawnRole,
7448) -> Result<Option<NonceHandoff>, SuperviseError> {
7449 command.env(SUBC_MODULE_ID_ENV, &spec.module_id);
7450 command.env_remove(subc_os::LAUNCH_NONCE_FD_ENV);
7455 command.env_remove(SUBC_LAUNCH_NONCE_ENV);
7457 if spec.protocol == ModuleProtocol::None {
7458 return Ok(None);
7459 }
7460 if let Some(connection_file_path) = connection_file_path {
7461 command.arg(SUBC_ARG).arg(connection_file_path);
7462 }
7463
7464 let nonce = generate_launch_nonce()?;
7468 if let Some(handle) = handle {
7469 match role {
7470 SpawnRole::Plain => {
7471 handle.set_spawn_nonce(&spec.module_id, nonce.clone());
7472 if spec.reserved {
7473 handle.set_reserved_nonce(&spec.module_id, nonce.clone());
7474 }
7475 }
7476 SpawnRole::SwapCandidate => handle.open_swap(&spec.module_id, nonce.clone()),
7477 }
7478 }
7479 #[cfg(unix)]
7480 let handoff = {
7481 let handoff =
7482 subc_os::LaunchNonceHandoff::new(&nonce).map_err(|source| SuperviseError::Spawn {
7483 program: spec.program.clone(),
7484 source,
7485 cgroup_path: None,
7486 })?;
7487 command.env(subc_os::LAUNCH_NONCE_FD_ENV, handoff.fd_env_value());
7488 Some(handoff)
7489 };
7490 #[cfg(not(unix))]
7491 let handoff = None;
7492 #[cfg(not(unix))]
7495 command.env(SUBC_LAUNCH_NONCE_ENV, nonce);
7496 Ok(handoff)
7497}
7498
7499fn apply_child_env(command: &mut Command, spec: &ModuleSpec) {
7500 command.env_remove(CK_LOG_ENV);
7501 command.env_remove(SUBC_SPAWN_ROLE_ENV);
7508 for (key, value) in &spec.env {
7509 if matches!(
7513 key.as_str(),
7514 CAPTURE_MAX_FILE_MB_ENV | CAPTURE_KEEP_ENV | CAPTURE_MAX_AGE_DAYS_ENV
7515 ) || key == SUBC_SPAWN_ROLE_ENV
7516 {
7517 continue;
7518 }
7519 command.env(key, value);
7520 }
7521}
7522
7523#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7526enum SpawnRole {
7527 Plain,
7528 SwapCandidate,
7529}
7530
7531fn apply_spawn_role(command: &mut Command, role: SpawnRole) {
7534 if role == SpawnRole::SwapCandidate {
7535 command.env(SUBC_SPAWN_ROLE_ENV, SPAWN_ROLE_SWAP_CANDIDATE);
7536 }
7537}
7538
7539fn spawn_child(
7540 spec: &ModuleSpec,
7541 connection_file_path: Option<&std::path::Path>,
7542 handle: Option<&SupervisorHandle>,
7543 ring: &Arc<Mutex<StderrRing>>,
7544 capture_logs_dir: Option<&std::path::Path>,
7545 roster: &ChildRoster,
7546 #[cfg(target_os = "linux")] cgroup_placement: Option<&subc_cgroup::Placement>,
7547) -> Result<SupervisedChild, SuperviseError> {
7548 spawn_child_in_slot(
7549 spec,
7550 connection_file_path,
7551 handle,
7552 ring,
7553 capture_logs_dir,
7554 roster,
7555 #[cfg(target_os = "linux")]
7556 cgroup_placement,
7557 SpawnRole::Plain,
7558 false,
7559 )
7560}
7561
7562#[allow(clippy::too_many_arguments)]
7575fn spawn_child_in_slot(
7576 spec: &ModuleSpec,
7577 connection_file_path: Option<&std::path::Path>,
7578 handle: Option<&SupervisorHandle>,
7579 ring: &Arc<Mutex<StderrRing>>,
7580 capture_logs_dir: Option<&std::path::Path>,
7581 roster: &ChildRoster,
7582 #[cfg(target_os = "linux")] cgroup_placement: Option<&subc_cgroup::Placement>,
7583 role: SpawnRole,
7584 alternate_slot: bool,
7585) -> Result<SupervisedChild, SuperviseError> {
7586 if roster.is_closed() {
7587 return Err(SuperviseError::Spawn {
7588 program: spec.program.clone(),
7589 source: io::Error::other("the daemon is shutting down; not starting a new process"),
7590 cgroup_path: None,
7591 });
7592 }
7593 #[cfg(target_os = "linux")]
7594 let cgroup_name = {
7595 if cgroup_placement.is_none() {
7600 swap::cgroup_name(&spec.module_id, alternate_slot)
7601 } else {
7602 let nonce = generate_launch_nonce()?;
7603 let suffix = u128::from_str_radix(&nonce[..32], 16).expect("hex launch nonce");
7604 let mut end = spec.module_id.len().min(64);
7607 while !spec.module_id.is_char_boundary(end) {
7608 end -= 1;
7609 }
7610 format!(
7611 "{}_{suffix}",
7612 swap::cgroup_name(&spec.module_id[..end], alternate_slot)
7613 )
7614 }
7615 };
7616 #[cfg(not(target_os = "linux"))]
7617 let _ = alternate_slot;
7618 #[cfg(target_os = "macos")]
7619 let (mut command, privacy_exec, exec_ack) = privacy_command(spec, roster)?;
7620 #[cfg(not(target_os = "macos"))]
7621 let mut command = Command::new(&spec.program);
7622 command.args(&spec.args);
7623 apply_child_env(&mut command, spec);
7653 apply_spawn_role(&mut command, role);
7654 let nonce_handoff =
7655 apply_wire_spawn_args_for_role(&mut command, spec, connection_file_path, handle, role)?;
7656
7657 #[cfg(target_os = "linux")]
7658 let cgroup_path = cgroup_placement
7659 .map(|placement| placement.module_path(&cgroup_name))
7660 .transpose()
7661 .map_err(|source| SuperviseError::Cgroup {
7662 module_id: spec.module_id.clone(),
7663 source,
7664 })?;
7665 #[cfg(not(target_os = "linux"))]
7666 let cgroup_path: Option<PathBuf> = None;
7667 #[cfg(target_os = "linux")]
7668 if let Some(path) = &cgroup_path {
7669 if let Err(error) = apply_cgroup_placement(&mut command, spec, path) {
7670 if let Some(placement) = cgroup_placement {
7671 remove_module_cgroup(placement, &cgroup_name);
7672 }
7673 return Err(error);
7674 }
7675 }
7676
7677 let output_sink = if let Some(logs_dir) = capture_logs_dir {
7678 let path = logs_dir.join(format!("{}.stderr.log", spec.module_id));
7679 match ChildOutputSink::open(&path, capture_retention(spec)) {
7680 Ok(sink) => sink,
7681 Err(error) => {
7682 warn!(
7683 module_id = %spec.module_id,
7684 path = %path.display(),
7685 error = %error,
7686 "could not open child output capture file; forwarding to stderr"
7687 );
7688 ChildOutputSink::Stderr
7689 }
7690 }
7691 } else {
7692 ChildOutputSink::Stderr
7693 };
7694
7695 command.stdout(Stdio::piped());
7696 command.stderr(Stdio::piped());
7697 command.kill_on_drop(true);
7698 #[cfg(unix)]
7715 command.process_group(0);
7716 command.stdin(Stdio::null());
7717 #[cfg(unix)]
7721 if let Some(handoff) = nonce_handoff {
7722 handoff.install_last(command.as_std_mut());
7723 }
7724 #[cfg(not(unix))]
7725 let _ = nonce_handoff;
7726
7727 #[cfg(windows)]
7732 subc_jobobject::suspend_on_create_async(&mut command);
7733 let mut child = match command.spawn() {
7734 Ok(child) => child,
7735 Err(source) => {
7736 #[cfg(target_os = "linux")]
7737 if let Some(placement) = cgroup_placement {
7738 remove_module_cgroup(placement, &cgroup_name);
7739 }
7740 return Err(SuperviseError::Spawn {
7741 program: spec.program.clone(),
7742 source,
7743 cgroup_path,
7744 });
7745 }
7746 };
7747 #[cfg(target_os = "macos")]
7752 drop(exec_ack);
7753
7754 #[cfg(windows)]
7756 let job = contain_spawned_child(&child, spec)?;
7757 let spawned_at_ms = unix_ms_now();
7758 let spawned_from = spec.program.clone();
7759 let spawned_file_identity = spawned_file_identity(&spawned_from);
7760 let pid = child.id().ok_or_else(|| SuperviseError::Spawn {
7761 program: spec.program.clone(),
7762 source: io::Error::other("spawned child exposed no live pid"),
7763 cgroup_path: cgroup_path.clone(),
7764 })?;
7765 let process_start_time = crate::provenance::process_start_time(pid);
7766 let process_identity = process_start_time.map(|start_time| ProcessIdentity { pid, start_time });
7767 #[cfg(all(test, target_os = "macos"))]
7768 privacy_exec_boundary_tests::before_image_sample(spec, pid);
7769 let recorded_image = observe_spawned_image(pid);
7775 #[cfg(target_os = "macos")]
7779 let recorded_image = if privacy_exec.is_some() {
7780 None
7781 } else {
7782 recorded_image
7783 };
7784 #[cfg(target_os = "linux")]
7785 let recorded_cgroup_name = cgroup_path.as_ref().map(|_| cgroup_name.clone());
7786 #[cfg(not(target_os = "linux"))]
7787 let recorded_cgroup_name = None;
7788 let roster_guard = roster.admit(
7789 spec.module_id.clone(),
7790 pid,
7791 spec.protocol,
7792 process_start_time,
7793 crate::child_roster::RecordedIdentity {
7794 start_time: recorded_image.map(|image| image.start_time),
7795 executable: recorded_image
7796 .and_then(|image| image.executable)
7797 .map(crate::live_children::ExecutableIdentity::from),
7798 cgroup_name: recorded_cgroup_name,
7799 #[cfg(target_os = "linux")]
7800 cgroup_placement: cgroup_placement.cloned(),
7801 },
7802 );
7803 if roster.is_closed() {
7812 #[cfg(target_os = "linux")]
7814 kill_module_cgroup(cgroup_placement, &cgroup_name);
7815 if let Err(error) = child.start_kill() {
7816 debug!(module_id = %spec.module_id, pid, %error, "kill of a process spawned during daemon shutdown failed; it may already have exited");
7817 }
7818 #[cfg(target_os = "linux")]
7819 if let Some(placement) = cgroup_placement {
7820 while matches!(child.try_wait(), Ok(None)) {
7824 std::thread::yield_now();
7825 }
7826 if matches!(
7827 subc_cgroup::kill_module(Some(placement), &cgroup_name),
7828 subc_cgroup::KillOutcome::Killed
7829 ) {
7830 if let Ok(path) = placement.module_path(&cgroup_name) {
7831 while std::fs::read_to_string(path.join("cgroup.events"))
7832 .ok()
7833 .is_some_and(|events| events.lines().any(|line| line == "populated 1"))
7834 {
7835 std::thread::yield_now();
7836 }
7837 }
7838 }
7839 remove_module_cgroup(placement, &cgroup_name);
7840 }
7841 drop(roster_guard);
7842 return Err(SuperviseError::Spawn {
7843 program: spec.program.clone(),
7844 source: io::Error::other(
7845 "the daemon began shutting down while this process was starting; ended it",
7846 ),
7847 cgroup_path,
7848 });
7849 }
7850
7851 let stdout_pump = match child.stdout.take() {
7852 Some(stdout) => Some(tokio::spawn(pump_stdout_to(stdout, output_sink.clone()))),
7853 None => {
7854 warn!(
7855 module_id = %spec.module_id,
7856 "spawned child exposed no stdout pipe; file capture will be incomplete"
7857 );
7858 None
7859 }
7860 };
7861 let stderr_pump = match child.stderr.take() {
7862 Some(stderr) => {
7863 let generation = ring
7864 .lock()
7865 .unwrap_or_else(|poisoned| poisoned.into_inner())
7866 .begin_process();
7867 Some(StderrPump {
7868 task: tokio::spawn(pump_stderr_to(
7869 stderr,
7870 Arc::clone(ring),
7871 generation,
7872 output_sink,
7873 )),
7874 generation,
7875 })
7876 }
7877 None => {
7878 ring.lock()
7882 .unwrap_or_else(|poisoned| poisoned.into_inner())
7883 .mark_not_captured("stderr pipe was not available on spawn");
7884 warn!(
7885 module_id = %spec.module_id,
7886 "spawned child exposed no stderr pipe; tail will be unavailable"
7887 );
7888 None
7889 }
7890 };
7891
7892 Ok(SupervisedChild {
7893 child,
7894 protocol: spec.protocol,
7895 #[cfg(target_os = "linux")]
7896 module_id: cgroup_name,
7897 #[cfg(target_os = "linux")]
7898 cgroup_placement: cgroup_placement.cloned(),
7899 #[cfg(windows)]
7900 job,
7901 stdout_pump,
7902 stderr_pump,
7903 stderr_ring: Arc::clone(ring),
7904 spawned_at_ms,
7905 spawned_from,
7906 spawned_file_identity,
7907 process_start_time,
7908 process_identity,
7909 pid,
7910 roster_guard: Some(roster_guard),
7911 #[cfg(target_os = "macos")]
7912 privacy_exec,
7913 #[cfg(target_os = "macos")]
7914 report_ready: Arc::new(OnceLock::new()),
7915 spawn_failure: None,
7916 })
7917}
7918
7919#[cfg(target_os = "linux")]
7920pub(crate) fn kill_module_cgroup(placement: Option<&subc_cgroup::Placement>, module_id: &str) {
7921 use subc_cgroup::KillOutcome;
7922 match subc_cgroup::kill_module(placement, module_id) {
7923 KillOutcome::Killed => {}
7924 KillOutcome::NotPlaced | KillOutcome::Unsupported => {
7925 debug!(
7926 module_id,
7927 "cgroup tree kill unavailable; using direct-child kill"
7928 );
7929 }
7930 KillOutcome::IoError { path, error } => {
7931 warn!(module_id, path = %path.display(), %error, "cgroup tree kill failed; using direct-child kill");
7932 }
7933 }
7934}
7935
7936#[cfg(windows)]
7950fn contain_spawned_child(
7951 child: &Child,
7952 spec: &ModuleSpec,
7953) -> Result<Option<subc_jobobject::JobObject>, SuperviseError> {
7954 let module_id = spec.module_id.as_str();
7955 let Some(pid) = child.id() else {
7956 warn!(
7959 module_id,
7960 "spawned child had already exited before containment; no job object attached"
7961 );
7962 return Ok(None);
7963 };
7964
7965 let job = match subc_jobobject::JobObject::new() {
7966 Ok(job) => job,
7967 Err(source) => {
7968 warn!(
7969 module_id,
7970 error = %source,
7971 "could not create a job object; this module's helper processes will not be \
7972 reaped on teardown"
7973 );
7974 resume_suspended_child(pid, spec)?;
7977 return Ok(None);
7978 }
7979 };
7980
7981 if let Err(source) = job.assign(child) {
7982 warn!(
7983 module_id,
7984 error = %source,
7985 "could not assign the child to its job object; this module's helper processes \
7986 will not be reaped on teardown"
7987 );
7988 resume_suspended_child(pid, spec)?;
7989 return Ok(None);
7990 }
7991
7992 resume_suspended_child(pid, spec)?;
7993 Ok(Some(job))
7994}
7995
7996#[cfg(windows)]
8001fn resume_suspended_child(pid: u32, spec: &ModuleSpec) -> Result<(), SuperviseError> {
8002 if let Err(source) = subc_jobobject::resume_main_thread(pid) {
8003 let _ = std::process::Command::new("taskkill.exe")
8007 .args(["/PID", &pid.to_string(), "/T", "/F"])
8008 .stdin(Stdio::null())
8009 .stdout(Stdio::null())
8010 .stderr(Stdio::null())
8011 .status();
8012 return Err(SuperviseError::Spawn {
8013 program: spec.program.clone(),
8014 source,
8015 cgroup_path: None,
8016 });
8017 }
8018 Ok(())
8019}
8020
8021#[cfg(target_os = "linux")]
8022fn remove_module_cgroup(placement: &subc_cgroup::Placement, module_id: &str) {
8023 match placement.remove_module(module_id) {
8024 Ok(()) => debug!(module_id, "removed module cgroup after process exit"),
8025 Err(error) => warn!(
8026 module_id,
8027 error = %error,
8028 "could not remove module cgroup after process exit; continuing teardown"
8029 ),
8030 }
8031}
8032
8033#[cfg(target_os = "linux")]
8034async fn cleanup_reaped_cgroup(placement: &subc_cgroup::Placement, module_id: &str) {
8035 if matches!(
8039 subc_cgroup::kill_module(Some(placement), module_id),
8040 subc_cgroup::KillOutcome::Killed
8041 ) {
8042 if let Ok(path) = placement.module_path(module_id) {
8043 while std::fs::read_to_string(path.join("cgroup.events"))
8044 .ok()
8045 .is_some_and(|events| events.lines().any(|line| line == "populated 1"))
8046 {
8047 sleep(Duration::from_millis(1)).await;
8048 }
8049 }
8050 }
8051 remove_module_cgroup(placement, module_id);
8052}
8053
8054#[cfg(target_os = "linux")]
8055fn apply_cgroup_placement(
8056 command: &mut Command,
8057 spec: &ModuleSpec,
8058 path: &std::path::Path,
8059) -> Result<(), SuperviseError> {
8060 subc_cgroup::apply(command, path).map_err(|source| SuperviseError::Cgroup {
8061 module_id: spec.module_id.clone(),
8062 source,
8063 })
8064}
8065
8066fn capture_retention(spec: &ModuleSpec) -> Retention {
8067 let defaults = Retention::default();
8068 let value = |name: &str| {
8069 spec.env
8070 .iter()
8071 .rev()
8072 .find_map(|(key, value)| (key == name).then_some(value.as_str()))
8073 };
8074 Retention {
8075 max_file_mb: value(CAPTURE_MAX_FILE_MB_ENV)
8076 .and_then(|value| value.parse().ok())
8077 .unwrap_or(defaults.max_file_mb),
8078 keep: value(CAPTURE_KEEP_ENV)
8079 .and_then(|value| value.parse().ok())
8080 .unwrap_or(defaults.keep),
8081 max_age_days: value(CAPTURE_MAX_AGE_DAYS_ENV)
8082 .and_then(|value| value.parse().ok())
8083 .unwrap_or(defaults.max_age_days),
8084 }
8085}
8086
8087fn generate_launch_nonce() -> Result<String, SuperviseError> {
8090 let mut bytes = [0u8; 32];
8091 getrandom::getrandom(&mut bytes).map_err(|source| SuperviseError::LaunchNonce {
8092 reason: source.to_string(),
8093 })?;
8094 let mut hex = String::with_capacity(64);
8095 for b in bytes {
8096 use std::fmt::Write;
8097 let _ = write!(hex, "{b:02x}");
8098 }
8099 Ok(hex)
8100}
8101
8102fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
8105 if a.len() != b.len() {
8106 return false;
8107 }
8108 let mut diff = 0u8;
8109 for (x, y) in a.iter().zip(b.iter()) {
8110 diff |= x ^ y;
8111 }
8112 diff == 0
8113}
8114
8115fn observe_spawned_image(pid: u32) -> Option<subc_os::Observation> {
8119 subc_os::Process::open(pid)
8120 .ok()
8121 .flatten()
8122 .and_then(|process| process.observe())
8123}
8124
8125fn spawn_and_mark_running(
8126 spec: &ModuleSpec,
8127 runtime: &SupervisorRuntimeConfig,
8128 snapshot: &SharedSnapshot,
8129) -> Result<SupervisedChild, SuperviseError> {
8130 let child = spawn_child(
8131 spec,
8132 runtime.connection_file_path.as_deref(),
8133 runtime.supervisor_handle.as_ref(),
8134 &runtime.stderr_ring,
8135 runtime.capture_logs_dir.as_deref(),
8136 &runtime.child_roster,
8137 #[cfg(target_os = "linux")]
8138 runtime.cgroup_placement.as_ref(),
8139 )?;
8140 set_running(snapshot, &child, &spec.module_id, &runtime.spawn_events)?;
8141 Ok(child)
8142}
8143
8144enum RegistrationWaitOutcome {
8145 Registered,
8146 Exited(ExitReport),
8147 TimedOut,
8148}
8149
8150struct ReloadRegistrationFailure {
8151 exit_report: ExitReport,
8152 reason: String,
8153}
8154
8155#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8156enum BusyGaugeObservation {
8157 Quiescent,
8158 Busy,
8159 Omitted,
8160}
8161
8162fn busy_gauge_observation(metrics: Option<&Value>, gauges: &[String]) -> BusyGaugeObservation {
8163 let Some(metrics) = metrics.and_then(Value::as_object) else {
8164 return BusyGaugeObservation::Omitted;
8165 };
8166 let mut sum = 0u128;
8167 for gauge in gauges {
8168 let Some(value) = metrics.get(gauge) else {
8169 return BusyGaugeObservation::Omitted;
8170 };
8171 let Some(value) = value.as_u64() else {
8172 return BusyGaugeObservation::Busy;
8173 };
8174 sum = sum.saturating_add(u128::from(value));
8175 }
8176 if sum == 0 {
8177 BusyGaugeObservation::Quiescent
8178 } else {
8179 BusyGaugeObservation::Busy
8180 }
8181}
8182
8183fn declared_busy_gauges(
8184 registry: &Registry,
8185 module_id: &str,
8186) -> Result<Vec<String>, SuperviseError> {
8187 busy_gauges_of(
8188 registry
8189 .get_module(module_id)
8190 .map_err(SuperviseError::Registry)?,
8191 )
8192}
8193
8194fn declared_busy_gauges_for_connection(
8198 registry: &Registry,
8199 connection_id: ConnectionId,
8200) -> Result<Vec<String>, SuperviseError> {
8201 busy_gauges_of(
8202 registry
8203 .get_module_by_connection(connection_id)
8204 .map_err(SuperviseError::Registry)?,
8205 )
8206}
8207
8208fn busy_gauges_of(
8209 registration: Option<crate::registry::ModuleRegistration>,
8210) -> Result<Vec<String>, SuperviseError> {
8211 let Some(registration) = registration else {
8212 return Ok(Vec::new());
8213 };
8214 let Some(self_signals) = registration.manifest.self_signals else {
8215 return Ok(Vec::new());
8216 };
8217
8218 let mut gauges = Vec::new();
8219 for declaration in self_signals {
8220 if declaration.kind != SelfSignalKind::Busy {
8221 continue;
8222 }
8223 match declaration.anchored_to {
8224 SignalAnchor::HealthGauges { gauges: declared } if !declared.is_empty() => {
8225 gauges.extend(declared)
8226 }
8227 _ => {
8228 gauges.push(String::new());
8231 }
8232 }
8233 }
8234 Ok(gauges)
8235}
8236
8237async fn wait_for_forwarding_quiescence(
8242 forwarding: &ForwardingTable,
8243 module_id: &str,
8244 runtime: &SupervisorRuntimeConfig,
8245 endpoint: crate::ModuleEndpointId,
8246 deadline: Instant,
8247 busy_gauges: &[String],
8248 scope: DrainScope,
8249) -> Result<bool, SuperviseError> {
8250 let mut gauges_quiescent = busy_gauges.is_empty();
8251 let mut next_probe_at = Instant::now();
8252 let mut omission_counted = false;
8253
8254 loop {
8255 let now = Instant::now();
8256 if !busy_gauges.is_empty() && now >= next_probe_at && now < deadline {
8257 let report = match scope {
8258 DrainScope::Active => probe_module_health(module_id, runtime, Some(deadline)).await,
8259 DrainScope::Endpoint(endpoint) => {
8260 probe_endpoint_health(endpoint, runtime, Some(deadline)).await
8261 }
8262 };
8263 gauges_quiescent = match report {
8264 Ok(report) => match busy_gauge_observation(report.metrics.as_ref(), busy_gauges) {
8265 BusyGaugeObservation::Quiescent => true,
8266 BusyGaugeObservation::Busy => false,
8267 BusyGaugeObservation::Omitted => {
8268 if !omission_counted {
8269 forwarding
8270 .counters()
8271 .increment_drains_with_undeclared_gauge();
8272 omission_counted = true;
8273 }
8274 false
8275 }
8276 },
8277 Err(err) => {
8278 warn!(
8279 module_id,
8280 error = %err,
8281 "drain health.check did not produce declared busy gauges; treating module as busy"
8282 );
8283 false
8284 }
8285 };
8286 next_probe_at = Instant::now() + runtime.health.cadence.max(REGISTRY_RELEASE_POLL);
8287 }
8288
8289 let in_flight = forwarding
8290 .endpoint_in_flight_count(endpoint)
8291 .map_err(SuperviseError::Forwarding)?;
8292 if in_flight == 0 && gauges_quiescent {
8293 return Ok(true);
8294 }
8295
8296 let now = Instant::now();
8297 if now >= deadline {
8298 return Ok(false);
8299 }
8300 let mut wait = deadline
8301 .saturating_duration_since(now)
8302 .min(REGISTRY_RELEASE_POLL);
8303 if !busy_gauges.is_empty() {
8304 wait = wait.min(next_probe_at.saturating_duration_since(now));
8305 }
8306 sleep(wait).await;
8307 }
8308}
8309
8310fn drained_after_quiescence_wait(wait_result: &Result<bool, SuperviseError>) -> bool {
8318 match wait_result {
8319 Ok(drained) => *drained,
8320 Err(_) => false,
8321 }
8322}
8323
8324fn send_route_goodbyes(forwarding: &ForwardingTable, released_routes: Vec<GoodbyeTarget>) {
8325 for released in released_routes {
8326 let frame = match Frame::build_with_version(
8327 released.negotiated_ver,
8328 FrameType::Goodbye,
8329 control_flags(),
8330 released.channel,
8331 released.epoch,
8332 0,
8333 Vec::new(),
8334 ) {
8335 Ok(frame) => frame,
8336 Err(err) => {
8337 warn!(
8338 route_channel = released.channel,
8339 error = %err,
8340 "failed to build supervisor drain route GOODBYE frame"
8341 );
8342 continue;
8343 }
8344 };
8345 if !released.close_on_delivery_failure() {
8346 crate::forwarding::send_module_route_goodbye(
8347 &forwarding.counters(),
8348 &released.sink,
8349 frame,
8350 released.module_id.as_deref(),
8351 "supervisor drain",
8352 );
8353 continue;
8354 }
8355 if let Err(err) = released.sink.try_send(frame) {
8356 warn!(
8357 target_connection_id = released.connection_id.get(),
8358 route_channel = released.channel,
8359 error = %err,
8360 "supervisor drain route GOODBYE was not delivered to client; closing target connection"
8361 );
8362 let _ = forwarding.escalate_client_delivery_failure(
8363 released.connection_id,
8364 released.channel,
8365 released.epoch,
8366 CloseReason::new(
8367 "route_goodbye_delivery_failed",
8368 format!(
8369 "failed to enqueue supervisor drain route GOODBYE for channel {}: {err}",
8370 released.channel
8371 ),
8372 ),
8373 crate::forwarding::UndeliveredFrame {
8374 module_id: released.module_id.as_deref(),
8375 sink: &released.sink,
8376 },
8377 );
8378 }
8379 }
8380}
8381
8382fn send_module_draining(
8383 module_id: &str,
8384 reason: RouteCloseReason,
8385 deadline_ms: u64,
8386 target: &ModuleDrainTarget,
8387) {
8388 let body = match serde_json::to_vec(&ModuleControlCommand::Draining {
8389 reason,
8390 deadline_ms,
8391 }) {
8392 Ok(body) => body,
8393 Err(err) => {
8394 warn!(
8395 module_id,
8396 error = %err,
8397 "failed to encode module draining command"
8398 );
8399 return;
8400 }
8401 };
8402 let frame = match Frame::build_with_version(
8403 target.negotiated_ver,
8404 FrameType::Push,
8405 control_flags(),
8406 0,
8407 0,
8408 0,
8409 body,
8410 ) {
8411 Ok(frame) => frame,
8412 Err(err) => {
8413 warn!(
8414 module_id,
8415 error = %err,
8416 "failed to build module draining command frame"
8417 );
8418 return;
8419 }
8420 };
8421 if let Err(err) = target.sink.try_send(frame) {
8422 warn!(
8423 module_id,
8424 target_connection_id = target.endpoint.connection_id.get(),
8425 error = %err,
8426 "module draining command was not delivered to peer"
8427 );
8428 }
8429}
8430
8431fn module_goodbye_frame(module_id: &str, negotiated_ver: u8) -> Option<Frame> {
8433 match Frame::build_with_version(
8434 negotiated_ver,
8435 FrameType::Goodbye,
8436 control_flags(),
8437 0,
8438 0,
8439 0,
8440 Vec::new(),
8441 ) {
8442 Ok(frame) => Some(frame),
8443 Err(err) => {
8444 warn!(
8445 module_id,
8446 error = %err,
8447 "failed to build module GOODBYE frame"
8448 );
8449 None
8450 }
8451 }
8452}
8453
8454#[cfg(unix)]
8468async fn send_module_goodbyes_for_daemon_shutdown(
8469 forwarding: &Arc<ForwardingTable>,
8470 reason: &CloseReason,
8471 wait_for_flush: bool,
8472) {
8473 const GOODBYE_BUDGET: Duration = Duration::from_millis(500);
8474 let targets = match forwarding.module_connections() {
8475 Ok(targets) => targets,
8476 Err(err) => {
8477 warn!(error = %err, "could not list module connections for shutdown GOODBYE");
8478 return;
8479 }
8480 };
8481 let deadline = Instant::now() + GOODBYE_BUDGET;
8482 let mut sends = tokio::task::JoinSet::new();
8483 for target in targets {
8484 let Some(frame) = module_goodbye_frame(&target.module_id, target.negotiated_ver) else {
8485 continue;
8486 };
8487 if !wait_for_flush {
8488 if let Err(err) = target.sink.try_send(frame) {
8489 debug!(
8490 module_id = %target.module_id,
8491 error = %err,
8492 "shutdown module GOODBYE was not queued"
8493 );
8494 }
8495 continue;
8496 }
8497 let forwarding = Arc::clone(forwarding);
8498 let reason = reason.clone();
8499 sends.spawn(async move {
8500 match timeout_at(deadline, target.sink.send_flushed(frame)).await {
8501 Ok(Ok(())) => {}
8502 Ok(Err(err)) => debug!(
8503 module_id = %target.module_id,
8504 error = %err,
8505 "module connection closed before its shutdown GOODBYE was written"
8506 ),
8507 Err(_) => warn!(
8508 module_id = %target.module_id,
8509 budget = ?GOODBYE_BUDGET,
8510 "shutdown module GOODBYE was not written within its budget; closing anyway"
8511 ),
8512 }
8513 forwarding.request_connection_close(target.endpoint.connection_id, reason);
8514 });
8515 }
8516 while sends.join_next().await.is_some() {}
8518}
8519
8520fn send_module_goodbye(module_id: &str, forwarding: &ForwardingTable, target: &ModuleDrainTarget) {
8521 let Some(frame) = module_goodbye_frame(module_id, target.negotiated_ver) else {
8522 return;
8523 };
8524 if let Err(err) = target.sink.try_send(frame) {
8525 warn!(
8526 module_id,
8527 target_connection_id = target.endpoint.connection_id.get(),
8528 error = %err,
8529 "supervisor drain module GOODBYE was not delivered to peer; closing module connection"
8530 );
8531 forwarding.request_connection_close(
8532 target.endpoint.connection_id,
8533 CloseReason::new(
8534 "module_goodbye_delivery_failed",
8535 format!("failed to enqueue supervisor drain module GOODBYE for module '{module_id}': {err}"),
8536 ),
8537 );
8538 }
8539}
8540
8541#[derive(Clone, Copy)]
8542struct ForwardingDrainContext<'a> {
8543 spec: &'a ModuleSpec,
8544 runtime: &'a SupervisorRuntimeConfig,
8545 registry: &'a Registry,
8546 scope: DrainScope,
8547}
8548
8549#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8551enum DrainScope {
8552 Active,
8555 Endpoint(crate::ModuleEndpointId),
8560}
8561
8562#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8570enum StopNotice {
8571 SentOverConnection,
8574 NoConnection,
8578 NotSent,
8582}
8583
8584async fn begin_forwarding_drain(
8585 spec: &ModuleSpec,
8586 runtime: &SupervisorRuntimeConfig,
8587 registry: &Registry,
8588 snapshot: &SharedSnapshot,
8589 enabled: Option<bool>,
8590 reason: RouteCloseReason,
8591) -> Result<StopNotice, SuperviseError> {
8592 let Some(forwarding) = runtime.forwarding.as_ref() else {
8593 return Err(SuperviseError::ReloadUnavailable {
8594 module_id: spec.module_id.clone(),
8595 reason: "supervisor was not configured with a forwarding table".to_string(),
8596 });
8597 };
8598
8599 begin_forwarding_drain_with(
8600 forwarding,
8601 ForwardingDrainContext {
8602 spec,
8603 runtime,
8604 registry,
8605 scope: DrainScope::Active,
8606 },
8607 snapshot,
8608 enabled,
8609 reason,
8610 runtime.drain_timeout,
8611 )
8612 .await
8613}
8614
8615async fn begin_forwarding_drain_if_configured(
8616 spec: &ModuleSpec,
8617 runtime: &SupervisorRuntimeConfig,
8618 registry: &Registry,
8619 snapshot: &SharedSnapshot,
8620 enabled: Option<bool>,
8621 reason: RouteCloseReason,
8622) -> Result<StopNotice, SuperviseError> {
8623 begin_forwarding_drain_with_timeout(
8624 spec,
8625 runtime,
8626 registry,
8627 snapshot,
8628 enabled,
8629 reason,
8630 runtime.drain_timeout,
8631 )
8632 .await
8633}
8634
8635async fn begin_forwarding_drain_with_timeout(
8639 spec: &ModuleSpec,
8640 runtime: &SupervisorRuntimeConfig,
8641 registry: &Registry,
8642 snapshot: &SharedSnapshot,
8643 enabled: Option<bool>,
8644 reason: RouteCloseReason,
8645 drain_timeout: Duration,
8646) -> Result<StopNotice, SuperviseError> {
8647 let Some(forwarding) = runtime.forwarding.as_ref() else {
8648 return Ok(StopNotice::NotSent);
8649 };
8650
8651 begin_forwarding_drain_with(
8652 forwarding,
8653 ForwardingDrainContext {
8654 spec,
8655 runtime,
8656 registry,
8657 scope: DrainScope::Active,
8658 },
8659 snapshot,
8660 enabled,
8661 reason,
8662 drain_timeout,
8663 )
8664 .await
8665}
8666
8667async fn begin_forwarding_drain_with(
8668 forwarding: &ForwardingTable,
8669 context: ForwardingDrainContext<'_>,
8670 snapshot: &SharedSnapshot,
8671 enabled: Option<bool>,
8672 reason: RouteCloseReason,
8673 drain_timeout: Duration,
8674) -> Result<StopNotice, SuperviseError> {
8675 let ForwardingDrainContext {
8676 spec,
8677 runtime,
8678 registry,
8679 scope,
8680 } = context;
8681 debug_assert_ne!(reason, RouteCloseReason::Crash);
8682 let terminal = matches!(reason, RouteCloseReason::Disable);
8683 let drain_started_at = Instant::now();
8684 let drain_deadline = drain_started_at + drain_timeout;
8685 let deadline_ms =
8686 unix_ms_now().saturating_add(u64::try_from(drain_timeout.as_millis()).unwrap_or(u64::MAX));
8687 let busy_gauges = match scope {
8688 DrainScope::Active => declared_busy_gauges(registry, &spec.module_id)?,
8689 DrainScope::Endpoint(endpoint) => {
8690 declared_busy_gauges_for_connection(registry, endpoint.connection_id)?
8691 }
8692 };
8693
8694 let gate_started = Instant::now();
8697 let drain_target = match scope {
8698 DrainScope::Active => forwarding.begin_module_drain(&spec.module_id, reason),
8699 DrainScope::Endpoint(endpoint) => forwarding.begin_endpoint_drain(endpoint, reason),
8700 }
8701 .map_err(SuperviseError::Forwarding)?;
8702 info!(
8707 module_id = %spec.module_id,
8708 ?reason,
8709 gate_ms = u64::try_from(gate_started.elapsed().as_millis()).unwrap_or(u64::MAX),
8710 connected = drain_target.is_some(),
8711 "module drain began; route admission closed"
8712 );
8713 if scope == DrainScope::Active {
8714 update_snapshot(snapshot, Some(&spec.module_id), |state| {
8715 state.state = ModuleState::Draining;
8716 state.draining_to_replace =
8717 matches!(reason, RouteCloseReason::Restart | RouteCloseReason::Reload);
8718 if let Some(enabled) = enabled {
8719 state.enabled = enabled;
8720 }
8721 })?;
8722 }
8723
8724 let Some(target) = drain_target.as_ref() else {
8725 return Ok(StopNotice::NoConnection);
8729 };
8730 {
8731 send_module_draining(&spec.module_id, reason, deadline_ms, target);
8732 let routes = forwarding
8733 .endpoint_routes(target.endpoint)
8734 .map_err(SuperviseError::Forwarding)?;
8735 let routes_notified = routes.len();
8736 crate::control::send_route_control_pushes(
8737 forwarding,
8738 routes.clone(),
8739 ClientControlPush::RouteClosing {
8740 module_id: spec.module_id.clone(),
8741 channels: Vec::new(),
8742 reason,
8743 },
8744 );
8745 send_route_goodbyes(forwarding, target.abandoned_bindings.clone());
8746
8747 let wait_result = wait_for_forwarding_quiescence(
8753 forwarding,
8754 &spec.module_id,
8755 runtime,
8756 target.endpoint,
8757 drain_deadline,
8758 &busy_gauges,
8759 scope,
8760 )
8761 .await;
8762 let drained = drained_after_quiescence_wait(&wait_result);
8763 if let Err(err) = &wait_result {
8764 error!(
8765 module_id = %spec.module_id,
8766 ?reason,
8767 error = %err,
8768 "forwarding quiescence wait failed after route.closing; forcing route.closed(drained: false) so the client is not left waiting on an unfulfilled promise"
8769 );
8770 } else if !drained {
8771 let holdouts = forwarding
8777 .endpoint_drain_holdouts(target.endpoint)
8778 .unwrap_or_default();
8779 warn!(
8780 module_id = %spec.module_id,
8781 waited = ?drain_timeout,
8782 ?reason,
8783 held_requests = holdouts.requests,
8784 held_routes = holdouts.routes,
8785 total_routes = holdouts.total_routes,
8786 top_connections = ?holdouts.top_connections,
8787 held = %holdouts
8790 .held
8791 .iter()
8792 .map(|(channel, corr)| format!("{channel}:{corr}"))
8793 .collect::<Vec<_>>()
8794 .join(","),
8795 "route drain timed out before request quiescence; forcing teardown"
8796 );
8797 }
8798 crate::control::send_route_control_pushes(
8799 forwarding,
8800 routes,
8801 ClientControlPush::RouteClosed {
8802 module_id: spec.module_id.clone(),
8803 channels: Vec::new(),
8804 reason,
8805 drained,
8806 abandoned: target.abandoned_bindings.len() as u32,
8807 excluded_subscriptions: target.excluded_subscriptions,
8808 terminal: Some(terminal),
8809 },
8810 );
8811 wait_result?;
8812
8813 let released_routes = match forwarding.release_module_endpoint_routes(target.endpoint) {
8819 Ok(routes) => routes,
8820 Err(err) => {
8821 warn!(
8822 module_id = %spec.module_id,
8823 ?reason,
8824 error = %err,
8825 "failed to release module endpoint routes after route.closed; module GOODBYE will still be sent"
8826 );
8827 send_module_goodbye(&spec.module_id, forwarding, target);
8828 return Err(SuperviseError::Forwarding(err));
8829 }
8830 };
8831 let route_goodbye_count = released_routes.len();
8832 send_route_goodbyes(forwarding, released_routes);
8833 send_module_goodbye(&spec.module_id, forwarding, target);
8834
8835 info!(
8841 module_id = %spec.module_id,
8842 ?reason,
8843 routes_notified,
8844 route_goodbyes = route_goodbye_count,
8845 abandoned_reservations = target.abandoned_bindings.len(),
8846 excluded_subscriptions = target.excluded_subscriptions,
8847 drained,
8848 "module drain complete; consumers notified via route.closing/route.closed pushes and per-route GOODBYE frames"
8849 );
8850 }
8851
8852 Ok(StopNotice::SentOverConnection)
8853}
8854
8855async fn wait_for_registration_after_reload(
8858 registry: &Registry,
8859 module_id: &str,
8860 snapshot: &SharedSnapshot,
8861 child: &mut SupervisedChild,
8862 wait: Duration,
8863) -> Result<RegistrationWaitOutcome, SuperviseError> {
8864 wait_for_slot_registration(
8865 registry,
8866 crate::registry::RegistrationSlot::Active(module_id),
8867 module_id,
8868 snapshot,
8869 child,
8870 wait,
8871 )
8872 .await
8873}
8874
8875async fn wait_for_slot_registration(
8883 registry: &Registry,
8884 slot: crate::registry::RegistrationSlot<'_>,
8885 module_id: &str,
8886 snapshot: &SharedSnapshot,
8887 child: &mut SupervisedChild,
8888 wait: Duration,
8889) -> Result<RegistrationWaitOutcome, SuperviseError> {
8890 let deadline = Instant::now() + wait;
8891 loop {
8892 if registry
8893 .registration(slot)
8894 .map_err(SuperviseError::Registry)?
8895 .is_some()
8896 {
8897 return Ok(RegistrationWaitOutcome::Registered);
8898 }
8899
8900 let now = Instant::now();
8901 if now >= deadline {
8902 return Ok(RegistrationWaitOutcome::TimedOut);
8903 }
8904 let remaining = deadline.saturating_duration_since(now);
8905 let poll = remaining.min(REGISTRY_RELEASE_POLL);
8906
8907 tokio::select! {
8908 wait_result = child.wait() => {
8909 let status = wait_result.map_err(|source| SuperviseError::Wait {
8910 module_id: module_id.to_string(),
8911 source,
8912 })?;
8913 return Ok(RegistrationWaitOutcome::Exited(classify_reaped_child_exit(
8914 snapshot,
8915 child,
8916 &status,
8917 )));
8918 }
8919 _ = sleep(poll) => {}
8920 }
8921 }
8922}
8923
8924fn registration_failure_exit_report(mut exit_report: ExitReport) -> ExitReport {
8925 if exit_report.kind != ExitKind::DeliberateSeverance {
8928 exit_report.kind = ExitKind::Crash;
8929 }
8930 exit_report
8931}
8932
8933async fn handle_reload_child_registration_failure(
8934 spec: &ModuleSpec,
8935 runtime: &SupervisorRuntimeConfig,
8936 registry: &Registry,
8937 process_liveness: &SupervisorProcessLiveness,
8938 snapshot: &SharedSnapshot,
8939 _child: &mut Option<SupervisedChild>,
8940 failure: ReloadRegistrationFailure,
8941) -> Result<(), SuperviseError> {
8942 let ReloadRegistrationFailure {
8943 exit_report,
8944 reason,
8945 } = failure;
8946 match on_child_exit(
8947 spec,
8948 runtime.restart_policy,
8949 registry,
8950 snapshot,
8951 &runtime.terminal_ring,
8952 &runtime.spawn_events,
8953 &runtime.child_roster,
8954 exit_report,
8955 )
8956 .await
8957 {
8958 NextAction::Stop {
8959 registration_released,
8960 } => {
8961 if registration_released {
8962 process_liveness.untrack_if_current(&spec.module_id, snapshot);
8963 }
8964 }
8965 NextAction::Restart { schedule } => {
8966 let delay = schedule.map_or(runtime.restart_policy.delay_for_restart(0), |schedule| {
8967 schedule.delay
8968 });
8969 if let Some(schedule) = schedule {
8970 log_crash_respawn(&spec.module_id, schedule);
8971 }
8972 schedule_respawn(
8973 runtime,
8974 snapshot,
8975 &spec.module_id,
8976 delay,
8977 RespawnKind::Spawn,
8978 )?;
8979 }
8980 }
8981 Err(SuperviseError::ReloadFailed {
8982 module_id: spec.module_id.clone(),
8983 reason,
8984 })
8985}
8986
8987async fn handle_reload_spawn_failure(
8988 spec: &ModuleSpec,
8989 runtime: &SupervisorRuntimeConfig,
8990 process_liveness: &SupervisorProcessLiveness,
8991 snapshot: &SharedSnapshot,
8992 _child: &mut Option<SupervisedChild>,
8993 reason: String,
8994) -> Result<(), SuperviseError> {
8995 let now = Instant::now();
8996 let mut schedule = None;
8997 update_snapshot(snapshot, Some(&spec.module_id), |state| {
8998 clear_current_process_facts(state);
8999 if state.enabled {
9000 schedule = state.next_crash_restart(&runtime.restart_policy, now);
9001 state.state = if schedule.is_some() {
9002 ModuleState::Restarting
9003 } else {
9004 ModuleState::Failed
9005 };
9006 } else {
9007 state.state = ModuleState::Disabled;
9008 }
9009 })?;
9010 if let Some(schedule) = schedule {
9011 schedule_respawn(
9012 runtime,
9013 snapshot,
9014 &spec.module_id,
9015 schedule.delay,
9016 RespawnKind::Spawn,
9017 )?;
9018 } else {
9019 process_liveness.untrack_if_current(&spec.module_id, snapshot);
9020 }
9021 Err(SuperviseError::ReloadFailed {
9022 module_id: spec.module_id.clone(),
9023 reason,
9024 })
9025}
9026
9027fn control_flags() -> Flags {
9028 Flags::new(false, Priority::Passive, false)
9029}
9030
9031#[allow(clippy::too_many_arguments)]
9032async fn drain_optional_child(
9033 module_id: &str,
9034 protocol: ModuleProtocol,
9035 stop_notice: StopNotice,
9036 registry: &Registry,
9037 forwarding: Option<&ForwardingTable>,
9038 snapshot: &SharedSnapshot,
9039 terminal_ring: &Arc<Mutex<TerminalRing>>,
9040 spawn_events: &SpawnEventFeed,
9041 child: &mut Option<SupervisedChild>,
9042 drain_timeout: Duration,
9043 final_state: ModuleState,
9044 enabled: Option<bool>,
9045) -> Result<(), SuperviseError> {
9046 if let Some(child) = child.take() {
9047 drain_child_to_state(
9048 module_id,
9049 protocol,
9050 stop_notice,
9051 registry,
9052 forwarding,
9053 snapshot,
9054 terminal_ring,
9055 spawn_events,
9056 child,
9057 drain_timeout,
9058 final_state,
9059 enabled,
9060 )
9061 .await
9062 } else {
9063 update_snapshot(snapshot, Some(module_id), |state| {
9064 state.state = final_state;
9065 if let Some(enabled) = enabled {
9066 state.enabled = enabled;
9067 }
9068 clear_current_process_facts(state);
9069 })?;
9070 release_dead_registration(registry, forwarding, snapshot, module_id).await
9071 }
9072}
9073
9074#[allow(clippy::too_many_arguments)]
9075async fn drain_child_to_state(
9076 module_id: &str,
9077 _protocol: ModuleProtocol,
9078 stop_notice: StopNotice,
9079 registry: &Registry,
9080 forwarding: Option<&ForwardingTable>,
9081 snapshot: &SharedSnapshot,
9082 terminal_ring: &Arc<Mutex<TerminalRing>>,
9083 spawn_events: &SpawnEventFeed,
9084 mut child: SupervisedChild,
9085 drain_timeout: Duration,
9086 final_state: ModuleState,
9087 enabled: Option<bool>,
9088) -> Result<(), SuperviseError> {
9089 let protocol = child.protocol;
9090 update_snapshot(snapshot, Some(module_id), |state| {
9091 state.state = ModuleState::Draining;
9092 state.draining_to_replace = final_state == ModuleState::Restarting;
9093 if let Some(enabled) = enabled {
9094 state.enabled = enabled;
9095 }
9096 })?;
9097
9098 if stop_notice != StopNotice::SentOverConnection {
9109 if protocol == ModuleProtocol::Subc && stop_notice == StopNotice::NoConnection {
9110 info!(
9111 module_id,
9112 pid = child.pid,
9113 budget_ms = u64::try_from(drain_timeout.as_millis()).unwrap_or(u64::MAX),
9114 "module has no connection yet; requesting stop by signal"
9115 );
9116 }
9117 request_graceful_stop(module_id, &child);
9118 }
9119
9120 let exit_report = match timeout(drain_timeout, child.wait()).await {
9121 Ok(Ok(status)) => classify_reaped_child_exit(snapshot, &child, &status),
9122 Ok(Err(source)) => {
9123 fail_snapshot(snapshot, Some(module_id), None);
9124 return Err(SuperviseError::Wait {
9125 module_id: module_id.to_string(),
9126 source,
9127 });
9128 }
9129 Err(_) => {
9130 warn!(
9143 module_id,
9144 pid = child.pid,
9145 budget_ms = u64::try_from(drain_timeout.as_millis()).unwrap_or(u64::MAX),
9146 reason = ?final_state,
9147 ?stop_notice,
9148 "drain budget expired before the module exited; killing it"
9149 );
9150 child.start_kill().map_err(|source| {
9151 fail_snapshot(snapshot, Some(module_id), None);
9152 SuperviseError::Kill {
9153 module_id: module_id.to_string(),
9154 source,
9155 }
9156 })?;
9157 let status = child.wait().await.map_err(|source| {
9158 fail_snapshot(snapshot, Some(module_id), None);
9159 SuperviseError::Wait {
9160 module_id: module_id.to_string(),
9161 source,
9162 }
9163 })?;
9164 classify_reaped_child_exit(snapshot, &child, &status)
9165 }
9166 };
9167
9168 update_snapshot(snapshot, Some(module_id), |state| {
9169 state.state = final_state;
9170 if let Some(enabled) = enabled {
9171 state.enabled = enabled;
9172 }
9173 clear_current_process_facts(state);
9174 state.last_exit = Some(exit_report.clone());
9175 if exit_report.kind == ExitKind::DeliberateSeverance {
9176 state.lifetime_restarts += 1;
9177 }
9178 })?;
9179 let detail = lock_snapshot(snapshot)?.drain_disposition_detail.take();
9180 record_terminal_with_detail(
9181 module_id,
9182 terminal_ring,
9183 spawn_events,
9184 &exit_report,
9185 terminal_disposition(final_state),
9186 detail,
9187 );
9188 child.drain_stderr(module_id).await;
9189
9190 release_dead_registration(registry, forwarding, snapshot, module_id).await
9191}
9192
9193#[cfg(unix)]
9213fn request_graceful_stop(module_id: &str, child: &SupervisedChild) {
9214 let Some(pid) = child
9215 .id()
9216 .and_then(|pid| i32::try_from(pid).ok())
9217 .and_then(rustix::process::Pid::from_raw)
9218 else {
9219 debug!(
9220 module_id,
9221 "no pid to signal for teardown; falling through to the drain wait"
9222 );
9223 return;
9224 };
9225 match rustix::process::kill_process(pid, rustix::process::Signal::TERM) {
9226 Ok(()) => debug!(
9227 module_id,
9228 "sent SIGTERM to a module nothing else asked to stop"
9229 ),
9230 Err(err) => debug!(
9231 module_id,
9232 error = %err,
9233 "SIGTERM to module failed; the drain wait and kill still apply"
9234 ),
9235 }
9236}
9237
9238#[cfg(not(unix))]
9246fn request_graceful_stop(module_id: &str, _child: &SupervisedChild) {
9247 debug!(
9248 module_id,
9249 "no graceful stop signal exists on this platform; teardown of a module nothing asked to stop waits, then kills"
9250 );
9251}
9252
9253fn terminal_disposition(final_state: ModuleState) -> TerminalDisposition {
9254 match final_state {
9255 ModuleState::Stopped => TerminalDisposition::Stopped,
9256 ModuleState::Disabled => TerminalDisposition::Disabled,
9257 ModuleState::Restarting => TerminalDisposition::Restarting,
9258 ModuleState::Failed => TerminalDisposition::Failed,
9259 ModuleState::Starting
9260 | ModuleState::Running
9261 | ModuleState::Unresponsive
9262 | ModuleState::Draining => {
9263 unreachable!("terminal exits only finish in terminal or restarting states")
9264 }
9265 }
9266}
9267
9268async fn release_dead_registration(
9276 registry: &Registry,
9277 forwarding: Option<&ForwardingTable>,
9278 snapshot: &SharedSnapshot,
9279 module_id: &str,
9280) -> Result<(), SuperviseError> {
9281 let result = async {
9282 let registration = registry
9283 .get_module(module_id)
9284 .map_err(SuperviseError::Registry)?;
9285 match wait_for_registration_release(registry, module_id, REGISTRY_RELEASE_TIMEOUT).await {
9286 Ok(()) => return Ok(()),
9287 Err(SuperviseError::RegistrationStillActive { .. }) => {}
9288 Err(err) => return Err(err),
9289 }
9290 let pid = lock_snapshot(snapshot)?.reaped_pid;
9291 if let (Some(registration), Some(forwarding), Some(pid)) = (registration, forwarding, pid) {
9292 warn!(
9293 module_id,
9294 pid,
9295 connection_id = registration.connection_id.get(),
9296 "reaped module registration outlived release grace; closing dead connection"
9297 );
9298 forwarding.request_connection_close(
9299 registration.connection_id,
9300 CloseReason::new(
9301 "supervised_process_reaped",
9302 format!("module '{module_id}' pid {pid} exited"),
9303 ),
9304 );
9305 wait_for_slot_registration_release(
9306 registry,
9307 crate::registry::RegistrationSlot::Connection(registration.connection_id),
9308 REGISTRY_RELEASE_TIMEOUT,
9309 )
9310 .await?;
9311 }
9312 wait_for_registration_release(registry, module_id, Duration::ZERO).await
9313 }
9314 .await;
9315 if let Err(err) = &result {
9316 fail_snapshot(snapshot, Some(module_id), None);
9317 error!(module_id, error = %err, "registration release failed after child exit; module is failed and start can retry");
9318 }
9319 result
9320}
9321
9322async fn wait_for_registration_release(
9325 registry: &Registry,
9326 module_id: &str,
9327 wait: Duration,
9328) -> Result<(), SuperviseError> {
9329 wait_for_slot_registration_release(
9330 registry,
9331 crate::registry::RegistrationSlot::Active(module_id),
9332 wait,
9333 )
9334 .await
9335}
9336
9337async fn wait_for_slot_registration_release(
9345 registry: &Registry,
9346 slot: crate::registry::RegistrationSlot<'_>,
9347 wait: Duration,
9348) -> Result<(), SuperviseError> {
9349 let deadline = Instant::now() + wait;
9350 let mut release_events = registration_release_events().subscribe();
9351 let still_active = |registration: &crate::registry::ModuleRegistration| {
9352 SuperviseError::RegistrationStillActive {
9353 module_id: registration.manifest.module_id.clone(),
9354 waited: wait,
9355 }
9356 };
9357 loop {
9358 let _observed_generation = *release_events.borrow_and_update();
9359 let Some(registration) = registry
9360 .registration(slot)
9361 .map_err(SuperviseError::Registry)?
9362 else {
9363 return Ok(());
9364 };
9365
9366 let now = Instant::now();
9367 if now >= deadline {
9368 return Err(still_active(®istration));
9369 }
9370
9371 let remaining = deadline.saturating_duration_since(now);
9372 match timeout(remaining, release_events.changed()).await {
9373 Ok(Ok(())) | Ok(Err(_)) => {}
9374 Err(_) => return Err(still_active(®istration)),
9375 }
9376 }
9377}
9378
9379#[cfg(test)]
9380mod slot_registration_wait_tests {
9381 use super::*;
9382 use crate::registry::{ConnectionId, RegistrationSlot};
9383 use subc_protocol::manifest::ModuleManifest;
9384
9385 #[tokio::test]
9386 async fn enable_release_failure_is_failed_and_a_second_enable_retries() {
9387 let registry = Arc::new(Registry::default());
9388 let supervisor = Supervisor::new_for_test(Arc::clone(®istry), RestartPolicy::default());
9389 let runtime = supervisor.runtime_config();
9390 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::disabled()));
9391 let spec = ModuleSpec {
9392 module_id: "enable-stale-registration".to_string(),
9393 program: PathBuf::from("/missing/enable-retry-test"),
9394 args: Vec::new(),
9395 env: Vec::new(),
9396 reserved: false,
9397 reserved_prefixes: Vec::new(),
9398 protocol: ModuleProtocol::Subc,
9399 overlap: Default::default(),
9400 };
9401 let connection = ConnectionId::new(90);
9402 registry
9403 .register_with_control_ops(
9404 ModuleManifest::builder(&spec.module_id, "0.1.0").build(),
9405 1,
9406 connection,
9407 Vec::new(),
9408 )
9409 .unwrap();
9410 let mut child = None;
9411 let err = set_child_enabled(
9412 &spec,
9413 &runtime,
9414 ®istry,
9415 &supervisor.process_liveness,
9416 &snapshot,
9417 &mut child,
9418 true,
9419 )
9420 .await
9421 .unwrap_err();
9422 assert!(matches!(
9423 err,
9424 SuperviseError::RegistrationStillActive { .. }
9425 ));
9426 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
9427 assert!(child.is_none());
9428 registry.deregister_connection(connection).unwrap();
9429 let err = set_child_enabled(
9430 &spec,
9431 &runtime,
9432 ®istry,
9433 &supervisor.process_liveness,
9434 &snapshot,
9435 &mut child,
9436 true,
9437 )
9438 .await
9439 .unwrap_err();
9440 assert!(
9441 matches!(err, SuperviseError::Spawn { .. }),
9442 "second enable must attempt a spawn: {err}"
9443 );
9444 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
9445 }
9446
9447 const INCUMBENT: u64 = 1;
9448 const CANDIDATE: u64 = 2;
9449
9450 fn swapped_registry() -> Arc<Registry> {
9451 let registry = Arc::new(Registry::default());
9452 let manifest = ModuleManifest::builder("m", "0.1.0").build();
9453 registry
9454 .register_with_control_ops(
9455 manifest.clone(),
9456 1,
9457 ConnectionId::new(INCUMBENT),
9458 Vec::new(),
9459 )
9460 .unwrap();
9461 registry
9462 .register_candidate_with_control_ops(
9463 manifest,
9464 1,
9465 ConnectionId::new(CANDIDATE),
9466 Vec::new(),
9467 )
9468 .unwrap();
9469 registry
9470 }
9471
9472 #[tokio::test]
9476 async fn incumbent_release_is_awaited_by_connection_not_by_module_id() {
9477 let registry = swapped_registry();
9478 registry.promote_candidate("m").unwrap().unwrap();
9479
9480 assert!(matches!(
9481 wait_for_registration_release(®istry, "m", Duration::from_millis(50)).await,
9482 Err(SuperviseError::RegistrationStillActive { .. })
9483 ));
9484
9485 assert!(matches!(
9487 wait_for_slot_registration_release(
9488 ®istry,
9489 RegistrationSlot::Connection(ConnectionId::new(INCUMBENT)),
9490 Duration::from_millis(50),
9491 )
9492 .await,
9493 Err(SuperviseError::RegistrationStillActive { .. })
9494 ));
9495
9496 let releaser = Arc::clone(®istry);
9497 let release = tokio::spawn(async move {
9498 sleep(Duration::from_millis(20)).await;
9499 releaser
9500 .deregister_connection(ConnectionId::new(INCUMBENT))
9501 .unwrap();
9502 notify_registration_release();
9503 });
9504 wait_for_slot_registration_release(
9505 ®istry,
9506 RegistrationSlot::Connection(ConnectionId::new(INCUMBENT)),
9507 Duration::from_secs(5),
9508 )
9509 .await
9510 .expect("the incumbent's own registration is released");
9511 release.await.unwrap();
9512 assert!(registry.get_module("m").unwrap().is_some());
9513 }
9514
9515 #[tokio::test]
9518 async fn candidate_slot_wait_ignores_the_incumbents_registration() {
9519 let registry = swapped_registry();
9520 assert!(matches!(
9521 wait_for_slot_registration_release(
9522 ®istry,
9523 RegistrationSlot::Candidate("m"),
9524 Duration::from_millis(50),
9525 )
9526 .await,
9527 Err(SuperviseError::RegistrationStillActive { .. })
9528 ));
9529 registry
9530 .deregister_connection(ConnectionId::new(CANDIDATE))
9531 .unwrap();
9532 wait_for_slot_registration_release(
9533 ®istry,
9534 RegistrationSlot::Candidate("m"),
9535 Duration::from_millis(50),
9536 )
9537 .await
9538 .expect("a candidate slot with no candidate is released");
9539 assert!(registry
9540 .registration(RegistrationSlot::Active("m"))
9541 .unwrap()
9542 .is_some());
9543 }
9544}
9545
9546fn classify_exit(status: &ExitStatus) -> ExitReport {
9547 ExitReport {
9548 kind: if status.success() {
9549 ExitKind::Clean
9550 } else {
9551 ExitKind::Crash
9552 },
9553 code: status.code(),
9554 signal: exit_signal(status),
9555 at_ms: unix_ms_now(),
9556 }
9557}
9558
9559fn wait_error_exit_report() -> ExitReport {
9565 ExitReport {
9566 kind: ExitKind::Crash,
9567 code: None,
9568 signal: None,
9569 at_ms: unix_ms_now(),
9570 }
9571}
9572
9573#[cfg(unix)]
9574fn exit_signal(status: &ExitStatus) -> Option<i32> {
9575 use std::os::unix::process::ExitStatusExt;
9576
9577 status.signal()
9578}
9579
9580#[cfg(not(unix))]
9581fn exit_signal(_status: &ExitStatus) -> Option<i32> {
9582 None
9583}
9584
9585fn reset_restart_count(snapshot: &SharedSnapshot, module_id: &str) -> Result<(), SuperviseError> {
9591 update_snapshot(snapshot, Some(module_id), |state| {
9592 state.clear_crash_restarts();
9593 })
9594}
9595
9596fn set_running(
9597 snapshot: &SharedSnapshot,
9598 child: &SupervisedChild,
9599 module_id: &str,
9600 spawn_events: &SpawnEventFeed,
9601) -> Result<(), SuperviseError> {
9602 let mut state = snapshot.lock().map_err(|_| SuperviseError::StatePoisoned {
9603 module_id: Some(module_id.to_string()),
9604 })?;
9605 state.spawn_generation = spawn_events.emit_spawned(module_id, child.pid, child.spawned_at_ms);
9606 if std::mem::take(&mut state.coalesced_restart_pending) {
9607 let generation = state.spawn_generation;
9608 info!(module_id, "restart already satisfied by generation {generation}; coalesced pending request completed");
9609 }
9610 state.drain_disposition_detail = None;
9611 state.spawn_failure = None;
9612 state.in_alternate_slot = false;
9615 state.configuration_updated_since_spawn = false;
9616 state.spawned_protocol = Some(child.protocol);
9617 state.state = ModuleState::Running;
9618 state.enabled = true;
9619 state.process_alive = true;
9620 state.pid = child.id();
9621 #[cfg(target_os = "macos")]
9622 {
9623 state.report_ready = Some(Arc::clone(&child.report_ready));
9624 }
9625 state.spawned_at_ms = Some(child.spawned_at_ms);
9626 state.spawned_from = Some(child.spawned_from.clone());
9627 state.spawned_file_identity = child.spawned_file_identity;
9628 state.process_start_time = child.process_start_time;
9629 Ok(())
9630}
9631
9632fn clear_current_process_facts(state: &mut SupervisorSnapshot) {
9633 state.process_alive = false;
9634 state.spawned_protocol = None;
9635 state.pid = None;
9636 #[cfg(target_os = "macos")]
9637 {
9638 state.report_ready = None;
9639 }
9640 state.spawned_at_ms = None;
9641 state.spawned_from = None;
9642 state.spawned_file_identity = None;
9643 state.process_start_time = None;
9644 state.deliberate_severance = None;
9645}
9646
9647#[cfg(test)]
9648fn record_deliberate_severance(
9649 snapshot: &SharedSnapshot,
9650 identity: ProcessIdentity,
9651) -> Result<(), SuperviseError> {
9652 update_snapshot(snapshot, None, |state| {
9653 state.deliberate_severance = Some(identity);
9654 })
9655}
9656
9657fn apply_deliberate_severance_marker(
9658 snapshot: &SharedSnapshot,
9659 exited_identity: Option<ProcessIdentity>,
9660 mut exit_report: ExitReport,
9661) -> ExitReport {
9662 let marker = lock_snapshot(snapshot)
9663 .ok()
9664 .and_then(|mut state| state.deliberate_severance.take());
9665 if marker.is_some() && marker == exited_identity {
9666 exit_report.kind = ExitKind::DeliberateSeverance;
9667 }
9668 exit_report
9669}
9670
9671fn classify_reaped_child_exit(
9672 snapshot: &SharedSnapshot,
9673 child: &SupervisedChild,
9674 status: &ExitStatus,
9675) -> ExitReport {
9676 let _ = update_snapshot(snapshot, None, |state| {
9677 state.reaped_pid = Some(child.pid);
9678 state.spawn_failure = child.spawn_failure.clone();
9679 });
9680 apply_deliberate_severance_marker(snapshot, child.process_identity(), classify_exit(status))
9681}
9682
9683fn fail_snapshot(
9684 snapshot: &SharedSnapshot,
9685 module_id: Option<&str>,
9686 last_exit: Option<ExitReport>,
9687) {
9688 if let Err(err) = update_snapshot(snapshot, module_id, |state| {
9689 state.state = ModuleState::Failed;
9690 clear_current_process_facts(state);
9691 if let Some(last_exit) = last_exit {
9692 state.last_exit = Some(last_exit);
9693 }
9694 }) {
9695 error!(error = %err, "failed to mark supervisor state failed");
9696 }
9697}
9698
9699fn update_snapshot(
9700 snapshot: &SharedSnapshot,
9701 module_id: Option<&str>,
9702 update: impl FnOnce(&mut SupervisorSnapshot),
9703) -> Result<(), SuperviseError> {
9704 let mut state = snapshot.lock().map_err(|_| SuperviseError::StatePoisoned {
9705 module_id: module_id.map(ToOwned::to_owned),
9706 })?;
9707 update(&mut state);
9708 Ok(())
9709}
9710
9711const SLOW_SNAPSHOT_LOCK_THRESHOLD: Duration = Duration::from_millis(250);
9712
9713fn lock_snapshot_for_control<'a>(
9714 snapshot: &'a SharedSnapshot,
9715 module_id: &str,
9716 caller: &'static str,
9717) -> Result<std::sync::MutexGuard<'a, SupervisorSnapshot>, SuperviseError> {
9718 let started_at = Instant::now();
9719 let guard = lock_snapshot(snapshot)?;
9720 let waited = started_at.elapsed();
9721 if waited >= SLOW_SNAPSHOT_LOCK_THRESHOLD {
9722 warn!(
9723 module_id = %module_id,
9724 waited_ms = waited.as_millis() as u64,
9725 caller = %caller,
9726 "slow snapshot lock"
9727 );
9728 }
9729 Ok(guard)
9730}
9731
9732fn lock_snapshot(
9733 snapshot: &SharedSnapshot,
9734) -> Result<std::sync::MutexGuard<'_, SupervisorSnapshot>, SuperviseError> {
9735 snapshot
9736 .lock()
9737 .map_err(|_| SuperviseError::StatePoisoned { module_id: None })
9738}
9739
9740#[cfg(test)]
9741mod terminal_history_tests {
9742 use std::{
9743 path::PathBuf,
9744 sync::Arc,
9745 time::{Duration, Instant},
9746 };
9747
9748 use tokio::{sync::mpsc, time::sleep};
9749
9750 use super::{
9751 apply_deliberate_severance_marker, daemon_will_restart, drain_child_to_state,
9752 drained_after_quiescence_wait, handle_reload_spawn_failure, health_restart_child,
9753 lock_snapshot, on_child_exit, record_deliberate_severance, record_wait_error_terminal,
9754 reset_restart_count, spawn_and_mark_running, update_snapshot, wait_error_exit_report,
9755 ExitKind, ExitReport, ModuleProtocol, ModuleSpec, ModuleState, NextAction, ProcessIdentity,
9756 RestartPolicy, SpawnEventKind, StopNotice, SuperviseError, SupervisedConfiguration,
9757 SupervisedModule, SupervisedModuleInner, Supervisor, SupervisorHandle,
9758 SupervisorHealthStatus, SupervisorSnapshot,
9759 };
9760 use super::Instant as ClockInstant;
9765 use crate::{
9766 registry::Registry,
9767 terminal_ring::{TerminalRing, TerminalRingConfig},
9768 };
9769 use std::sync::Mutex;
9770 use subc_control::TerminalDisposition;
9771
9772 pub(super) fn fake_aft_stub_path() -> PathBuf {
9777 let mut path = std::env::current_exe().expect("current_exe available in tests");
9778 path.pop();
9779 path.pop();
9780 path.push(if cfg!(windows) {
9781 "fake-aft-stub.exe"
9782 } else {
9783 "fake-aft-stub"
9784 });
9785 assert!(
9786 path.exists(),
9787 "fake-aft-stub not built at {}: run `cargo test -p subc-core` (which builds \
9788 [[bin]] targets) rather than `cargo test -p subc-core --lib` (which does not)",
9789 path.display()
9790 );
9791 path
9792 }
9793
9794 #[test]
9795 fn reserved_never_spawned_refuses_every_hello() {
9796 let supervisor = SupervisorHandle::default();
9801 supervisor.apply_identity_configuration(&ModuleSpec {
9802 module_id: "never-spawned".to_string(),
9803 program: PathBuf::from("/usr/bin/false"),
9804 args: Vec::new(),
9805 env: Vec::new(),
9806 reserved: true,
9807 reserved_prefixes: Vec::new(),
9808 protocol: ModuleProtocol::Subc,
9809 overlap: Default::default(),
9810 });
9811 assert!(
9812 supervisor
9813 .reserved_hello_rejection("never-spawned", Some("any-forged-nonce"))
9814 .is_some(),
9815 "forged nonce must refuse on a reserved never-spawned id"
9816 );
9817 assert!(
9818 supervisor
9819 .reserved_hello_rejection("never-spawned", None)
9820 .is_some(),
9821 "absent nonce must refuse on a reserved never-spawned id"
9822 );
9823 supervisor.set_spawn_nonce("never-spawned", "minted".to_string());
9825 supervisor.apply_identity_configuration(&ModuleSpec {
9826 module_id: "never-spawned".to_string(),
9827 program: PathBuf::from("/usr/bin/false"),
9828 args: Vec::new(),
9829 env: Vec::new(),
9830 reserved: true,
9831 reserved_prefixes: Vec::new(),
9832 protocol: ModuleProtocol::Subc,
9833 overlap: Default::default(),
9834 });
9835 assert!(supervisor
9836 .reserved_hello_rejection("never-spawned", Some("minted"))
9837 .is_none());
9838 assert!(supervisor
9839 .reserved_hello_rejection("never-spawned", Some("forged"))
9840 .is_some());
9841 }
9842
9843 fn seed_crash_restarts(state: &mut SupervisorSnapshot, count: u32) {
9846 let now = ClockInstant::now();
9847 for _ in 0..count {
9848 state.crash_restarts.push_back(now);
9849 }
9850 }
9851
9852 fn age_oldest_crash_restart_out_of_window(state: &mut SupervisorSnapshot, window: Duration) {
9856 let aged = state
9857 .crash_restarts
9858 .front()
9859 .expect("a crash restart must be recorded before it can be aged")
9860 .checked_sub(window + Duration::from_secs(1))
9861 .expect("the test clock is far enough from its origin to age an instant");
9862 state.crash_restarts[0] = aged;
9863 }
9864
9865 fn snapshot_with_restarts(enabled: bool, count: u32) -> SupervisorSnapshot {
9866 let mut state = SupervisorSnapshot::new(ModuleState::Running, enabled);
9867 seed_crash_restarts(&mut state, count);
9868 state
9869 }
9870
9871 #[test]
9872 fn daemon_owned_recovery_predicate_uses_the_pre_increment_budget() {
9873 let policy = RestartPolicy::new(3, Duration::ZERO);
9874 let now = ClockInstant::now();
9875 assert!(daemon_will_restart(
9876 &mut snapshot_with_restarts(true, 2),
9877 &policy,
9878 now
9879 ));
9880 assert!(!daemon_will_restart(
9881 &mut snapshot_with_restarts(true, 3),
9882 &policy,
9883 now
9884 ));
9885 assert!(!daemon_will_restart(
9886 &mut snapshot_with_restarts(false, 0),
9887 &policy,
9888 now
9889 ));
9890 }
9891
9892 #[test]
9893 fn crash_restart_backoff_escalates_with_in_window_count() {
9894 let policy = RestartPolicy::new(4, Duration::from_millis(100))
9895 .with_max_backoff(Duration::from_secs(30));
9896 let now = ClockInstant::now();
9897 let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
9898 let schedules = (0..4)
9899 .map(|_| {
9900 state
9901 .next_crash_restart(&policy, now)
9902 .expect("the test policy allows four crash restarts")
9903 })
9904 .collect::<Vec<_>>();
9905
9906 assert_eq!(
9907 schedules
9908 .iter()
9909 .map(|schedule| schedule.restart_in_window)
9910 .collect::<Vec<_>>(),
9911 vec![0, 1, 2, 3]
9912 );
9913 assert_eq!(
9914 schedules
9915 .iter()
9916 .map(|schedule| schedule.delay)
9917 .collect::<Vec<_>>(),
9918 vec![
9919 Duration::from_millis(100),
9920 Duration::from_secs(1),
9921 Duration::from_secs(10),
9922 Duration::from_secs(30),
9923 ]
9924 );
9925 }
9926
9927 #[test]
9928 fn crash_restart_backoff_resets_after_ring_clear() {
9929 let policy = RestartPolicy::new(3, Duration::from_millis(100));
9930 let now = ClockInstant::now();
9931 let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
9932 assert_eq!(
9933 state.next_crash_restart(&policy, now).unwrap().delay,
9934 Duration::from_millis(100)
9935 );
9936 assert_eq!(
9937 state.next_crash_restart(&policy, now).unwrap().delay,
9938 Duration::from_secs(1)
9939 );
9940
9941 state.clear_crash_restarts();
9942 let schedule = state
9943 .next_crash_restart(&policy, now)
9944 .expect("a cleared ring must allow another restart");
9945 assert_eq!(schedule.restart_in_window, 0);
9946 assert_eq!(schedule.delay, Duration::from_millis(100));
9947 }
9948
9949 #[test]
9950 fn crash_restart_backoff_ignores_aged_restarts() {
9951 let policy = RestartPolicy::new(3, Duration::from_millis(100));
9952 let now = ClockInstant::now();
9953 let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
9954 state
9955 .next_crash_restart(&policy, now)
9956 .expect("the first restart is allowed");
9957 state
9958 .next_crash_restart(&policy, now)
9959 .expect("the second restart is allowed");
9960 state.crash_restarts[0] = now
9961 .checked_sub(policy.window + Duration::from_secs(1))
9962 .expect("the fake clock can age a restart past the window");
9963
9964 let schedule = state
9965 .next_crash_restart(&policy, now)
9966 .expect("an aged restart must release its slot");
9967 assert_eq!(schedule.restart_in_window, 1);
9968 assert_eq!(schedule.delay, Duration::from_secs(1));
9969 assert_eq!(state.crash_restarts.len(), 2);
9970 }
9971
9972 #[test]
9976 fn a_budget_spent_before_the_window_no_longer_refuses() {
9977 let policy = RestartPolicy::new(3, Duration::ZERO);
9978 let mut state = snapshot_with_restarts(true, 3);
9979 let now = ClockInstant::now();
9980 assert!(!daemon_will_restart(&mut state, &policy, now));
9981
9982 assert!(daemon_will_restart(
9983 &mut state,
9984 &policy,
9985 now + policy.window + Duration::from_secs(1)
9986 ));
9987 assert!(
9988 state.crash_restarts.is_empty(),
9989 "reading the budget must drop the instants that left the window"
9990 );
9991 }
9992
9993 fn module_with_recovery_snapshot(
9994 state: ModuleState,
9995 enabled: bool,
9996 restart_count: u32,
9997 ) -> SupervisedModule {
9998 let registry = Arc::new(Registry::default());
9999 let supervisor =
10000 Supervisor::new_for_test(Arc::clone(®istry), RestartPolicy::new(3, Duration::ZERO));
10001 let runtime = supervisor.runtime_config();
10002 let spec = ModuleSpec {
10003 module_id: "recovery-snapshot".to_string(),
10004 program: fake_aft_stub_path(),
10005 args: Vec::new(),
10006 env: Vec::new(),
10007 reserved: false,
10008 reserved_prefixes: Vec::new(),
10009 protocol: ModuleProtocol::Subc,
10010 overlap: Default::default(),
10011 };
10012 let mut snapshot = SupervisorSnapshot::new(state, enabled);
10013 seed_crash_restarts(&mut snapshot, restart_count);
10014 let (commands, _rx) = mpsc::channel(4);
10017 SupervisedModule {
10018 inner: Arc::new(SupervisedModuleInner {
10019 module_id: spec.module_id.clone(),
10020 registry,
10021 snapshot: Arc::new(Mutex::new(snapshot)),
10022 configuration: Arc::new(Mutex::new(SupervisedConfiguration {
10023 spec,
10024 health: runtime.health,
10025 })),
10026 stderr_ring: runtime.stderr_ring,
10027 terminal_ring: runtime.terminal_ring,
10028 commands,
10029 monitor: Mutex::new(None),
10030 restart_policy: runtime.restart_policy,
10031 effective_drain_timeout: runtime.effective_drain_timeout,
10032 provenance_probe: supervisor.provenance_probe.clone(),
10033 }),
10034 }
10035 }
10036
10037 #[cfg(target_os = "linux")]
10038 #[tokio::test]
10039 async fn no_cgroup_placement_does_not_block_fake_aft_stub_spawn() {
10040 let supervisor =
10041 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
10042 .with_cgroup_placement(None);
10043 let result = supervisor.spawn(ModuleSpec {
10044 module_id: "no-cgroup-placement".to_string(),
10045 program: fake_aft_stub_path(),
10046 args: Vec::new(),
10047 env: Vec::new(),
10048 reserved: false,
10049 reserved_prefixes: Vec::new(),
10050 protocol: ModuleProtocol::Subc,
10051 overlap: Default::default(),
10052 });
10053
10054 assert!(
10055 result.is_ok(),
10056 "no delegation must not turn an otherwise valid spawn into a failure: {result:?}"
10057 );
10058 }
10059
10060 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10061 async fn undecided_snapshot_uses_shared_restart_predicate() {
10062 assert!(module_with_recovery_snapshot(ModuleState::Running, true, 2)
10063 .will_recover_after_connection_loss()
10064 .unwrap());
10065 assert!(
10066 !module_with_recovery_snapshot(ModuleState::Running, true, 3)
10067 .will_recover_after_connection_loss()
10068 .unwrap()
10069 );
10070 }
10071
10072 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10073 async fn restarting_snapshot_at_exhausted_budget_is_non_terminal() {
10074 assert!(
10075 module_with_recovery_snapshot(ModuleState::Restarting, true, 3)
10076 .will_recover_after_connection_loss()
10077 .unwrap()
10078 );
10079 }
10080
10081 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10082 async fn terminal_phase_snapshots_are_terminal_before_budget_exhaustion() {
10083 assert!(!module_with_recovery_snapshot(ModuleState::Failed, true, 0)
10084 .will_recover_after_connection_loss()
10085 .unwrap());
10086 assert!(
10087 !module_with_recovery_snapshot(ModuleState::Disabled, true, 0)
10088 .will_recover_after_connection_loss()
10089 .unwrap()
10090 );
10091 }
10092
10093 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10094 async fn warming_snapshot_is_limited_to_startup_phases() {
10095 for state in [
10096 ModuleState::Starting,
10097 ModuleState::Running,
10098 ModuleState::Restarting,
10099 ] {
10100 assert!(
10101 module_with_recovery_snapshot(state, true, 0)
10102 .is_warming()
10103 .unwrap(),
10104 "{state:?} should be warming"
10105 );
10106 }
10107 for state in [
10108 ModuleState::Unresponsive,
10109 ModuleState::Draining,
10110 ModuleState::Stopped,
10111 ModuleState::Failed,
10112 ModuleState::Disabled,
10113 ] {
10114 assert!(
10115 !module_with_recovery_snapshot(state, true, 0)
10116 .is_warming()
10117 .unwrap(),
10118 "{state:?} should not be warming"
10119 );
10120 }
10121 }
10122
10123 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10124 async fn terminal_history_survives_respawn_and_keeps_both_crashes_in_order() {
10125 let registry = Arc::new(Registry::default());
10126 let supervisor =
10127 Supervisor::new_for_test(Arc::clone(®istry), RestartPolicy::new(1, Duration::ZERO));
10128 let module = supervisor
10129 .spawn(ModuleSpec {
10130 module_id: "terminal-history".to_string(),
10131 program: fake_aft_stub_path(),
10132 args: Vec::new(),
10133 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
10134 reserved: false,
10135 reserved_prefixes: Vec::new(),
10136 protocol: ModuleProtocol::Subc,
10137 overlap: Default::default(),
10138 })
10139 .unwrap();
10140
10141 let deadline = Instant::now() + Duration::from_secs(5);
10142 loop {
10143 let history = module.terminal_history();
10144 if history.entries.len() == 2 {
10145 assert_eq!(module.status().unwrap().state, ModuleState::Failed);
10146 assert_eq!(history.dropped, 0);
10147 assert_eq!(
10148 history
10149 .entries
10150 .iter()
10151 .map(|entry| entry.exit_code)
10152 .collect::<Vec<_>>(),
10153 vec![Some(23), Some(23)]
10154 );
10155 assert!(history.entries[0].at_ms <= history.entries[1].at_ms);
10156 return;
10157 }
10158 assert!(
10159 Instant::now() < deadline,
10160 "module did not retain two terminal exits: {history:?}"
10161 );
10162 sleep(Duration::from_millis(10)).await;
10163 }
10164 }
10165
10166 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10170 async fn disable_during_crash_backoff_cancels_pending_respawn() {
10171 let backoff = Duration::from_secs(2);
10172 let supervisor = Supervisor::new_for_test(
10173 Arc::new(Registry::default()),
10174 RestartPolicy::new(10, backoff),
10175 );
10176 let module = supervisor
10177 .spawn(ModuleSpec {
10178 module_id: "disable-during-backoff".to_string(),
10179 program: fake_aft_stub_path(),
10180 args: Vec::new(),
10181 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
10182 reserved: false,
10183 reserved_prefixes: Vec::new(),
10184 protocol: ModuleProtocol::Subc,
10185 overlap: Default::default(),
10186 })
10187 .unwrap();
10188
10189 let deadline = Instant::now() + Duration::from_secs(5);
10191 loop {
10192 if module.status().unwrap().state == ModuleState::Restarting {
10193 break;
10194 }
10195 assert!(
10196 Instant::now() < deadline,
10197 "module never entered the crash backoff"
10198 );
10199 sleep(Duration::from_millis(10)).await;
10200 }
10201
10202 let started = Instant::now();
10203 module.set_enabled(false).await.unwrap();
10204 let waited = started.elapsed();
10205
10206 assert!(
10207 waited < backoff / 2,
10208 "disable waited {waited:?} behind the {backoff:?} crash backoff; the operator command must preempt the pending respawn"
10209 );
10210 assert_eq!(module.status().unwrap().state, ModuleState::Disabled);
10211
10212 sleep(backoff + Duration::from_millis(500)).await;
10214 let status = module.status().unwrap();
10215 assert_eq!(status.state, ModuleState::Disabled);
10216 assert_eq!(
10217 status.spawn_generation, 1,
10218 "module respawned after the operator disabled it"
10219 );
10220 }
10221
10222 #[cfg(unix)]
10225 fn protocol_none_sigterm_exits_clean_spec(
10226 module_id: &str,
10227 dir: &std::path::Path,
10228 ) -> (ModuleSpec, PathBuf, PathBuf) {
10229 let ready = dir.join("ready");
10230 let marker = dir.join("sigterm");
10231 let spec = ModuleSpec {
10232 module_id: module_id.to_string(),
10233 program: fake_aft_stub_path(),
10234 args: Vec::new(),
10235 env: vec![
10236 ("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string()),
10237 (
10238 "FAKE_AFT_SIGTERM_MARKER_PATH".to_string(),
10239 marker.display().to_string(),
10240 ),
10241 (
10242 "FAKE_AFT_NEVER_CONNECT_READY_PATH".to_string(),
10243 ready.display().to_string(),
10244 ),
10245 ],
10246 reserved: false,
10247 reserved_prefixes: Vec::new(),
10248 protocol: ModuleProtocol::None,
10249 overlap: Default::default(),
10250 };
10251 (spec, ready, marker)
10252 }
10253
10254 #[cfg(unix)]
10258 async fn wait_for_file(path: &std::path::Path) {
10259 let deadline = Instant::now() + Duration::from_secs(10);
10260 while !path.exists() {
10261 assert!(
10262 Instant::now() < deadline,
10263 "{} never appeared",
10264 path.display()
10265 );
10266 sleep(Duration::from_millis(10)).await;
10267 }
10268 }
10269
10270 #[cfg(unix)]
10274 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10275 async fn protocol_none_unrequested_clean_exit_restarts_as_a_crash() {
10276 let dir = cortexkit_test_support::ScratchDir::new("none-unrequested-clean-exit");
10277 let (spec, ready, marker) =
10278 protocol_none_sigterm_exits_clean_spec("none-unrequested-clean-exit", dir.path());
10279 let supervisor = Supervisor::new_for_test(
10280 Arc::new(Registry::default()),
10281 RestartPolicy::new(3, Duration::ZERO),
10282 );
10283 let module = supervisor.spawn(spec).unwrap();
10284 wait_for_file(&ready).await;
10285 let deadline = Instant::now() + Duration::from_secs(10);
10289 let first_pid = loop {
10290 let status = module.status().unwrap();
10291 if status.state == ModuleState::Running {
10292 if let Some(pid) = status.pid {
10293 break pid;
10294 }
10295 }
10296 assert!(
10297 Instant::now() < deadline,
10298 "a running module must report its pid after exec confirmation: {status:?}"
10299 );
10300 sleep(Duration::from_millis(10)).await;
10301 };
10302
10303 rustix::process::kill_process(
10304 rustix::process::Pid::from_raw(i32::try_from(first_pid).unwrap()).unwrap(),
10305 rustix::process::Signal::TERM,
10306 )
10307 .unwrap();
10308
10309 let deadline = Instant::now() + Duration::from_secs(10);
10310 let respawned = loop {
10311 let status = module.status().unwrap();
10312 if status.state == ModuleState::Running
10313 && status.pid.is_some_and(|pid| pid != first_pid)
10314 {
10315 break status;
10316 }
10317 assert!(
10318 Instant::now() < deadline,
10319 "protocol-none module was not respawned after an unrequested clean exit: {status:?}"
10320 );
10321 sleep(Duration::from_millis(10)).await;
10322 };
10323 assert_eq!(respawned.spawn_generation, 2);
10324 assert!(
10325 marker.exists(),
10326 "the child must have exited through its SIGTERM handler (exit 0), or this proves nothing about clean exits"
10327 );
10328
10329 let history = module.terminal_history();
10330 assert_eq!(history.entries.len(), 1, "{history:?}");
10331 let entry = &history.entries[0];
10332 assert_eq!(entry.exit_code, Some(0));
10333 assert_eq!(entry.exit_kind, subc_control::TerminalExitKind::Clean);
10334 assert_eq!(entry.disposition, TerminalDisposition::Restarting);
10335
10336 module.stop().await.unwrap();
10337 }
10338
10339 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10343 async fn protocol_none_repeated_clean_exits_exhaust_the_restart_budget() {
10344 let supervisor = Supervisor::new_for_test(
10345 Arc::new(Registry::default()),
10346 RestartPolicy::new(1, Duration::ZERO),
10347 );
10348 let module = supervisor
10349 .spawn(ModuleSpec {
10350 module_id: "none-clean-exit-budget".to_string(),
10351 program: fake_aft_stub_path(),
10352 args: Vec::new(),
10353 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "0".to_string())],
10354 reserved: false,
10355 reserved_prefixes: Vec::new(),
10356 protocol: ModuleProtocol::None,
10357 overlap: Default::default(),
10358 })
10359 .unwrap();
10360
10361 let deadline = Instant::now() + Duration::from_secs(10);
10364 loop {
10365 let status = module.status().unwrap();
10366 if status.state == ModuleState::Failed {
10367 break;
10368 }
10369 assert!(
10370 Instant::now() < deadline,
10371 "module never exhausted its budget: {status:?} {:?}",
10372 module.terminal_history()
10373 );
10374 sleep(Duration::from_millis(10)).await;
10375 }
10376 let history = module.terminal_history();
10377 assert_eq!(
10378 history
10379 .entries
10380 .iter()
10381 .map(|entry| (entry.exit_code, entry.disposition.clone()))
10382 .collect::<Vec<_>>(),
10383 vec![
10384 (Some(0), TerminalDisposition::Restarting),
10385 (Some(0), TerminalDisposition::Failed),
10386 ]
10387 );
10388 let detail = history.entries[1]
10389 .disposition_detail
10390 .as_deref()
10391 .expect("a budget failure names the budget");
10392 assert!(detail.contains("max_restarts=1"), "{detail}");
10393 assert_eq!(module.status().unwrap().spawn_generation, 2);
10394 }
10395
10396 #[test]
10397 fn restart_budget_failure_is_published_after_its_terminal_record() {
10398 let supervisor = Supervisor::new_for_test(
10399 Arc::new(Registry::default()),
10400 RestartPolicy::new(0, Duration::ZERO),
10401 );
10402 let runtime = supervisor.runtime_config();
10403 let spec = ModuleSpec {
10404 protocol: ModuleProtocol::None,
10405 ..windowed_crash_spec("budget-publication-order")
10406 };
10407 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(
10408 ModuleState::Running,
10409 true,
10410 )));
10411 let reaped_snapshot = snapshot.clone();
10412 let events = runtime.spawn_events.clone();
10413 let history = runtime.terminal_ring.clone();
10414
10415 let before_record = events.0.lock().unwrap();
10419 let reap = std::thread::spawn(move || {
10420 tokio::runtime::Builder::new_current_thread()
10421 .enable_all()
10422 .build()
10423 .unwrap()
10424 .block_on(on_child_exit(
10425 &spec,
10426 runtime.restart_policy,
10427 &supervisor.registry,
10428 &reaped_snapshot,
10429 &runtime.terminal_ring,
10430 &runtime.spawn_events,
10431 &runtime.child_roster,
10432 ExitReport {
10433 kind: ExitKind::Clean,
10434 code: Some(0),
10435 signal: None,
10436 at_ms: 1,
10437 },
10438 ))
10439 });
10440 let deadline = Instant::now() + Duration::from_secs(10);
10442 let before_state = loop {
10443 let state = lock_snapshot(&snapshot).unwrap();
10444 if state.last_exit.is_some() {
10445 break state.state;
10446 }
10447 drop(state);
10448 assert!(Instant::now() < deadline, "exit decision was not reached");
10449 std::thread::yield_now();
10450 };
10451 let before_history = history.lock().unwrap().snapshot();
10452 drop(before_record);
10453 assert!(matches!(reap.join().unwrap(), NextAction::Stop { .. }));
10454 assert!(before_history.entries.is_empty());
10455 assert_ne!(
10456 before_state,
10457 ModuleState::Failed,
10458 "Failed was visible before its terminal record could be written"
10459 );
10460 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
10461 let history = history.lock().unwrap().snapshot();
10462 assert_eq!(history.entries.len(), 1);
10463 assert_eq!(history.entries[0].exit_code, Some(0));
10464 assert_eq!(history.entries[0].disposition, TerminalDisposition::Failed);
10465 }
10466
10467 #[tokio::test]
10468 async fn restart_budget_failure_remains_failed_when_journal_append_fails() {
10469 let dir = cortexkit_test_support::ScratchDir::new("budget-journal-failure");
10470 let path = dir.join("terminals.jsonl");
10471 std::fs::create_dir(&path).unwrap();
10472 let supervisor = Supervisor::new_for_test(
10473 Arc::new(Registry::default()),
10474 RestartPolicy::new(0, Duration::ZERO),
10475 )
10476 .with_terminal_journal(path, "budget-journal-failure".into());
10477 let runtime = supervisor.runtime_config();
10478 let spec = windowed_crash_spec("budget-journal-failure");
10479 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
10480 assert!(matches!(
10481 on_child_exit(
10482 &spec,
10483 runtime.restart_policy,
10484 &supervisor.registry,
10485 &snapshot,
10486 &runtime.terminal_ring,
10487 &runtime.spawn_events,
10488 &runtime.child_roster,
10489 crash_exit_report(1),
10490 )
10491 .await,
10492 NextAction::Stop { .. }
10493 ));
10494 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
10495 let history = runtime
10496 .terminal_ring
10497 .lock()
10498 .unwrap()
10499 .durable_history(&spec.module_id);
10500 assert!(history.journal_write_failures > 0);
10501 assert_eq!(history.entries.len(), 1);
10502 assert_eq!(history.entries[0].disposition, TerminalDisposition::Failed);
10503 }
10504
10505 #[test]
10506 fn restart_budget_failure_remains_failed_when_exit_recording_panics() {
10507 let supervisor = Supervisor::new_for_test(
10508 Arc::new(Registry::default()),
10509 RestartPolicy::new(0, Duration::ZERO),
10510 );
10511 let runtime = supervisor.runtime_config();
10512 let spec = windowed_crash_spec("budget-recording-panic");
10513 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
10514 runtime.spawn_events.emit_spawned(&spec.module_id, 1, 1);
10515 runtime.spawn_events.0.lock().unwrap().seq = u64::MAX;
10518 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
10519 tokio::runtime::Builder::new_current_thread()
10520 .enable_all()
10521 .build()
10522 .unwrap()
10523 .block_on(on_child_exit(
10524 &spec,
10525 runtime.restart_policy,
10526 &supervisor.registry,
10527 &snapshot,
10528 &runtime.terminal_ring,
10529 &runtime.spawn_events,
10530 &runtime.child_roster,
10531 crash_exit_report(1),
10532 ))
10533 }));
10534 let panic = result.err().expect("recording must still unwind");
10535 assert_eq!(
10536 panic.downcast_ref::<String>().map(String::as_str),
10537 Some("spawn event sequence exhausted")
10538 );
10539 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
10540 }
10541
10542 #[cfg(unix)]
10545 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10546 async fn protocol_none_requested_stop_and_disable_do_not_respawn() {
10547 for disable in [false, true] {
10548 let label = if disable {
10549 "none-requested-disable"
10550 } else {
10551 "none-requested-stop"
10552 };
10553 let dir = cortexkit_test_support::ScratchDir::new(label);
10554 let (spec, ready, marker) = protocol_none_sigterm_exits_clean_spec(label, dir.path());
10555 let supervisor = Supervisor::new_for_test(
10556 Arc::new(Registry::default()),
10557 RestartPolicy::new(3, Duration::ZERO),
10558 );
10559 let module = supervisor.spawn(spec).unwrap();
10560 wait_for_file(&ready).await;
10561
10562 if disable {
10563 module.set_enabled(false).await.unwrap();
10564 } else {
10565 module.stop().await.unwrap();
10566 }
10567 assert!(
10568 marker.exists(),
10569 "{label}: the child must have left through its SIGTERM handler with exit 0"
10570 );
10571
10572 sleep(Duration::from_millis(500)).await;
10575 let status = module.status().unwrap();
10576 let expected = if disable {
10577 ModuleState::Disabled
10578 } else {
10579 ModuleState::Stopped
10580 };
10581 assert_eq!(status.state, expected, "{label}");
10582 assert_eq!(
10583 status.spawn_generation, 1,
10584 "{label}: respawned after a requested stop"
10585 );
10586 let history = module.terminal_history();
10587 assert_eq!(history.entries.len(), 1, "{label}: {history:?}");
10588 assert_eq!(history.entries[0].exit_code, Some(0), "{label}");
10589 assert_ne!(
10590 history.entries[0].disposition,
10591 TerminalDisposition::Restarting,
10592 "{label}"
10593 );
10594 }
10595 }
10596
10597 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10600 async fn subc_wire_clean_exit_is_still_a_stop() {
10601 let supervisor = Supervisor::new_for_test(
10602 Arc::new(Registry::default()),
10603 RestartPolicy::new(3, Duration::ZERO),
10604 );
10605 let module = supervisor
10606 .spawn(ModuleSpec {
10607 module_id: "wire-clean-exit".to_string(),
10608 program: fake_aft_stub_path(),
10609 args: Vec::new(),
10610 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "0".to_string())],
10611 reserved: false,
10612 reserved_prefixes: Vec::new(),
10613 protocol: ModuleProtocol::Subc,
10614 overlap: Default::default(),
10615 })
10616 .unwrap();
10617
10618 let deadline = Instant::now() + Duration::from_secs(10);
10619 while module.terminal_history().entries.is_empty() {
10620 assert!(Instant::now() < deadline, "module never exited");
10621 sleep(Duration::from_millis(10)).await;
10622 }
10623 sleep(Duration::from_millis(500)).await;
10625 let status = module.status().unwrap();
10626 assert_eq!(status.state, ModuleState::Stopped);
10627 assert_eq!(status.spawn_generation, 1);
10628 let history = module.terminal_history();
10629 assert_eq!(history.entries.len(), 1, "{history:?}");
10630 assert_eq!(history.entries[0].exit_code, Some(0));
10631 assert_eq!(history.entries[0].disposition, TerminalDisposition::Stopped);
10632 }
10633
10634 #[cfg(unix)]
10635 #[tokio::test]
10636 async fn reload_exit_keeps_roster_until_terminal_is_recorded_during_shutdown() {
10637 let dir = cortexkit_test_support::ScratchDir::new("reload-roster-terminal-order");
10638 let record = dir.join("live-children.json");
10639 let supervisor = Supervisor::new_for_test(
10640 Arc::new(Registry::default()),
10641 RestartPolicy::new(0, Duration::ZERO),
10642 );
10643 let mut runtime = supervisor.runtime_config();
10644 runtime.child_roster.record_to(record.clone());
10645 let gate = Arc::new(super::ReloadExitRecordGate::default());
10646 runtime.test_reload_exit_record_gate = Some(Arc::clone(&gate));
10647 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
10648 let spec = ModuleSpec {
10649 module_id: "reload-exit-roster".into(),
10650 program: fake_aft_stub_path(),
10651 args: Vec::new(),
10652 env: vec![("FAKE_AFT_EXIT_CODE".into(), "23".into())],
10653 reserved: false,
10654 reserved_prefixes: Vec::new(),
10655 protocol: ModuleProtocol::Subc,
10656 overlap: Default::default(),
10657 };
10658 let mut child = None;
10659 let reload = super::finish_reload_child(
10660 &spec,
10661 &runtime,
10662 &supervisor.registry,
10663 &supervisor.process_liveness,
10664 &snapshot,
10665 &mut child,
10666 );
10667 tokio::pin!(reload);
10668 tokio::select! {
10669 result = &mut reload => panic!("reload missed the before-record gate: {result:?}"),
10670 _ = gate.reached.notified() => {}
10671 }
10672 assert!(runtime
10673 .terminal_ring
10674 .lock()
10675 .unwrap()
10676 .snapshot()
10677 .entries
10678 .is_empty());
10679 assert_eq!(
10680 crate::live_children::read_record(&record).unwrap().len(),
10681 1,
10682 "shutdown must still wait for the reaped child until its terminal record exists"
10683 );
10684 runtime.child_roster.close();
10685 gate.resume.notify_one();
10686 assert!(reload.await.is_err());
10687 assert!(crate::live_children::read_record(&record)
10688 .unwrap()
10689 .is_empty());
10690 let history = runtime.terminal_ring.lock().unwrap().snapshot();
10691 assert_eq!(history.entries.len(), 1);
10692 assert_eq!(
10693 history.entries[0].disposition,
10694 TerminalDisposition::DaemonShutdown
10695 );
10696 }
10697
10698 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10702 async fn every_restart_increment_path_advances_lifetime_count() {
10703 let supervisor = Supervisor::new_for_test(
10704 Arc::new(Registry::default()),
10705 RestartPolicy::new(1, Duration::ZERO),
10706 );
10707 let runtime = supervisor.runtime_config();
10708 let spec = ModuleSpec {
10709 module_id: "lifetime-increment-path".to_string(),
10710 program: PathBuf::from("/unused/lifetime-increment-path"),
10711 args: Vec::new(),
10712 env: Vec::new(),
10713 reserved: false,
10714 reserved_prefixes: Vec::new(),
10715 protocol: ModuleProtocol::Subc,
10716 overlap: Default::default(),
10717 };
10718
10719 let crash_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
10720 assert!(matches!(
10721 on_child_exit(
10722 &spec,
10723 runtime.restart_policy,
10724 &supervisor.registry,
10725 &crash_snapshot,
10726 &runtime.terminal_ring,
10727 &runtime.spawn_events,
10728 &runtime.child_roster,
10729 ExitReport {
10730 kind: ExitKind::Crash,
10731 code: Some(1),
10732 signal: None,
10733 at_ms: 1,
10734 },
10735 )
10736 .await,
10737 NextAction::Restart { schedule: _ }
10738 ));
10739 let (crash_restarts, crash_lifetime) = {
10740 let state = lock_snapshot(&crash_snapshot).unwrap();
10741 (state.crash_restarts.len(), state.lifetime_restarts)
10742 };
10743 assert_eq!(crash_restarts, 1);
10744 assert_eq!(crash_lifetime, 1);
10745
10746 let health_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
10747 let mut health_child = None;
10748 assert!(matches!(
10749 health_restart_child(
10750 &spec,
10751 &runtime,
10752 &supervisor.registry,
10753 &supervisor.process_liveness,
10754 &health_snapshot,
10755 &mut health_child,
10756 SupervisorHealthStatus::Failing,
10757 None,
10758 2,
10759 )
10760 .await,
10761 Ok(())
10762 ));
10763 assert!(health_child.is_none());
10764 assert!(lock_snapshot(&health_snapshot).unwrap().respawn_pending);
10765 assert!(runtime.scheduled_respawn.lock().unwrap().take().is_some());
10766 let (health_restarts, health_lifetime) = {
10767 let state = lock_snapshot(&health_snapshot).unwrap();
10768 (state.crash_restarts.len(), state.lifetime_restarts)
10769 };
10770 assert_eq!(health_restarts, 1);
10771 assert_eq!(health_lifetime, 1);
10772
10773 let reload_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
10774 let mut reload_child = None;
10775 assert!(matches!(
10776 handle_reload_spawn_failure(
10777 &spec,
10778 &runtime,
10779 &supervisor.process_liveness,
10780 &reload_snapshot,
10781 &mut reload_child,
10782 "forced reload spawn failure".to_string(),
10783 )
10784 .await,
10785 Err(SuperviseError::ReloadFailed { .. })
10786 ));
10787 let (reload_restarts, reload_lifetime) = {
10788 let state = lock_snapshot(&reload_snapshot).unwrap();
10789 (state.crash_restarts.len(), state.lifetime_restarts)
10790 };
10791 assert_eq!(reload_restarts, 1);
10792 assert_eq!(reload_lifetime, 1);
10793 }
10794
10795 #[tokio::test]
10796 async fn deliberately_severed_live_child_records_lifetime_without_spending_restart_budget() {
10797 let supervisor = Supervisor::new_for_test(
10798 Arc::new(Registry::default()),
10799 RestartPolicy::new(3, Duration::ZERO),
10800 );
10801 let runtime = supervisor.runtime_config();
10802 let spec = ModuleSpec {
10803 module_id: "deliberately-severed".to_string(),
10804 program: PathBuf::from("/unused/deliberately-severed"),
10805 args: Vec::new(),
10806 env: Vec::new(),
10807 reserved: false,
10808 reserved_prefixes: Vec::new(),
10809 protocol: ModuleProtocol::Subc,
10810 overlap: Default::default(),
10811 };
10812 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
10813 let process = ProcessIdentity {
10814 pid: 41,
10815 start_time: 101,
10816 };
10817 record_deliberate_severance(&snapshot, process).unwrap();
10818 let exit_report = apply_deliberate_severance_marker(
10819 &snapshot,
10820 Some(process),
10821 ExitReport {
10822 kind: ExitKind::Crash,
10823 code: Some(1),
10824 signal: None,
10825 at_ms: 1,
10826 },
10827 );
10828 assert_eq!(exit_report.kind, ExitKind::DeliberateSeverance);
10829
10830 assert!(matches!(
10831 on_child_exit(
10832 &spec,
10833 runtime.restart_policy,
10834 &supervisor.registry,
10835 &snapshot,
10836 &runtime.terminal_ring,
10837 &runtime.spawn_events,
10838 &runtime.child_roster,
10839 exit_report,
10840 )
10841 .await,
10842 NextAction::Restart { schedule: _ }
10843 ));
10844 let state = lock_snapshot(&snapshot).unwrap();
10845 assert_eq!(state.lifetime_restarts, 1);
10846 assert_eq!(state.crash_restarts.len(), 0);
10847 }
10848
10849 #[tokio::test]
10850 async fn genuine_crash_spends_restart_budget_and_records_lifetime() {
10851 let supervisor = Supervisor::new_for_test(
10852 Arc::new(Registry::default()),
10853 RestartPolicy::new(3, Duration::ZERO),
10854 );
10855 let runtime = supervisor.runtime_config();
10856 let spec = ModuleSpec {
10857 module_id: "genuine-crash".to_string(),
10858 program: PathBuf::from("/unused/genuine-crash"),
10859 args: Vec::new(),
10860 env: Vec::new(),
10861 reserved: false,
10862 reserved_prefixes: Vec::new(),
10863 protocol: ModuleProtocol::Subc,
10864 overlap: Default::default(),
10865 };
10866 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
10867
10868 assert!(matches!(
10869 on_child_exit(
10870 &spec,
10871 runtime.restart_policy,
10872 &supervisor.registry,
10873 &snapshot,
10874 &runtime.terminal_ring,
10875 &runtime.spawn_events,
10876 &runtime.child_roster,
10877 ExitReport {
10878 kind: ExitKind::Crash,
10879 code: Some(1),
10880 signal: None,
10881 at_ms: 1,
10882 },
10883 )
10884 .await,
10885 NextAction::Restart { schedule: _ }
10886 ));
10887 let state = lock_snapshot(&snapshot).unwrap();
10888 assert_eq!(state.lifetime_restarts, 1);
10889 assert_eq!(state.crash_restarts.len(), 1);
10890 }
10891
10892 fn crash_exit_report(at_ms: u64) -> ExitReport {
10893 ExitReport {
10894 kind: ExitKind::Crash,
10895 code: Some(1),
10896 signal: None,
10897 at_ms,
10898 }
10899 }
10900
10901 fn windowed_crash_spec(module_id: &str) -> ModuleSpec {
10902 ModuleSpec {
10903 module_id: module_id.to_string(),
10904 program: PathBuf::from("/unused").join(module_id),
10905 args: Vec::new(),
10906 env: Vec::new(),
10907 reserved: false,
10908 reserved_prefixes: Vec::new(),
10909 protocol: ModuleProtocol::Subc,
10910 overlap: Default::default(),
10911 }
10912 }
10913
10914 #[tokio::test]
10920 async fn three_crashes_inside_the_window_stop_the_module_and_name_the_window() {
10921 let (logs, _guard) = crate::router::test_log::log_capture(tracing::Level::ERROR);
10922 let supervisor = Supervisor::new_for_test(
10923 Arc::new(Registry::default()),
10924 RestartPolicy::new(2, Duration::ZERO),
10925 );
10926 let runtime = supervisor.runtime_config();
10927 let spec = windowed_crash_spec("crash-loop-in-window");
10928 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
10929
10930 for attempt in 1..=2 {
10931 assert!(
10932 matches!(
10933 on_child_exit(
10934 &spec,
10935 runtime.restart_policy,
10936 &supervisor.registry,
10937 &snapshot,
10938 &runtime.terminal_ring,
10939 &runtime.spawn_events,
10940 &runtime.child_roster,
10941 crash_exit_report(attempt),
10942 )
10943 .await,
10944 NextAction::Restart { schedule: _ }
10945 ),
10946 "crash {attempt} is inside the budget and must respawn"
10947 );
10948 }
10949
10950 assert!(matches!(
10951 on_child_exit(
10952 &spec,
10953 runtime.restart_policy,
10954 &supervisor.registry,
10955 &snapshot,
10956 &runtime.terminal_ring,
10957 &runtime.spawn_events,
10958 &runtime.child_roster,
10959 crash_exit_report(3),
10960 )
10961 .await,
10962 NextAction::Stop { .. }
10963 ));
10964
10965 {
10966 let state = lock_snapshot(&snapshot).unwrap();
10967 assert_eq!(state.state, ModuleState::Failed);
10968 assert_eq!(state.crash_restarts.len(), 2);
10969 assert_eq!(state.lifetime_restarts, 2);
10970 }
10971
10972 let history = runtime
10973 .terminal_ring
10974 .lock()
10975 .expect("terminal ring is not poisoned")
10976 .snapshot();
10977 let last = history
10978 .entries
10979 .last()
10980 .expect("the refused crash is retained");
10981 assert_eq!(last.disposition, TerminalDisposition::Failed);
10982 assert_eq!(
10983 last.disposition_detail.as_deref(),
10984 Some("crash budget exhausted: max_restarts=2 within window_secs=600")
10985 );
10986
10987 let captured = crate::router::test_log::captured_logs(&logs);
10988 assert!(
10989 captured.contains("crash budget exhausted: max_restarts=2 within window_secs=600"),
10990 "the stop must be logged with its window: {captured}"
10991 );
10992 }
10993
10994 #[tokio::test]
11002 async fn a_crash_older_than_the_window_frees_its_slot_for_a_later_crash() {
11003 let supervisor = Supervisor::new_for_test(
11004 Arc::new(Registry::default()),
11005 RestartPolicy::new(2, Duration::ZERO),
11006 );
11007 let runtime = supervisor.runtime_config();
11008 let spec = windowed_crash_spec("crash-across-windows");
11009 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11010
11011 for attempt in 1..=2 {
11012 assert!(matches!(
11013 on_child_exit(
11014 &spec,
11015 runtime.restart_policy,
11016 &supervisor.registry,
11017 &snapshot,
11018 &runtime.terminal_ring,
11019 &runtime.spawn_events,
11020 &runtime.child_roster,
11021 crash_exit_report(attempt),
11022 )
11023 .await,
11024 NextAction::Restart { schedule: _ }
11025 ));
11026 }
11027
11028 update_snapshot(&snapshot, Some(&spec.module_id), |state| {
11031 age_oldest_crash_restart_out_of_window(state, runtime.restart_policy.window);
11032 })
11033 .unwrap();
11034
11035 assert!(
11036 matches!(
11037 on_child_exit(
11038 &spec,
11039 runtime.restart_policy,
11040 &supervisor.registry,
11041 &snapshot,
11042 &runtime.terminal_ring,
11043 &runtime.spawn_events,
11044 &runtime.child_roster,
11045 crash_exit_report(3),
11046 )
11047 .await,
11048 NextAction::Restart { schedule: _ }
11049 ),
11050 "a crash older than the window must not hold a budget slot"
11051 );
11052
11053 let state = lock_snapshot(&snapshot).unwrap();
11054 assert_eq!(state.state, ModuleState::Restarting);
11055 assert_eq!(
11056 state.crash_restarts.len(),
11057 2,
11058 "the aged instant is dropped and the new one takes its place"
11059 );
11060 assert_eq!(
11061 state.lifetime_restarts, 3,
11062 "the ledger counts every restart, including the ones the window forgot"
11063 );
11064 }
11065
11066 #[tokio::test]
11071 async fn an_operator_restart_clears_the_ring_and_leaves_the_ledger_alone() {
11072 let supervisor = Supervisor::new_for_test(
11073 Arc::new(Registry::default()),
11074 RestartPolicy::new(2, Duration::ZERO),
11075 );
11076 let runtime = supervisor.runtime_config();
11077 let spec = windowed_crash_spec("operator-cleared-budget");
11078 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11079
11080 for attempt in 1..=2 {
11081 assert!(matches!(
11082 on_child_exit(
11083 &spec,
11084 runtime.restart_policy,
11085 &supervisor.registry,
11086 &snapshot,
11087 &runtime.terminal_ring,
11088 &runtime.spawn_events,
11089 &runtime.child_roster,
11090 crash_exit_report(attempt),
11091 )
11092 .await,
11093 NextAction::Restart { schedule: _ }
11094 ));
11095 }
11096
11097 reset_restart_count(&snapshot, &spec.module_id).unwrap();
11098 {
11099 let state = lock_snapshot(&snapshot).unwrap();
11100 assert!(
11101 state.crash_restarts.is_empty(),
11102 "an operator restart returns the full budget"
11103 );
11104 assert_eq!(
11105 state.lifetime_restarts, 2,
11106 "clearing the budget must not unmake the crashes"
11107 );
11108 }
11109
11110 assert!(
11111 matches!(
11112 on_child_exit(
11113 &spec,
11114 runtime.restart_policy,
11115 &supervisor.registry,
11116 &snapshot,
11117 &runtime.terminal_ring,
11118 &runtime.spawn_events,
11119 &runtime.child_roster,
11120 crash_exit_report(3),
11121 )
11122 .await,
11123 NextAction::Restart { schedule: _ }
11124 ),
11125 "the cleared budget must be spendable again"
11126 );
11127 let state = lock_snapshot(&snapshot).unwrap();
11128 assert_eq!(state.crash_restarts.len(), 1);
11129 assert_eq!(state.lifetime_restarts, 3);
11130 }
11131
11132 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11133 async fn severance_marker_for_a_dead_child_does_not_label_its_successor() {
11134 let severed = ProcessIdentity {
11135 pid: 41,
11136 start_time: 101,
11137 };
11138 let successor = ProcessIdentity {
11139 pid: 41,
11140 start_time: 202,
11141 };
11142 let module = module_with_recovery_snapshot(ModuleState::Running, true, 0);
11143 update_snapshot(&module.inner.snapshot, Some("recovery-snapshot"), |state| {
11144 state.pid = Some(successor.pid);
11145 state.process_start_time = Some(successor.start_time);
11146 })
11147 .unwrap();
11148 assert!(!module.record_deliberate_severance(severed).unwrap());
11149
11150 let exit_report = apply_deliberate_severance_marker(
11151 &module.inner.snapshot,
11152 Some(successor),
11153 ExitReport {
11154 kind: ExitKind::Crash,
11155 code: Some(1),
11156 signal: None,
11157 at_ms: 1,
11158 },
11159 );
11160
11161 assert_eq!(exit_report.kind, ExitKind::Crash);
11162 }
11163
11164 #[tokio::test]
11165 async fn drain_reap_marks_deliberate_severance_and_records_lifetime_without_budget() {
11166 let registry = Registry::default();
11167 let supervisor = Supervisor::new_for_test(
11168 Arc::new(Registry::default()),
11169 RestartPolicy::new(3, Duration::ZERO),
11170 );
11171 let runtime = supervisor.runtime_config();
11172 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11173 let spec = ModuleSpec {
11174 module_id: "drain-deliberate-severance".to_string(),
11175 program: fake_aft_stub_path(),
11176 args: Vec::new(),
11177 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
11178 reserved: false,
11179 reserved_prefixes: Vec::new(),
11180 protocol: ModuleProtocol::Subc,
11181 overlap: Default::default(),
11182 };
11183 let mut child = spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap();
11184 let process = ProcessIdentity {
11185 pid: 41,
11186 start_time: 101,
11187 };
11188 child.process_identity = Some(process);
11189 update_snapshot(&snapshot, Some(&spec.module_id), |state| {
11190 state.pid = Some(process.pid);
11191 state.process_start_time = Some(process.start_time);
11192 })
11193 .unwrap();
11194 record_deliberate_severance(&snapshot, process).unwrap();
11195
11196 drain_child_to_state(
11197 &spec.module_id,
11198 spec.protocol,
11199 StopNotice::SentOverConnection,
11202 ®istry,
11203 None,
11204 &snapshot,
11205 &runtime.terminal_ring,
11206 &runtime.spawn_events,
11207 child,
11208 Duration::from_secs(1),
11209 ModuleState::Stopped,
11210 Some(false),
11211 )
11212 .await
11213 .unwrap();
11214
11215 let state = lock_snapshot(&snapshot).unwrap();
11216 assert_eq!(
11217 state.last_exit.as_ref().map(|exit| exit.kind),
11218 Some(ExitKind::DeliberateSeverance)
11219 );
11220 assert_eq!(state.lifetime_restarts, 1);
11221 assert_eq!(state.crash_restarts.len(), 0);
11222 drop(state);
11223 let history = runtime.terminal_ring.lock().unwrap().snapshot();
11224 assert_eq!(
11225 history.entries[0].exit_kind,
11226 subc_control::TerminalExitKind::DeliberateSeverance
11227 );
11228 }
11229
11230 #[tokio::test]
11231 async fn ordinary_drain_reap_does_not_record_a_lifetime_restart() {
11232 let registry = Registry::default();
11233 let supervisor = Supervisor::new_for_test(
11234 Arc::new(Registry::default()),
11235 RestartPolicy::new(3, Duration::ZERO),
11236 );
11237 let runtime = supervisor.runtime_config();
11238 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11239 let spec = ModuleSpec {
11240 module_id: "ordinary-drain".to_string(),
11241 program: fake_aft_stub_path(),
11242 args: Vec::new(),
11243 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
11244 reserved: false,
11245 reserved_prefixes: Vec::new(),
11246 protocol: ModuleProtocol::Subc,
11247 overlap: Default::default(),
11248 };
11249 let child = spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap();
11250
11251 drain_child_to_state(
11252 &spec.module_id,
11253 spec.protocol,
11254 StopNotice::SentOverConnection,
11257 ®istry,
11258 None,
11259 &snapshot,
11260 &runtime.terminal_ring,
11261 &runtime.spawn_events,
11262 child,
11263 Duration::from_secs(1),
11264 ModuleState::Stopped,
11265 Some(false),
11266 )
11267 .await
11268 .unwrap();
11269
11270 let state = lock_snapshot(&snapshot).unwrap();
11271 assert_eq!(
11272 state.last_exit.as_ref().map(|exit| exit.kind),
11273 Some(ExitKind::Crash)
11274 );
11275 assert_eq!(state.lifetime_restarts, 0);
11276 assert_eq!(state.crash_restarts.len(), 0);
11277 }
11278
11279 #[test]
11280 fn fatal_connection_teardown_cannot_arm_a_marker_for_a_surviving_process() {
11281 assert!(!include_str!("server.rs")
11287 .contains("router.record_deliberate_connection_severance(ctx.connection_id)"));
11288 }
11289
11290 #[test]
11297 fn drained_after_quiescence_wait_passes_ok_through_and_forces_false_on_err() {
11298 assert!(drained_after_quiescence_wait(&Ok(true)));
11299 assert!(!drained_after_quiescence_wait(&Ok(false)));
11300 assert!(!drained_after_quiescence_wait(&Err(
11301 SuperviseError::StatePoisoned { module_id: None }
11302 )));
11303 }
11304
11305 #[test]
11314 fn wait_error_exit_report_records_a_failed_terminal_with_no_code_or_signal() {
11315 let ring = Arc::new(Mutex::new(TerminalRing::new(
11316 TerminalRingConfig::default(),
11317 0,
11318 )));
11319 record_wait_error_terminal("wait-error", &ring, &super::SpawnEventFeed::default());
11320
11321 let snapshot = ring.lock().unwrap().snapshot();
11322 assert_eq!(snapshot.entries.len(), 1);
11323 let entry = &snapshot.entries[0];
11324 assert_eq!(entry.exit_code, None);
11325 assert_eq!(entry.exit_signal, None);
11326 assert_eq!(entry.disposition, TerminalDisposition::Failed);
11327 }
11328
11329 #[test]
11330 fn wait_error_exit_path_preserves_spawn_event_density() {
11331 let feed = super::SpawnEventFeed::default();
11332 feed.configure_incarnation("wait-error-density".to_string());
11333 feed.emit_spawned("wait-error", 41, 1);
11334 let ring = Arc::new(Mutex::new(TerminalRing::new(
11335 TerminalRingConfig::default(),
11336 0,
11337 )));
11338
11339 record_wait_error_terminal("wait-error", &ring, &feed);
11340 feed.emit_spawned("after-wait-error", 42, 2);
11341
11342 let state = feed.0.lock().unwrap();
11343 let sequences = state
11344 .events
11345 .iter()
11346 .map(|event| event.cursor.seq)
11347 .collect::<Vec<_>>();
11348 assert_eq!(sequences, vec![1, 2, 3]);
11349 assert_eq!(state.events[1].kind, SpawnEventKind::Exited);
11350 assert_eq!(state.events[1].exit_code, None);
11351 assert_eq!(state.events[1].exit_signal, None);
11352 }
11353
11354 #[test]
11358 fn wait_error_exit_report_is_classified_as_a_crash() {
11359 assert_eq!(wait_error_exit_report().kind, ExitKind::Crash);
11360 }
11361}
11362
11363#[cfg(all(test, unix))]
11364mod health_event_tests {
11365 use super::*;
11366 use cortexkit_test_support::ScratchDir;
11367 use subc_protocol::{manifest::Concurrency, session::ModuleControlResponse};
11368
11369 struct Actor {
11370 module: SupervisedModule,
11371 registry: Arc<Registry>,
11372 forwarding: Arc<ForwardingTable>,
11373 spec: ModuleSpec,
11374 health: HealthConfig,
11375 rx: mpsc::Receiver<crate::router::OutboundFrame>,
11376 _home: ScratchDir,
11377 }
11378
11379 async fn wait_for<T>(reason: &str, mut observe: impl FnMut() -> Option<T>) -> T {
11380 let deadline = std::time::Instant::now() + Duration::from_secs(10);
11384 let tick = Instant::now();
11385 loop {
11386 if let Some(value) = observe() {
11387 assert_eq!(Instant::now(), tick, "{reason} must not wait for a timer");
11388 return value;
11389 }
11390 assert!(
11391 std::time::Instant::now() < deadline,
11392 "timed out waiting for {reason}"
11393 );
11394 tokio::task::yield_now().await;
11395 }
11396 }
11397
11398 impl Actor {
11399 async fn start(protocol: ModuleProtocol, cadence: Duration) -> Self {
11400 let home = ScratchDir::new("health-events");
11401 let registry = Arc::new(Registry::default());
11402 let forwarding = Arc::new(ForwardingTable::default());
11403 let health = HealthConfig {
11404 cadence,
11405 deadline: Duration::from_secs(3600),
11406 ..HealthConfig::default()
11407 };
11408 let supervisor =
11409 Supervisor::new_for_test(registry.clone(), RestartPolicy::new(3, Duration::ZERO))
11410 .with_forwarding(forwarding.clone())
11411 .with_health_config(health.clone());
11412 let spec = ModuleSpec {
11413 module_id: "event-health-child".into(),
11414 program: PathBuf::from("/bin/sh"),
11415 args: vec!["-c".into(), "exec sleep 600".into()],
11418 env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
11419 .into_iter()
11420 .map(|key| (key.into(), home.path().display().to_string()))
11421 .collect(),
11422 reserved: false,
11423 reserved_prefixes: vec![],
11424 protocol,
11425 overlap: Default::default(),
11426 };
11427 let module = supervisor.spawn(spec.clone()).unwrap();
11428 let (_, rx) = mpsc::channel(8);
11429 let actor = Self {
11430 module,
11431 registry,
11432 forwarding,
11433 spec,
11434 health,
11435 rx,
11436 _home: home,
11437 };
11438 actor
11439 .wait_for_select(|checkpoint| checkpoint.generation > 0)
11440 .await;
11441 actor
11442 }
11443
11444 fn turns(&self) -> u64 {
11445 lock_snapshot(&self.module.inner.snapshot)
11446 .unwrap()
11447 .actor_turns
11448 }
11449
11450 async fn wait_for_select(
11451 &self,
11452 expected: impl Fn(&ActorSelectCheckpoint) -> bool,
11453 ) -> ActorSelectCheckpoint {
11454 wait_for("actor select checkpoint", || {
11455 let state = lock_snapshot(&self.module.inner.snapshot).unwrap();
11456 state.actor_select.filter(|checkpoint| {
11457 checkpoint.generation == state.spawn_generation
11458 && state.state == ModuleState::Running
11459 && state.process_alive
11460 && state.reported_pid().is_some()
11461 && expected(checkpoint)
11462 })
11463 })
11464 .await
11465 }
11466
11467 fn hello(&mut self, connection: u64, health: bool) {
11468 let manifest =
11469 subc_protocol::manifest::ModuleManifest::builder(&self.spec.module_id, "1.0.0")
11470 .protocol_ver(subc_protocol::PROTOCOL_VERSION)
11471 .build();
11472 let connection = ConnectionId::new(connection);
11473 let (tx, rx) = mpsc::channel(8);
11474 self.rx = rx;
11475 self.forwarding
11476 .register_module_connection(
11477 connection,
11478 self.spec.module_id.clone(),
11479 subc_protocol::PROTOCOL_VERSION,
11480 Concurrency::ModuleManaged,
11481 FrameSink::new(tx),
11482 )
11483 .unwrap();
11484 self.registry
11485 .register_with_control_ops(
11486 manifest,
11487 subc_protocol::PROTOCOL_VERSION,
11488 connection,
11489 if health {
11490 vec![MODULE_CONTROL_OP_HEALTH_CHECK.into()]
11491 } else {
11492 vec![]
11493 },
11494 )
11495 .unwrap();
11496 }
11497
11498 async fn probe(&mut self) -> crate::router::OutboundFrame {
11499 let frame = wait_for("outbound health probe", || match self.rx.try_recv() {
11500 Ok(frame) => Some(frame),
11501 Err(mpsc::error::TryRecvError::Empty) => None,
11502 Err(mpsc::error::TryRecvError::Disconnected) => panic!("health peer disconnected"),
11503 })
11504 .await;
11505 let body: Value = serde_json::from_slice(&frame.body).unwrap();
11506 assert_eq!(body["op"], MODULE_CONTROL_OP_HEALTH_CHECK);
11507 frame
11508 }
11509
11510 fn no_probe(&mut self) {
11511 assert!(matches!(
11512 self.rx.try_recv(),
11513 Err(mpsc::error::TryRecvError::Empty)
11514 ));
11515 }
11516
11517 fn answer(&self, connection: u64, frame: crate::router::OutboundFrame) {
11518 self.forwarding
11519 .complete_module_control_rpc(
11520 ConnectionId::new(connection),
11521 frame.header.corr,
11522 Some(MODULE_CONTROL_OP_HEALTH_CHECK),
11523 ModuleControlRpcOutcome::Response(ModuleControlResponse::HealthCheck {
11524 status: HealthStatus::Ok,
11525 detail: None,
11526 metrics: None,
11527 }),
11528 )
11529 .unwrap();
11530 }
11531 }
11532
11533 #[tokio::test(start_paused = true)]
11534 async fn no_health_children_park_for_sixty_seconds() {
11535 let none = Actor::start(ModuleProtocol::None, Duration::from_secs(30)).await;
11536 let unregistered = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
11537 let mut unadvertised = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
11538 unadvertised.hello(1, false);
11539 for _ in 0..6000 {
11544 tokio::time::advance(Duration::from_millis(10)).await;
11545 tokio::task::yield_now().await;
11546 }
11547 for actor in [&none, &unregistered, &unadvertised] {
11548 assert!(
11549 actor.turns() <= 5,
11550 "no-health actor woke {} times",
11551 actor.turns()
11552 );
11553 assert_eq!(actor.module.state().unwrap(), ModuleState::Running);
11554 }
11555 }
11556
11557 #[tokio::test(start_paused = true)]
11558 async fn late_hello_starts_a_due_probe_in_the_notification_tick() {
11559 let mut actor = Actor::start(ModuleProtocol::Subc, Duration::ZERO).await;
11563 let tick = Instant::now();
11564 actor.hello(1, true);
11565 actor.probe().await;
11566 assert_eq!(Instant::now(), tick, "HELLO must not wait for a poll");
11567 }
11568
11569 #[tokio::test(start_paused = true)]
11570 async fn hello_keeps_the_jittered_cadence_and_catalog_updates_do_not_reset_it() {
11571 let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
11572 let hello_tick = Instant::now();
11573 actor.hello(1, true);
11574 let armed = actor
11575 .wait_for_select(|checkpoint| {
11576 checkpoint.registered_connection == Some(ConnectionId::new(1))
11577 && checkpoint.next_probe_at.is_some()
11578 })
11579 .await;
11580 let delay = jittered_health_delay(&actor.spec.module_id, 0, actor.health.cadence);
11581 assert!((Duration::from_secs(30)..Duration::from_secs(33)).contains(&delay));
11582 let first_deadline = hello_tick + delay;
11583 assert_eq!(armed.next_probe_at, Some(first_deadline));
11584 tokio::time::advance(Duration::from_secs(29)).await;
11585 let turns = actor.turns();
11586 actor
11587 .registry
11588 .replace_catalog_for_connection(ConnectionId::new(1), vec![], None, Some(true))
11589 .unwrap();
11590 let updated = actor
11591 .wait_for_select(|checkpoint| checkpoint.turn > turns)
11592 .await;
11593 assert_eq!(
11594 updated.next_probe_at,
11595 Some(first_deadline),
11596 "catalog update must not reset cadence"
11597 );
11598 actor.no_probe();
11599 tokio::time::advance(first_deadline - Instant::now()).await;
11600 let frame = actor.probe().await;
11601 actor.answer(1, frame);
11602 let next = jittered_health_delay(&actor.spec.module_id, 1, actor.health.cadence);
11603 let next_deadline = Instant::now() + next;
11604 let rearmed = actor
11605 .wait_for_select(|checkpoint| {
11606 checkpoint
11607 .next_probe_at
11608 .is_some_and(|deadline| deadline > first_deadline)
11609 })
11610 .await;
11611 assert_eq!(rearmed.next_probe_at, Some(next_deadline));
11612 tokio::time::advance(next - Duration::from_nanos(1)).await;
11613 actor.no_probe();
11614 tokio::time::advance(Duration::from_nanos(1)).await;
11615 actor.probe().await;
11616 }
11617
11618 #[tokio::test(start_paused = true)]
11619 async fn disconnect_disarms_and_reconnect_rearms_health() {
11620 let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
11621 actor.hello(1, true);
11622 actor
11623 .wait_for_select(|checkpoint| {
11624 checkpoint.registered_connection == Some(ConnectionId::new(1))
11625 && checkpoint.next_probe_at.is_some()
11626 })
11627 .await;
11628 let turns = actor.turns();
11629 let tick = Instant::now();
11630 actor
11631 .registry
11632 .deregister_connection(ConnectionId::new(1))
11633 .unwrap();
11634 let disarmed = actor
11635 .wait_for_select(|checkpoint| {
11636 checkpoint.turn > turns && checkpoint.registered_connection.is_none()
11637 })
11638 .await;
11639 assert_eq!(disarmed.next_probe_at, None);
11640 assert_eq!(disarmed.wake_after, None);
11641 assert_eq!(
11642 Instant::now(),
11643 tick,
11644 "disconnect must disarm in the notification tick"
11645 );
11646 tokio::time::advance(Duration::from_secs(60)).await;
11647 actor.no_probe();
11648 let reconnect_tick = Instant::now();
11649 actor.hello(2, true);
11650 let rearmed = actor
11651 .wait_for_select(|checkpoint| {
11652 checkpoint.registered_connection == Some(ConnectionId::new(2))
11653 && checkpoint.next_probe_at.is_some()
11654 })
11655 .await;
11656 let delay = jittered_health_delay(&actor.spec.module_id, 0, actor.health.cadence);
11657 assert_eq!(rearmed.next_probe_at, Some(reconnect_tick + delay));
11658 tokio::time::advance(delay).await;
11659 actor.probe().await;
11660 }
11661
11662 #[tokio::test(start_paused = true)]
11663 async fn reregistration_without_health_disarms_the_old_schedule() {
11664 let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
11665 actor.hello(1, true);
11666 actor
11667 .wait_for_select(|checkpoint| {
11668 checkpoint.registered_connection == Some(ConnectionId::new(1))
11669 && checkpoint.next_probe_at.is_some()
11670 })
11671 .await;
11672 actor
11673 .registry
11674 .deregister_connection(ConnectionId::new(1))
11675 .unwrap();
11676 actor.hello(2, false);
11677 let disarmed = actor
11678 .wait_for_select(|checkpoint| {
11679 checkpoint.registered_connection == Some(ConnectionId::new(2))
11680 })
11681 .await;
11682 assert_eq!(disarmed.next_probe_at, None);
11683 assert_eq!(disarmed.wake_after, None);
11684 tokio::time::advance(Duration::from_secs(60)).await;
11685 actor.no_probe();
11686 }
11687
11688 #[tokio::test(start_paused = true)]
11689 async fn restart_waits_for_its_own_hello_before_rearming_health() {
11690 let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
11691 actor.hello(1, true);
11692 let armed = actor
11693 .wait_for_select(|checkpoint| {
11694 checkpoint.registered_connection == Some(ConnectionId::new(1))
11695 && checkpoint.next_probe_at.is_some()
11696 })
11697 .await;
11698 actor
11702 .registry
11703 .deregister_connection(ConnectionId::new(1))
11704 .unwrap();
11705 actor.module.restart(Some(0)).await.unwrap();
11706 let parked = actor
11707 .wait_for_select(|checkpoint| {
11708 checkpoint.generation > armed.generation
11709 && checkpoint.registered_connection.is_none()
11710 && checkpoint.next_probe_at.is_none()
11711 })
11712 .await;
11713 assert_eq!(parked.wake_after, None);
11714 actor.rx.close();
11715 while actor.rx.try_recv().is_ok() {}
11718 let turns = actor.turns();
11719 tokio::time::advance(Duration::from_secs(60)).await;
11720 let after = actor.turns();
11721 eprintln!(
11722 "restart park turns: before={turns} after={after} delta={}",
11723 after - turns
11724 );
11725 assert_eq!(after, turns, "replacement must park before HELLO");
11726 let hello_tick = Instant::now();
11727 actor.hello(2, true);
11728 let rearmed = actor
11729 .wait_for_select(|checkpoint| {
11730 checkpoint.registered_connection == Some(ConnectionId::new(2))
11731 && checkpoint.next_probe_at.is_some()
11732 })
11733 .await;
11734 let delay = jittered_health_delay(&actor.spec.module_id, 0, actor.health.cadence);
11735 assert_eq!(rearmed.next_probe_at, Some(hello_tick + delay));
11736 tokio::time::advance(delay).await;
11737 actor.probe().await;
11738 }
11739
11740 #[tokio::test(start_paused = true)]
11741 async fn rescan_adds_and_removes_http_health_without_polling() {
11742 use tokio::{
11743 io::{AsyncReadExt, AsyncWriteExt},
11744 net::TcpListener,
11745 };
11746 let mut actor = Actor::start(ModuleProtocol::None, Duration::from_secs(30)).await;
11747 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
11748 actor.health.http = Some(format!("http://{}/health", listener.local_addr().unwrap()));
11749 actor.health.cadence = Duration::from_millis(1);
11751 let (started_tx, mut started_rx) = mpsc::channel(8);
11752 let peer = tokio::spawn(async move {
11753 loop {
11754 let (mut socket, _) = listener.accept().await.unwrap();
11755 let mut request = [0; 1024];
11756 assert!(socket.read(&mut request).await.unwrap() > 0);
11757 started_tx.send(()).await.unwrap();
11758 socket
11759 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")
11760 .await
11761 .unwrap();
11762 }
11763 });
11764 let tick = Instant::now();
11765 let turns = actor.turns();
11766 actor
11767 .module
11768 .update_configuration(actor.spec.clone(), actor.health.clone(), None)
11769 .await
11770 .unwrap();
11771 let armed = actor
11772 .wait_for_select(|checkpoint| {
11773 checkpoint.turn > turns && checkpoint.next_probe_at.is_some()
11774 })
11775 .await;
11776 assert_eq!(armed.next_probe_at, Some(tick + Duration::from_millis(1)));
11777 assert_eq!(Instant::now(), tick);
11778 tokio::time::advance(Duration::from_millis(1)).await;
11779 let mut started = false;
11780 wait_for("successful HTTP probe", || {
11781 started |= started_rx.try_recv().is_ok();
11782 (started && actor.module.status().unwrap().health.status == SupervisorHealthStatus::Ok)
11783 .then_some(())
11784 })
11785 .await;
11786 assert!(started, "added HTTP check must start at its first deadline");
11787 assert_eq!(
11788 actor.module.status().unwrap().health.status,
11789 SupervisorHealthStatus::Ok
11790 );
11791 assert_eq!(Instant::now(), tick + Duration::from_millis(1));
11792 actor.health.http = None;
11793 let turns = actor.turns();
11794 actor
11795 .module
11796 .update_configuration(actor.spec.clone(), actor.health.clone(), None)
11797 .await
11798 .unwrap();
11799 let parked = actor
11800 .wait_for_select(|checkpoint| {
11801 checkpoint.turn > turns && checkpoint.next_probe_at.is_none()
11802 })
11803 .await;
11804 assert_eq!(parked.wake_after, None);
11805 let turns = actor.turns();
11806 tokio::time::advance(Duration::from_secs(60)).await;
11807 assert_eq!(
11808 actor.turns(),
11809 turns,
11810 "removed HTTP check must park the actor"
11811 );
11812 assert!(
11813 started_rx.try_recv().is_err(),
11814 "removed HTTP check must stay disarmed"
11815 );
11816 assert_eq!(
11817 actor.module.status().unwrap().health,
11818 ModuleHealthStatus::default()
11819 );
11820 peer.abort();
11821 }
11822}
11823
11824#[cfg(test)]
11825mod health_evidence_tests {
11826 use super::{HealthProbeError, HealthProbeEvidence};
11827 use std::collections::HashSet;
11828
11829 #[test]
11837 fn only_a_dead_lane_is_proof_of_death() {
11838 assert!(HealthProbeError::lane_dead("gone").is_proof_of_death());
11839 assert!(!HealthProbeError::no_answer("timed out").is_proof_of_death());
11843 assert!(!HealthProbeError::bad_answer("garbage").is_proof_of_death());
11844 assert!(!HealthProbeError::misconfigured("no table").is_proof_of_death());
11845 }
11846
11847 #[test]
11853 fn every_evidence_class_has_a_distinct_label() {
11854 let labels = [
11855 HealthProbeError::lane_dead("").label(),
11856 HealthProbeError::no_answer("").label(),
11857 HealthProbeError::bad_answer("").label(),
11858 HealthProbeError::misconfigured("").label(),
11859 ];
11860 let unique: HashSet<_> = labels.iter().collect();
11861 assert_eq!(unique.len(), labels.len(), "labels collided: {labels:?}");
11862 }
11863
11864 #[test]
11870 fn classification_preserves_the_original_message() {
11871 let err = HealthProbeError::no_answer("module did not answer within 5s");
11872 assert_eq!(err.to_string(), "module did not answer within 5s");
11873 assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
11874 }
11875}
11876
11877#[cfg(test)]
11878mod health_tombstone_tests {
11879 use std::{path::PathBuf, sync::Arc, time::Duration};
11880
11881 use subc_protocol::{
11882 manifest::Concurrency,
11883 session::{HealthStatus, ModuleControlResponse},
11884 };
11885 use tokio::sync::mpsc;
11886
11887 use super::{
11888 probe_module_health, HealthAction, HealthConfig, HealthProbeEvidence, ModuleProtocol,
11889 ModuleSpec, RestartPolicy, Supervisor, SupervisorRuntimeConfig,
11890 };
11891 use crate::{
11892 control::ControlHandler,
11893 forwarding::{ForwardingTable, ModuleControlRpcCompletion, ModuleControlRpcOutcome},
11894 registry::{ConnectionId, Registry},
11895 router::FrameSink,
11896 };
11897
11898 struct ProbeHarness {
11899 spec: ModuleSpec,
11900 runtime: SupervisorRuntimeConfig,
11901 forwarding: Arc<ForwardingTable>,
11902 module_connection: ConnectionId,
11903 module_rx: mpsc::Receiver<crate::router::OutboundFrame>,
11904 handler: ControlHandler,
11905 module: super::SupervisedModule,
11906 }
11907
11908 fn probe_harness() -> ProbeHarness {
11909 let registry = Arc::new(Registry::default());
11910 let forwarding = Arc::new(ForwardingTable::default());
11911 let supervisor_handle = super::SupervisorHandle::new();
11912 let health = HealthConfig {
11913 http: None,
11914 cadence: Duration::from_secs(30),
11915 deadline: Duration::from_secs(5),
11916 failure_threshold: 3,
11917 on_degraded: HealthAction::Report,
11918 on_failing: HealthAction::Report,
11919 critical: false,
11920 };
11921 let supervisor = Supervisor::new_for_test(Arc::clone(®istry), RestartPolicy::default())
11922 .with_forwarding(Arc::clone(&forwarding))
11923 .with_handle(supervisor_handle.clone())
11924 .with_health_config(health);
11925 let spec = ModuleSpec {
11926 module_id: "late-health-module".to_string(),
11927 program: PathBuf::from("disabled-module"),
11928 args: Vec::new(),
11929 env: Vec::new(),
11930 reserved: false,
11931 reserved_prefixes: Vec::new(),
11932 protocol: ModuleProtocol::Subc,
11933 overlap: Default::default(),
11934 };
11935 let module = supervisor
11936 .supervise_configured(spec.clone(), false)
11937 .unwrap();
11938 let runtime = supervisor.runtime_config();
11939 let handler = ControlHandler::with_forwarding(registry, Arc::clone(&forwarding))
11940 .with_supervisor(supervisor_handle);
11941 let module_connection = ConnectionId::new(700);
11942 let (module_tx, module_rx) = mpsc::channel(8);
11943 forwarding
11944 .register_module_connection(
11945 module_connection,
11946 spec.module_id.clone(),
11947 subc_protocol::PROTOCOL_VERSION,
11948 Concurrency::ModuleManaged,
11949 FrameSink::new(module_tx),
11950 )
11951 .unwrap();
11952
11953 ProbeHarness {
11954 spec,
11955 runtime,
11956 forwarding,
11957 module_connection,
11958 module_rx,
11959 handler,
11960 module,
11961 }
11962 }
11963
11964 async fn finish_after(
11965 harness: &mut ProbeHarness,
11966 stall: Duration,
11967 ) -> ModuleControlRpcCompletion {
11968 assert!(stall > harness.runtime.health.deadline);
11969 let deadline = harness.runtime.health.deadline;
11970 let probe = probe_module_health(&harness.spec.module_id, &harness.runtime, None);
11971 let answer = async {
11972 let frame = harness.module_rx.recv().await.expect("health.check frame");
11973 tokio::time::advance(deadline).await;
11974 tokio::task::yield_now().await;
11975 tokio::time::advance(stall - deadline).await;
11976 harness
11977 .forwarding
11978 .complete_module_control_rpc(
11979 harness.module_connection,
11980 frame.header.corr,
11981 Some("health.check"),
11982 ModuleControlRpcOutcome::Response(ModuleControlResponse::HealthCheck {
11983 status: HealthStatus::Ok,
11984 detail: None,
11985 metrics: None,
11986 }),
11987 )
11988 .unwrap()
11989 };
11990 let (probe_result, completion) = tokio::join!(probe, answer);
11991 let err = probe_result.expect_err("probe must miss its deadline");
11992 assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
11993 completion
11994 }
11995
11996 async fn time_out_without_answer(harness: &mut ProbeHarness) {
11997 let deadline = harness.runtime.health.deadline;
11998 let probe = probe_module_health(&harness.spec.module_id, &harness.runtime, None);
11999 let exhaust_deadline = async {
12000 let _frame = harness.module_rx.recv().await.expect("health.check frame");
12001 tokio::time::advance(deadline).await;
12002 tokio::task::yield_now().await;
12003 };
12004 let (probe_result, ()) = tokio::join!(probe, exhaust_deadline);
12005 let err = probe_result.expect_err("probe must miss its deadline");
12006 assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
12007 }
12008
12009 async fn run_probe_cycle(harness: &mut ProbeHarness, answer: bool) {
12010 let registry = Arc::clone(&harness.module.inner.registry);
12011 let snapshot = Arc::clone(&harness.module.inner.snapshot);
12012 let process_liveness = super::SupervisorProcessLiveness::default();
12013 let mut child = None;
12014 let cycle = super::run_health_probe_cycle(
12015 &harness.spec,
12016 &harness.runtime,
12017 ®istry,
12018 &process_liveness,
12019 &snapshot,
12020 &mut child,
12021 );
12022 let peer = async {
12023 let frame = harness.module_rx.recv().await.expect("health.check frame");
12024 if answer {
12025 harness
12026 .forwarding
12027 .complete_module_control_rpc(
12028 harness.module_connection,
12029 frame.header.corr,
12030 Some("health.check"),
12031 ModuleControlRpcOutcome::Response(ModuleControlResponse::HealthCheck {
12032 status: HealthStatus::Ok,
12033 detail: None,
12034 metrics: Some(serde_json::json!({"ready": true})),
12035 }),
12036 )
12037 .unwrap();
12038 } else {
12039 tokio::time::advance(harness.runtime.health.deadline).await;
12040 tokio::task::yield_now().await;
12041 }
12042 };
12043 tokio::join!(cycle, peer);
12044 }
12045
12046 #[tokio::test(start_paused = true)]
12047 async fn unanswered_probe_is_unknown_until_threshold_and_ok_report_recovers() {
12048 let mut harness = probe_harness();
12049 let monitor = harness.module.inner.monitor.lock().unwrap().take().unwrap();
12053 monitor.abort();
12054 let _ = monitor.await;
12055 super::update_snapshot(&harness.module.inner.snapshot, None, |state| {
12056 state.enabled = true;
12057 state.state = super::ModuleState::Running;
12058 state.process_alive = true;
12059 })
12060 .unwrap();
12061
12062 run_probe_cycle(&mut harness, true).await;
12063 assert_eq!(
12064 harness.module.status().unwrap().health.status,
12065 super::SupervisorHealthStatus::Ok
12066 );
12067
12068 for failures in 1..harness.runtime.health.failure_threshold {
12069 run_probe_cycle(&mut harness, false).await;
12070 let status = harness.module.status().unwrap();
12071 assert_eq!(status.health.status, super::SupervisorHealthStatus::Unknown);
12072 assert_eq!(status.health.consecutive_failures, failures);
12073 assert!(status.health.last_probe_ms.is_some());
12074 assert!(status.health.detail.unwrap().starts_with("[no-answer]"));
12075 assert!(status.health.metrics.is_none());
12076 assert_eq!(status.state, super::ModuleState::Running);
12077 assert!(status.process_alive);
12078 assert_eq!(status.restart_count, 0);
12079 assert_eq!(status.lifetime_restarts, 0);
12080 assert!(status.health.last_action.is_none());
12081 assert!(harness.runtime.scheduled_respawn.lock().unwrap().is_none());
12082 }
12083
12084 run_probe_cycle(&mut harness, true).await;
12085 let recovered = harness.module.status().unwrap();
12086 assert_eq!(recovered.health.status, super::SupervisorHealthStatus::Ok);
12087 assert_eq!(recovered.health.consecutive_failures, 0);
12088 assert!(recovered.health.detail.is_none());
12089 assert_eq!(
12090 recovered.health.metrics,
12091 Some(serde_json::json!({"ready": true}))
12092 );
12093 assert_eq!(recovered.lifetime_restarts, 0);
12094
12095 for failures in 1..=harness.runtime.health.failure_threshold {
12096 run_probe_cycle(&mut harness, false).await;
12097 let status = harness.module.status().unwrap();
12098 assert_eq!(status.health.consecutive_failures, failures);
12099 if failures < harness.runtime.health.failure_threshold {
12100 assert_eq!(status.health.status, super::SupervisorHealthStatus::Unknown);
12101 assert_eq!(status.state, super::ModuleState::Running);
12102 assert_eq!(status.lifetime_restarts, 0);
12103 assert!(harness.runtime.scheduled_respawn.lock().unwrap().is_none());
12104 } else {
12105 assert_eq!(
12106 status.health.status,
12107 super::SupervisorHealthStatus::Unresponsive
12108 );
12109 assert_eq!(status.state, super::ModuleState::Restarting);
12110 assert_eq!(status.restart_count, 1);
12111 assert_eq!(status.lifetime_restarts, 1);
12112 assert_eq!(status.health.last_action.as_deref(), Some("restart"));
12113 assert!(harness.runtime.scheduled_respawn.lock().unwrap().is_some());
12114 }
12115 }
12116 }
12117
12118 #[tokio::test(start_paused = true)]
12119 async fn late_health_answers_record_start_anchored_latency_for_two_stalls() {
12120 let mut harness = probe_harness();
12121
12122 let first = finish_after(&mut harness, Duration::from_secs(8)).await;
12123 let first_latency = match &first {
12124 ModuleControlRpcCompletion::LateHealthAnswer { latency, .. } => *latency,
12125 other => panic!("late answer was not retained: {other:?}"),
12126 };
12127 assert!(harness.handler.observe_module_control_completion(first));
12128
12129 let second = finish_after(&mut harness, Duration::from_secs(11)).await;
12130 let second_latency = match &second {
12131 ModuleControlRpcCompletion::LateHealthAnswer { latency, .. } => *latency,
12132 other => panic!("late answer was not retained: {other:?}"),
12133 };
12134 assert!(harness.handler.observe_module_control_completion(second));
12135
12136 assert_eq!(first_latency, Duration::from_secs(8));
12137 assert_eq!(
12138 second_latency - first_latency,
12139 Duration::from_secs(3),
12140 "latency must grow linearly with the additional stall"
12141 );
12142 let health = harness.module.status().unwrap().health;
12143 assert_eq!(health.late_answer_count, 2);
12144 assert_eq!(health.last_late_answer_latency_ms, Some(11_000));
12145 }
12146
12147 #[tokio::test(start_paused = true)]
12155 async fn late_answer_clears_the_consecutive_failure_streak() {
12156 let mut harness = probe_harness();
12157
12158 time_out_without_answer(&mut harness).await;
12160 harness
12161 .module
12162 .record_health_probe_failure_for_test("[no-answer] test miss")
12163 .unwrap();
12164 assert_eq!(
12165 harness.module.status().unwrap().health.consecutive_failures,
12166 1,
12167 "precondition: the miss must be on the streak before the late answer"
12168 );
12169
12170 let late = finish_after(&mut harness, Duration::from_secs(9)).await;
12172 assert!(matches!(
12173 late,
12174 ModuleControlRpcCompletion::LateHealthAnswer { .. }
12175 ));
12176 assert!(harness.handler.observe_module_control_completion(late));
12177
12178 let health = harness.module.status().unwrap().health;
12179 assert_eq!(
12180 health.consecutive_failures, 0,
12181 "a late answer is an answer: the streak must reset"
12182 );
12183 assert_eq!(health.late_answer_count, 1);
12184 }
12185
12186 #[tokio::test(start_paused = true)]
12187 async fn repeated_serial_probe_cycles_keep_one_tombstone_per_endpoint() {
12188 let mut harness = probe_harness();
12189
12190 for _ in 0..20 {
12191 time_out_without_answer(&mut harness).await;
12192 assert_eq!(
12193 harness.forwarding.health_probe_tombstone_count().unwrap(),
12194 1
12195 );
12196 }
12197 }
12198}
12199
12200#[cfg(test)]
12201mod child_env_tests {
12202 use super::{
12203 apply_child_env, apply_spawn_role, apply_wire_spawn_args, ModuleProtocol, ModuleSpec,
12204 SpawnRole, SupervisorHandle, SPAWN_ROLE_SWAP_CANDIDATE, SUBC_ARG, SUBC_LAUNCH_NONCE_ENV,
12205 SUBC_MODULE_ID_ENV, SUBC_SPAWN_ROLE_ENV,
12206 };
12207 use std::{ffi::OsStr, path::PathBuf};
12208 use tokio::process::Command;
12209
12210 fn spec(env: Vec<(String, String)>) -> ModuleSpec {
12211 ModuleSpec {
12212 module_id: "env-plan".to_string(),
12213 program: PathBuf::from("/nonexistent"),
12214 args: Vec::new(),
12215 env,
12216 reserved: false,
12217 reserved_prefixes: Vec::new(),
12218 protocol: ModuleProtocol::Subc,
12219 overlap: Default::default(),
12220 }
12221 }
12222
12223 #[test]
12237 fn ambient_ck_log_is_removed_and_a_configured_one_survives() {
12238 let mut command = Command::new("/nonexistent");
12239 apply_child_env(&mut command, &spec(Vec::new()));
12240 let removed = command
12241 .as_std()
12242 .get_envs()
12243 .any(|(key, value)| key == OsStr::new("CK_LOG") && value.is_none());
12244 assert!(
12245 removed,
12246 "ambient CK_LOG must be explicitly removed for an unconfigured module"
12247 );
12248
12249 let mut configured = Command::new("/nonexistent");
12250 apply_child_env(
12251 &mut configured,
12252 &spec(vec![("CK_LOG".to_string(), "debug".to_string())]),
12253 );
12254 let effective = configured
12255 .as_std()
12256 .get_envs()
12257 .filter(|(key, _)| *key == OsStr::new("CK_LOG"))
12258 .last()
12259 .map(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()));
12260 assert_eq!(
12261 effective,
12262 Some(Some("debug".to_string())),
12263 "a module's configured CK_LOG must survive the ambient removal"
12264 );
12265 }
12266
12267 #[test]
12276 fn protocol_none_spawn_carries_no_subc_argument_and_no_nonce() {
12277 let connection_file = std::path::Path::new("/run/subc-connection.json");
12278 let handle = SupervisorHandle::new();
12279
12280 let mut none_spec = spec(Vec::new());
12281 none_spec.protocol = ModuleProtocol::None;
12282 let mut none = Command::new("/nonexistent");
12283 let none_handoff =
12284 apply_wire_spawn_args(&mut none, &none_spec, Some(connection_file), Some(&handle))
12285 .expect("protocol-none spawn args apply");
12286 assert!(
12287 none_handoff.is_none(),
12288 "protocol:none spawn must not receive a nonce descriptor"
12289 );
12290 assert!(
12291 !none.as_std().get_envs().any(|(key, value)| key
12292 == OsStr::new(subc_os::LAUNCH_NONCE_FD_ENV)
12293 && value.is_some()),
12294 "protocol:none spawn must not name a nonce descriptor"
12295 );
12296 let none_args: Vec<String> = none
12297 .as_std()
12298 .get_args()
12299 .map(|a| a.to_string_lossy().into_owned())
12300 .collect();
12301 assert!(
12302 !none_args.iter().any(|a| a == SUBC_ARG),
12303 "protocol:none argv must not carry --subc; got {none_args:?}"
12304 );
12305 let none_has_nonce = none
12306 .as_std()
12307 .get_envs()
12308 .any(|(key, value)| key == OsStr::new(SUBC_LAUNCH_NONCE_ENV) && value.is_some());
12309 assert!(
12310 !none_has_nonce,
12311 "protocol:none spawn must not receive a launch nonce"
12312 );
12313 let none_has_module_id = none
12314 .as_std()
12315 .get_envs()
12316 .any(|(key, value)| key == OsStr::new(SUBC_MODULE_ID_ENV) && value.is_some());
12317 assert!(
12318 none_has_module_id,
12319 "SUBC_MODULE_ID is inert and stays on every path"
12320 );
12321 assert!(
12322 handle.spawn_nonce(&none_spec.module_id).is_none(),
12323 "no nonce record for a process that will never present one"
12324 );
12325
12326 let wire_spec = spec(Vec::new());
12328 let mut wire = Command::new("/nonexistent");
12329 let wire_handoff =
12330 apply_wire_spawn_args(&mut wire, &wire_spec, Some(connection_file), Some(&handle))
12331 .expect("subc-wire spawn args apply");
12332 let wire_fd_env = wire
12333 .as_std()
12334 .get_envs()
12335 .find(|(key, _)| *key == OsStr::new(subc_os::LAUNCH_NONCE_FD_ENV))
12336 .and_then(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()));
12337 #[cfg(unix)]
12338 assert_eq!(
12339 wire_fd_env,
12340 Some(wire_handoff.expect("a descriptor handoff").fd_env_value()),
12341 "a subc-wire spawn names the pipe it will receive at descriptor 3"
12342 );
12343 #[cfg(not(unix))]
12344 assert!(wire_handoff.is_none() && wire_fd_env.is_none());
12345 let wire_args: Vec<String> = wire
12346 .as_std()
12347 .get_args()
12348 .map(|a| a.to_string_lossy().into_owned())
12349 .collect();
12350 assert_eq!(
12351 wire_args,
12352 vec![
12353 SUBC_ARG.to_string(),
12354 connection_file.to_string_lossy().into_owned()
12355 ],
12356 "a subc-wire spawn still carries --subc <path>"
12357 );
12358 assert_eq!(
12359 wire.as_std()
12360 .get_envs()
12361 .any(|(key, value)| key == OsStr::new(SUBC_LAUNCH_NONCE_ENV) && value.is_some()),
12362 !cfg!(unix),
12363 "only Windows supplies the environment nonce"
12364 );
12365 assert!(handle.spawn_nonce(&wire_spec.module_id).is_some());
12366 }
12367
12368 #[test]
12378 fn plain_spawn_removes_the_spawn_role_even_when_the_spec_sets_it() {
12379 let role = |command: &Command| {
12380 command
12381 .as_std()
12382 .get_envs()
12383 .filter(|(key, _)| *key == OsStr::new(SUBC_SPAWN_ROLE_ENV))
12384 .last()
12385 .map(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()))
12386 };
12387 let forged = spec(vec![(
12388 SUBC_SPAWN_ROLE_ENV.to_string(),
12389 SPAWN_ROLE_SWAP_CANDIDATE.to_string(),
12390 )]);
12391
12392 let mut plain = Command::new("/nonexistent");
12393 apply_child_env(&mut plain, &forged);
12394 apply_spawn_role(&mut plain, SpawnRole::Plain);
12395 assert_eq!(
12396 role(&plain),
12397 Some(None),
12398 "a plain spawn must remove SUBC_SPAWN_ROLE, whatever the spec says"
12399 );
12400
12401 let mut candidate = Command::new("/nonexistent");
12402 apply_child_env(&mut candidate, &spec(Vec::new()));
12403 apply_spawn_role(&mut candidate, SpawnRole::SwapCandidate);
12404 assert_eq!(
12405 role(&candidate),
12406 Some(Some(SPAWN_ROLE_SWAP_CANDIDATE.to_string()))
12407 );
12408 }
12409
12410 #[test]
12416 fn daemon_private_capture_keys_are_not_passed_to_the_child() {
12417 let mut command = Command::new("/nonexistent");
12418 apply_child_env(
12419 &mut command,
12420 &spec(vec![
12421 (super::CAPTURE_KEEP_ENV.to_string(), "5".to_string()),
12422 ("KEPT".to_string(), "yes".to_string()),
12423 ]),
12424 );
12425 let keys: Vec<String> = command
12426 .as_std()
12427 .get_envs()
12428 .filter(|(_, value)| value.is_some())
12429 .map(|(key, _)| key.to_string_lossy().into_owned())
12430 .collect();
12431 assert!(keys.contains(&"KEPT".to_string()), "got {keys:?}");
12432 assert!(
12433 !keys.contains(&super::CAPTURE_KEEP_ENV.to_string()),
12434 "daemon-private capture key leaked to the child: {keys:?}"
12435 );
12436 }
12437}
12438
12439#[cfg(test)]
12440mod jitter_tests {
12441 use super::jittered_health_delay;
12442 use std::{collections::HashSet, time::Duration};
12443
12444 const FLEET: [&str; 14] = [
12453 "aft",
12454 "alfonso-core",
12455 "magic-context",
12456 "broca",
12457 "thalamus",
12458 "quota",
12459 "engram",
12460 "plexus",
12461 "cerebellum",
12462 "astrocyte",
12463 "synapse",
12464 "subc-mcp",
12465 "cortexkit-credentials",
12466 "subc-federation",
12467 ];
12468
12469 #[test]
12477 fn probe_delays_disperse_across_the_fleet() {
12478 let cadence = Duration::from_secs(30);
12479 let delays: HashSet<Duration> = FLEET
12480 .iter()
12481 .map(|id| jittered_health_delay(id, 0, cadence))
12482 .collect();
12483 assert_eq!(
12484 delays.len(),
12485 FLEET.len(),
12486 "every supervised module must land on its own probe offset"
12487 );
12488 }
12489
12490 #[test]
12496 fn jitter_only_delays_and_stays_within_one_tenth_of_cadence() {
12497 let cadence = Duration::from_secs(30);
12498 let span = cadence / 10;
12499 for id in FLEET {
12500 for probe_index in 0..8 {
12501 let delay = jittered_health_delay(id, probe_index, cadence);
12502 assert!(
12503 delay >= cadence,
12504 "{id}#{probe_index}: jitter must not shorten the cadence"
12505 );
12506 assert!(
12507 delay < cadence + span,
12508 "{id}#{probe_index}: jitter must stay inside one tenth of the cadence"
12509 );
12510 }
12511 }
12512 }
12513
12514 #[test]
12520 fn a_module_offset_is_stable_across_restarts() {
12521 let cadence = Duration::from_secs(30);
12522 for id in FLEET {
12523 assert_eq!(
12524 jittered_health_delay(id, 0, cadence),
12525 jittered_health_delay(id, 0, cadence),
12526 "{id}: the same module and probe index must produce the same offset"
12527 );
12528 }
12529 }
12530
12531 #[test]
12533 fn zero_cadence_yields_zero_delay() {
12534 assert_eq!(
12535 jittered_health_delay("aft", 0, Duration::ZERO),
12536 Duration::ZERO
12537 );
12538 }
12539}
12540
12541#[cfg(all(test, target_os = "linux"))]
12542mod cgroup_placement_tests {
12543 use super::{
12544 apply_cgroup_placement, remove_module_cgroup, ModuleProtocol, ModuleSpec, SuperviseError,
12545 SupervisedChild,
12546 };
12547 use crate::stderr_tail::{StderrRing, StderrTailConfig};
12548 use cortexkit_test_support::ScratchDir;
12549 use std::{
12550 fs, io,
12551 path::{Path, PathBuf},
12552 sync::{Arc, Mutex},
12553 };
12554 use tokio::process::Command;
12555
12556 #[tokio::test]
12557 async fn unique_spawn_cgroups_clean_up_across_restarts_and_shutdown() {
12558 use super::*;
12559 let dir = ScratchDir::new("unique-spawn-cgroups");
12560 let root = PathBuf::from(format!(
12561 "/sys/fs/cgroup/subc-unique-test-{}-{}",
12562 std::process::id(),
12563 unix_ms_now()
12564 ));
12565 if let Err(error) = fs::create_dir(&root) {
12566 assert!(
12567 std::env::var_os("SUBC_REQUIRE_CGROUP_TEST").is_none(),
12568 "required cgroup test cannot execute: {error}"
12569 );
12570 eprintln!("SKIP unique_spawn_cgroups_clean_up_across_restarts_and_shutdown: {error}");
12571 return;
12572 }
12573 let placement = subc_cgroup::prepare_at(&root).unwrap().unwrap();
12574 let group_count = || {
12575 fs::read_dir(root.join("subc-modules"))
12576 .unwrap()
12577 .map(|entry| entry.unwrap().file_type().unwrap())
12578 .filter(|kind| kind.is_dir())
12579 .count()
12580 };
12581 let supervisor =
12582 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
12583 .with_cgroup_placement(Some(placement.clone()));
12584 let runtime = supervisor.runtime_config();
12585 let mut spec = ModuleSpec {
12586 module_id: "unique-spawn".into(),
12587 program: PathBuf::from("/bin/sleep"),
12588 args: vec!["60".into()],
12589 env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
12590 .into_iter()
12591 .map(|key| (key.into(), dir.to_string_lossy().into_owned()))
12592 .collect(),
12593 reserved: false,
12594 reserved_prefixes: vec![],
12595 protocol: ModuleProtocol::None,
12596 overlap: Default::default(),
12597 };
12598 let spawn = |spec: &ModuleSpec| {
12599 spawn_child(
12600 spec,
12601 None,
12602 None,
12603 &runtime.stderr_ring,
12604 None,
12605 &runtime.child_roster,
12606 Some(&placement),
12607 )
12608 .unwrap()
12609 };
12610 let mut live = spawn(&spec);
12611 for _ in 0..3 {
12612 let next = spawn(&spec);
12614 assert_ne!(live.module_id, next.module_id);
12615 live.start_kill().unwrap();
12616 live.wait().await.unwrap();
12617 live = next;
12618 assert!(
12619 live.child.try_wait().unwrap().is_none(),
12620 "retiring the old slot must not kill the replacement"
12621 );
12622 assert_eq!(
12623 group_count(),
12624 1,
12625 "only the live spawn's cgroup should remain"
12626 );
12627 }
12628 supervisor.begin_daemon_shutdown();
12629 let reap = tokio::spawn(async move {
12630 live.wait().await.unwrap();
12631 });
12632 supervisor
12633 .end_children_for_daemon_shutdown(false, std::future::pending())
12634 .await;
12635 reap.await.unwrap();
12636 assert_eq!(group_count(), 0);
12637 spec.program = PathBuf::from("/bin/true");
12639 spec.args.clear();
12640 let fresh_roster = ChildRoster::default();
12641 let mut short = spawn_child(
12642 &spec,
12643 None,
12644 None,
12645 &runtime.stderr_ring,
12646 None,
12647 &fresh_roster,
12648 Some(&placement),
12649 )
12650 .unwrap();
12651 short.wait().await.unwrap();
12652 assert_eq!(group_count(), 0);
12653 spec.module_id = "_".repeat(255);
12654 let mut long_id = spawn_child(
12655 &spec,
12656 None,
12657 None,
12658 &runtime.stderr_ring,
12659 None,
12660 &fresh_roster,
12661 Some(&placement),
12662 )
12663 .unwrap();
12664 long_id.wait().await.unwrap();
12665 assert_eq!(
12666 group_count(),
12667 0,
12668 "valid long module IDs must not exceed cgroup NAME_MAX"
12669 );
12670 fs::remove_dir(root.join("subc-modules")).unwrap();
12671 fs::remove_dir(root).unwrap();
12672 eprintln!("EXECUTED unique_spawn_cgroups_clean_up_across_restarts_and_shutdown");
12673 }
12674
12675 #[test]
12676 fn failed_parent_cgroup_open_is_a_cgroup_supervision_error() {
12677 let path = Path::new("/definitely-missing-subc-cgroup");
12678 let mut command = Command::new("true");
12679 let error = apply_cgroup_placement(
12680 &mut command,
12681 &ModuleSpec {
12682 module_id: "broken-cgroup".to_string(),
12683 program: PathBuf::from("true"),
12684 args: Vec::new(),
12685 env: Vec::new(),
12686 reserved: false,
12687 reserved_prefixes: Vec::new(),
12688 protocol: ModuleProtocol::Subc,
12689 overlap: Default::default(),
12690 },
12691 path,
12692 )
12693 .expect_err("a parent cgroup open failure must reject the supervised spawn");
12694 let reason = error.to_string();
12695
12696 assert!(
12697 matches!(error, SuperviseError::Cgroup { .. }),
12698 "parent cgroup open must be reported as a cgroup supervision error: {reason}"
12699 );
12700 assert!(
12701 reason.contains("/definitely-missing-subc-cgroup/cgroup.procs"),
12702 "parent cgroup open failure must name cgroup.procs: {reason}"
12703 );
12704 }
12705
12706 #[tokio::test]
12707 async fn reaping_a_child_removes_its_empty_module_cgroup() {
12708 let root = ScratchDir::new("supervisor-reap-cgroup");
12709 fs::write(root.join("cgroup.procs"), b"").expect("write scratch cgroup marker");
12710 let placement = subc_cgroup::prepare_at(&root)
12711 .expect("prepare scratch cgroup root")
12712 .expect("scratch root has a cgroup.procs marker");
12713 let module_id = "reaped-module";
12714 let module = placement
12715 .module_path(module_id)
12716 .expect("create scratch module cgroup");
12717 let child = Command::new("true")
12718 .env("XDG_DATA_HOME", root.path())
12719 .env("XDG_RUNTIME_DIR", root.path())
12720 .env("XDG_CONFIG_HOME", root.path())
12721 .spawn()
12722 .expect("spawn short-lived child");
12723 let pid = child.id().expect("spawned child has pid");
12724 let mut child = SupervisedChild {
12725 child,
12726 protocol: ModuleProtocol::Subc,
12727 module_id: module_id.to_string(),
12728 cgroup_placement: Some(placement),
12729 stdout_pump: None,
12730 stderr_pump: None,
12731 stderr_ring: Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default()))),
12732 spawned_at_ms: 0,
12733 spawned_from: PathBuf::from("true"),
12734 spawned_file_identity: None,
12735 process_start_time: None,
12736 process_identity: None,
12737 pid,
12738 roster_guard: None,
12739 #[cfg(target_os = "macos")]
12740 privacy_exec: None,
12741 spawn_failure: None,
12742 };
12743
12744 child.wait().await.expect("reap short-lived child");
12745
12746 assert!(
12747 !module.exists(),
12748 "reaping the supervised child must remove its empty cgroup"
12749 );
12750 }
12751
12752 #[test]
12753 fn non_empty_cgroup_removal_is_reported_without_blocking_teardown() {
12754 let root = ScratchDir::new("supervisor-non-empty-cgroup");
12755 fs::write(root.join("cgroup.procs"), b"").expect("write scratch cgroup marker");
12756 let placement = subc_cgroup::prepare_at(&root)
12757 .expect("prepare scratch cgroup root")
12758 .expect("scratch root has a cgroup.procs marker");
12759 let module = placement
12760 .module_path("surviving-module")
12761 .expect("create scratch module cgroup");
12762 fs::write(module.join("surviving-process"), b"still present")
12763 .expect("make scratch cgroup non-empty");
12764 let (logs, _guard) = crate::router::test_log::log_capture(tracing::Level::WARN);
12765
12766 remove_module_cgroup(&placement, "surviving-module");
12767
12768 let logs = crate::router::test_log::captured_logs(&logs);
12769 assert!(
12770 module.exists(),
12771 "failed removal must leave the cgroup intact"
12772 );
12773 assert!(
12774 logs.contains("could not remove module cgroup after process exit; continuing teardown")
12775 && logs.contains("surviving-module"),
12776 "best-effort removal must report the failure without returning it: {logs}"
12777 );
12778 }
12779
12780 #[test]
12781 fn cgroup_pre_exec_spawn_failure_names_the_cgroup_path() {
12782 let cgroup_path = PathBuf::from("/sys/fs/cgroup/subc-modules/broken-module");
12783 let reason = SuperviseError::Spawn {
12784 program: PathBuf::from("/bin/true"),
12785 source: io::Error::from_raw_os_error(13),
12786 cgroup_path: Some(cgroup_path.clone()),
12787 }
12788 .to_string();
12789
12790 assert!(
12791 reason.contains(&cgroup_path.display().to_string()),
12792 "a pre_exec spawn failure must name the cgroup path: {reason}"
12793 );
12794 }
12795}
12796
12797#[cfg(test)]
12798mod spawn_subscriber_lag_tests {
12799 use super::*;
12800
12801 #[tokio::test]
12806 async fn lagged_spawn_subscriber_receives_a_terminal_lagged_error_after_its_queued_frames() {
12807 let feed = SpawnEventFeed::default();
12808 feed.configure_incarnation("lag-incarnation".to_string());
12809 let (tx, mut rx) = mpsc::channel(1);
12812 feed.subscribe(ConnectionId::new(1), 7, 1, None, FrameSink::new(tx))
12813 .expect("subscribe");
12814 let emitted = SPAWN_SUBSCRIBER_BUFFER + 16;
12815 for index in 0..emitted {
12816 feed.emit_spawned(&format!("lag-module-{index}"), 1000, 0);
12817 tokio::task::yield_now().await;
12820 }
12821 assert_eq!(
12822 feed.subscriber_count(),
12823 0,
12824 "the lagged subscriber must be removed"
12825 );
12826
12827 let mut data = Vec::new();
12828 let mut last = None;
12829 loop {
12830 let next = tokio::time::timeout(Duration::from_secs(5), rx.recv())
12831 .await
12832 .expect("the forwarder must finish once the subscriber is dropped");
12833 let Some(outbound) = next else { break };
12834 let frame = outbound.frame;
12835 if frame.header.ty == FrameType::StreamData {
12836 assert!(last.is_none(), "no data may follow the terminal frame");
12837 let event: SpawnEvent = serde_json::from_slice(&frame.body).unwrap();
12838 data.push(event.cursor.seq);
12839 } else {
12840 assert!(last.is_none(), "exactly one terminal frame");
12841 last = Some(frame);
12842 }
12843 }
12844 assert!(!data.is_empty(), "queued frames drain before the terminal");
12845 for pair in data.windows(2) {
12846 assert_eq!(
12847 pair[1],
12848 pair[0] + 1,
12849 "queued frames arrive dense and in order"
12850 );
12851 }
12852 let terminal = last.expect("a lagged subscriber must receive a terminal frame");
12853 assert_eq!(terminal.header.ty, FrameType::Error);
12854 assert_eq!(terminal.header.corr, 7);
12855 let body: subc_protocol::ErrorBody = serde_json::from_slice(&terminal.body).unwrap();
12856 assert_eq!(body.code, SPAWN_SUBSCRIBER_LAGGED_CODE);
12857 let detail = body.detail.expect("lagged error carries detail");
12858 assert_eq!(
12859 detail["first_undelivered_cursor"]["seq"],
12860 data.last().unwrap() + 1,
12861 "the named cursor is the first event the subscriber did not receive"
12862 );
12863 assert_eq!(
12864 detail["first_undelivered_cursor"]["daemon_incarnation"],
12865 "lag-incarnation"
12866 );
12867 }
12868}
12869
12870#[cfg(test)]
12871mod terminal_history_read_concurrency_tests {
12872 use super::*;
12873 use crate::terminal_journal::read_pause;
12874 use cortexkit_test_support::ScratchDir;
12875 use std::sync::mpsc as std_mpsc;
12876
12877 fn journaled_ring(
12878 journal: &Arc<crate::terminal_journal::TerminalJournal>,
12879 ) -> Arc<Mutex<TerminalRing>> {
12880 Arc::new(Mutex::new(
12881 TerminalRing::new(TerminalRingConfig::default(), 1)
12882 .with_journal(Some(Arc::clone(journal))),
12883 ))
12884 }
12885
12886 fn crash(at_ms: u64) -> ExitReport {
12887 ExitReport {
12888 kind: ExitKind::Crash,
12889 code: Some(1),
12890 signal: None,
12891 at_ms,
12892 }
12893 }
12894
12895 fn record_within(
12898 module_id: &'static str,
12899 ring: &Arc<Mutex<TerminalRing>>,
12900 at_ms: u64,
12901 bound: Duration,
12902 ) -> bool {
12903 let ring = Arc::clone(ring);
12904 let (done, done_rx) = std_mpsc::channel();
12905 std::thread::spawn(move || {
12906 record_terminal(
12907 module_id,
12908 &ring,
12909 &SpawnEventFeed::default(),
12910 &crash(at_ms),
12911 TerminalDisposition::Restarting,
12912 );
12913 let _ = done.send(());
12914 });
12915 done_rx.recv_timeout(bound).is_ok()
12916 }
12917
12918 #[test]
12923 fn exits_recorded_during_a_paused_history_read_are_not_blocked_or_half_merged() {
12924 let dir = ScratchDir::new("terminal-history-concurrent-read");
12925 let path = dir.join("terminals.jsonl");
12926 let journal = Arc::new(crate::terminal_journal::TerminalJournal::open(
12927 path.clone(),
12928 "daemon".into(),
12929 ));
12930 let reader_ring = journaled_ring(&journal);
12931 let other_ring = journaled_ring(&journal);
12932 assert!(record_within(
12933 "reader-module",
12934 &reader_ring,
12935 10,
12936 Duration::from_secs(5)
12937 ));
12938
12939 let (started, release) = read_pause::install(&path);
12940 let reading = {
12941 let ring = Arc::clone(&reader_ring);
12942 std::thread::spawn(move || durable_terminal_history_of(&ring, "reader-module"))
12943 };
12944 started
12945 .recv_timeout(Duration::from_secs(5))
12946 .expect("the history read reached its pause");
12947
12948 let bound = Duration::from_secs(1);
12949 assert!(
12950 record_within("other-module", &other_ring, 20, bound),
12951 "another module's exit waited on a history read (journal writer held)"
12952 );
12953 assert!(
12954 record_within("reader-module", &reader_ring, 30, bound),
12955 "the read module's own exit waited on its history read (ring held)"
12956 );
12957
12958 drop(release);
12959 let paused = reading.join().unwrap();
12960 assert_eq!(
12961 paused.entries.iter().map(|e| e.at_ms).collect::<Vec<_>>(),
12962 vec![10],
12963 "an exit recorded after the read began lands in neither half of it"
12964 );
12965 assert_eq!(paused.journal_skipped_lines, 0);
12966 assert_eq!(paused.journal_read_errors, 0);
12967
12968 let after = durable_terminal_history_of(&reader_ring, "reader-module");
12969 assert_eq!(
12970 after.entries.iter().map(|e| e.at_ms).collect::<Vec<_>>(),
12971 vec![10, 30],
12972 "the next read merges ring and journal with no duplicate"
12973 );
12974 assert_eq!(after.journal_skipped_lines, 0);
12975 }
12976}
12977
12978#[cfg(test)]
12983mod stderr_settle_tests {
12984 use std::{
12985 future::Future,
12986 io,
12987 pin::Pin,
12988 sync::{Arc, Mutex},
12989 task::{Context, Poll},
12990 time::Duration,
12991 };
12992
12993 use tokio::{
12994 io::{AsyncRead, ReadBuf},
12995 sync::oneshot,
12996 time::Instant,
12997 };
12998
12999 use super::{settle_stderr_pump, settle_stdout_pump, StderrPump};
13000 use crate::stderr_tail::{
13001 pump_stderr_to, pump_stdout_to, split_capture_stamp, untimed, CaptureState,
13002 ChildOutputSink, OutputSink, StderrRing, StderrTailConfig, TailEntry,
13003 };
13004
13005 const BOUND: Duration = Duration::from_millis(250);
13006
13007 struct HeldReader {
13011 before: Option<Vec<u8>>,
13012 gate: Option<oneshot::Receiver<()>>,
13013 after: io::Cursor<Vec<u8>>,
13014 }
13015
13016 impl AsyncRead for HeldReader {
13017 fn poll_read(
13018 mut self: Pin<&mut Self>,
13019 cx: &mut Context<'_>,
13020 buf: &mut ReadBuf<'_>,
13021 ) -> Poll<io::Result<()>> {
13022 if let Some(bytes) = self.before.take() {
13023 buf.put_slice(&bytes);
13024 return Poll::Ready(Ok(()));
13025 }
13026 if let Some(gate) = self.gate.as_mut() {
13027 match Pin::new(gate).poll(cx) {
13028 Poll::Pending => return Poll::Pending,
13029 Poll::Ready(_) => self.gate = None,
13030 }
13031 }
13032 Pin::new(&mut self.after).poll_read(cx, buf)
13033 }
13034 }
13035
13036 struct DiscardSink;
13037
13038 impl OutputSink for DiscardSink {
13039 fn write_line(&mut self, _line: &[u8]) {}
13040 }
13041
13042 fn line(text: &str) -> TailEntry {
13043 TailEntry::Line {
13044 text: text.to_string(),
13045 truncated: false,
13046 at_ms: None,
13047 }
13048 }
13049
13050 fn lock(ring: &Arc<Mutex<StderrRing>>) -> std::sync::MutexGuard<'_, StderrRing> {
13051 ring.lock().unwrap()
13052 }
13053
13054 fn held_pump(
13058 ring: &Arc<Mutex<StderrRing>>,
13059 before: &str,
13060 after: &str,
13061 ) -> (StderrPump, oneshot::Sender<()>) {
13062 let generation = lock(ring).begin_process();
13063 let (release, gate) = oneshot::channel();
13064 let reader = HeldReader {
13065 before: Some(before.as_bytes().to_vec()),
13066 gate: Some(gate),
13067 after: io::Cursor::new(after.as_bytes().to_vec()),
13068 };
13069 let task = tokio::spawn(pump_stderr_to(
13070 reader,
13071 Arc::clone(ring),
13072 generation,
13073 DiscardSink,
13074 ));
13075 (StderrPump { task, generation }, release)
13076 }
13077
13078 async fn wait_until(ring: &Arc<Mutex<StderrRing>>, done: impl Fn(&StderrRing) -> bool) {
13079 for _ in 0..1000 {
13080 if done(&lock(ring)) {
13081 return;
13082 }
13083 tokio::time::sleep(Duration::from_millis(1)).await;
13084 }
13085 panic!(
13086 "ring never reached the expected state: {:?}",
13087 lock(ring).snapshot(None, None)
13088 );
13089 }
13090
13091 #[tokio::test(start_paused = true)]
13092 async fn a_crash_line_the_reader_had_not_reached_by_the_bound_is_kept_before_the_restart() {
13093 let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
13094 let (pump, release) = held_pump(&ring, "booting\n", "config error: missing storage\n");
13095
13096 settle_stderr_pump("crasher", &ring, pump, BOUND).await;
13097 let before_release = lock(&ring).snapshot(None, None);
13098 assert!(
13099 matches!(before_release.capture, CaptureState::Incomplete { .. }),
13100 "a reader that has not reached EOF cannot claim a whole tail: {before_release:?}"
13101 );
13102
13103 let next = lock(&ring).begin_process();
13106 lock(&ring).push_line_from(next, "next process booting");
13107 release.send(()).unwrap();
13108 wait_until(&ring, |ring| {
13109 ring.snapshot(None, None).capture == CaptureState::Captured
13110 })
13111 .await;
13112
13113 assert_eq!(
13114 untimed(lock(&ring).snapshot(None, None).entries),
13115 vec![
13116 line("booting"),
13117 line("config error: missing storage"),
13118 TailEntry::ProcessStart,
13119 line("next process booting"),
13120 ],
13121 "the crash's last line must survive a slow reader and stay in the crashed process's section"
13122 );
13123 }
13124
13125 #[tokio::test(start_paused = true)]
13126 async fn a_pipe_held_open_by_a_descendant_reads_incomplete_without_delaying_the_restart_past_the_bound(
13127 ) {
13128 let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
13129 let (pump, _held) = held_pump(&ring, "parent exiting\n", "");
13132
13133 let started = Instant::now();
13134 settle_stderr_pump("orphaning", &ring, pump, BOUND).await;
13135 assert_eq!(
13136 started.elapsed(),
13137 BOUND,
13138 "the restart must wait exactly the bound for a pipe that stays open, no longer"
13139 );
13140
13141 let next = lock(&ring).begin_process();
13142 lock(&ring).push_line_from(next, "next process booting");
13143 tokio::time::sleep(Duration::from_secs(60)).await;
13144
13145 let snapshot = lock(&ring).snapshot(None, None);
13146 match &snapshot.capture {
13147 CaptureState::Incomplete { reason } => assert!(
13148 reason.contains("had not reached EOF") && reason.contains("250ms"),
13149 "the reason must say what is missing and after how long: {reason}"
13150 ),
13151 other => panic!("expected Incomplete while the pipe is held open, got {other:?}"),
13152 }
13153 assert_eq!(
13154 untimed(snapshot.entries),
13155 vec![
13156 line("parent exiting"),
13157 TailEntry::ProcessStart,
13158 line("next process booting"),
13159 ]
13160 );
13161 }
13162
13163 #[tokio::test(start_paused = true)]
13164 async fn a_reader_that_reaches_eof_within_the_bound_leaves_the_tail_captured() {
13165 let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
13166 let (pump, release) = held_pump(&ring, "one\n", "two\n");
13167 release.send(()).unwrap();
13168
13169 settle_stderr_pump("clean", &ring, pump, BOUND).await;
13170
13171 let snapshot = lock(&ring).snapshot(None, None);
13172 assert_eq!(snapshot.capture, CaptureState::Captured);
13173 assert_eq!(untimed(snapshot.entries), vec![line("one"), line("two")]);
13174 }
13175
13176 #[tokio::test(start_paused = true)]
13177 async fn stdout_buffered_past_the_exit_drain_bound_reaches_the_capture_file() {
13178 const BURST: usize = 150;
13179 let capture = cortexkit_test_support::ScratchDir::new("stdout-exit-drain");
13180 let path = capture.join("two-pipe.stderr.log");
13181 let sink = ChildOutputSink::open(&path, cortexkit_log::Retention::default()).unwrap();
13182 let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
13183 let generation = lock(&ring).begin_process();
13184 let stdout = (0..BURST)
13185 .map(|i| format!("out-{i:04}-{}\n", "o".repeat(64)))
13186 .collect::<String>();
13187 let stderr = (0..BURST)
13188 .map(|i| format!("err-{i:04}-{}\n", "e".repeat(64)))
13189 .collect::<String>();
13190 let first_chunk = stdout.find('\n').unwrap() + 1 + "out-0001-".len();
13195 let (release, gate) = oneshot::channel();
13196 let reader = HeldReader {
13197 before: Some(stdout.as_bytes()[..first_chunk].to_vec()),
13198 gate: Some(gate),
13199 after: io::Cursor::new(stdout.as_bytes()[first_chunk..stdout.len() - 1].to_vec()),
13200 };
13201 let (finished, completion) = oneshot::channel();
13202 let stdout_sink = sink.clone();
13203 let pump = tokio::spawn(async move {
13204 pump_stdout_to(reader, stdout_sink).await;
13205 finished.send(()).unwrap();
13206 });
13207 pump_stderr_to(io::Cursor::new(stderr.into_bytes()), ring, generation, sink).await;
13208
13209 let started = Instant::now();
13210 settle_stdout_pump("two-pipe", pump, BOUND).await;
13211 assert_eq!(
13212 started.elapsed(),
13213 BOUND,
13214 "a late reader must not hold up restart"
13215 );
13216 assert_eq!(
13217 std::fs::read_to_string(&path).unwrap().lines().count(),
13218 BURST + 1
13219 );
13220
13221 release
13222 .send(())
13223 .expect("stdout reader must survive the exit drain bound");
13224 tokio::time::timeout(Duration::from_secs(1), completion)
13225 .await
13226 .expect("released stdout reader must reach EOF")
13227 .expect("stdout capture must finish at EOF, not be cancelled");
13228 let contents = std::fs::read_to_string(&path).unwrap();
13229 let mut lines = contents
13230 .lines()
13231 .map(|line| split_capture_stamp(line).expect("capture stamp").1)
13232 .collect::<Vec<_>>();
13233 let mut expected = (0..BURST)
13234 .flat_map(|i| {
13235 [
13236 format!("out-{i:04}-{}", "o".repeat(64)),
13237 format!("err-{i:04}-{}", "e".repeat(64)),
13238 ]
13239 })
13240 .collect::<Vec<_>>();
13241 lines.sort_unstable();
13242 expected.sort_unstable();
13243 assert_eq!(
13244 lines, expected,
13245 "both pipes must retain every complete line exactly once"
13246 );
13247 }
13248}
13249
13250#[cfg(all(test, windows))]
13264mod job_containment_tests {
13265 use super::*;
13266 use cortexkit_test_support::ScratchDir;
13267 use std::{
13268 path::{Path, PathBuf},
13269 sync::{Arc, Mutex},
13270 time::{Duration, Instant},
13271 };
13272
13273 fn stub_path() -> PathBuf {
13279 let mut path = std::env::current_exe().expect("current_exe available in tests");
13280 path.pop();
13281 path.pop();
13282 path.push("fake-aft-stub.exe");
13283 assert!(
13284 path.exists(),
13285 "fake-aft-stub not built at {}: run `cargo test -p subc-core` (which builds \
13286 [[bin]] targets) rather than `cargo test -p subc-core --lib` (which does not)",
13287 path.display()
13288 );
13289 path
13290 }
13291
13292 fn read_grandchild_pid(path: &Path) -> u32 {
13294 let deadline = Instant::now() + Duration::from_secs(10);
13295 loop {
13296 if let Ok(contents) = std::fs::read_to_string(path) {
13297 if let Ok(pid) = contents.trim().parse() {
13298 return pid;
13299 }
13300 }
13301 assert!(
13302 Instant::now() < deadline,
13303 "the stub never recorded a grandchild pid at {}",
13304 path.display()
13305 );
13306 std::thread::sleep(Duration::from_millis(10));
13307 }
13308 }
13309
13310 struct Fixture {
13313 _dir: ScratchDir,
13314 module_id: String,
13315 grandchild: u32,
13316 child: Option<SupervisedChild>,
13317 registry: Arc<Registry>,
13318 snapshot: Arc<Mutex<SupervisorSnapshot>>,
13319 terminal_ring: Arc<Mutex<TerminalRing>>,
13320 spawn_events: SpawnEventFeed,
13321 }
13322
13323 fn fixture(label: &str, module_id: &str) -> Fixture {
13324 let dir = ScratchDir::new(label);
13325 let pid_file = dir.join("grandchild.pid");
13326 let supervisor = Supervisor::new_for_test(
13327 Arc::new(Registry::default()),
13328 RestartPolicy::new(3, Duration::ZERO),
13329 );
13330 let runtime = supervisor.runtime_config();
13331 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
13332 let spec = ModuleSpec {
13333 module_id: module_id.to_string(),
13334 program: stub_path(),
13335 args: Vec::new(),
13339 env: vec![
13340 ("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string()),
13341 (
13342 "FAKE_AFT_GRANDCHILD_PID_FILE".to_string(),
13343 pid_file.display().to_string(),
13344 ),
13345 ],
13346 reserved: false,
13347 reserved_prefixes: Vec::new(),
13348 protocol: ModuleProtocol::Subc,
13349 overlap: Default::default(),
13350 };
13351 let child = spawn_and_mark_running(&spec, &runtime, &snapshot)
13352 .expect("spawn the supervised fixture");
13353 let grandchild = read_grandchild_pid(&pid_file);
13354 Fixture {
13355 _dir: dir,
13356 module_id: module_id.to_string(),
13357 grandchild,
13358 child: Some(child),
13359 registry: Arc::new(Registry::default()),
13360 snapshot,
13361 terminal_ring: Arc::clone(&runtime.terminal_ring),
13362 spawn_events: SpawnEventFeed::default(),
13363 }
13364 }
13365
13366 impl Fixture {
13367 async fn drain(&mut self) {
13369 let child = self
13370 .child
13371 .take()
13372 .expect("the fixture child is still present");
13373 drain_child_to_state(
13374 &self.module_id,
13375 ModuleProtocol::Subc,
13376 StopNotice::NotSent,
13379 &self.registry,
13380 None,
13381 &self.snapshot,
13382 &self.terminal_ring,
13383 &self.spawn_events,
13384 child,
13385 Duration::from_millis(500),
13386 ModuleState::Stopped,
13387 Some(false),
13388 )
13389 .await
13390 .expect("drain the supervised fixture");
13391 }
13392 }
13393
13394 #[tokio::test]
13400 async fn teardown_reaps_the_grandchild() {
13401 let mut fixture = fixture("teardown-grandchild", "tree-teardown");
13402 let grandchild = fixture.grandchild;
13403
13404 assert!(
13405 subc_jobobject::process_exists(grandchild),
13406 "grandchild {grandchild} must be alive before teardown, or this proves nothing"
13407 );
13408
13409 fixture.drain().await;
13410
13411 assert!(
13412 subc_jobobject::wait_for_process_exit(grandchild, Duration::from_secs(10)),
13413 "grandchild {grandchild} outlived module teardown: the tree was not contained"
13414 );
13415 }
13416
13417 #[test]
13430 fn an_uncontained_grandchild_survives_a_direct_child_kill() {
13431 let dir = ScratchDir::new("teardown-uncontained");
13432 let pid_file = dir.join("grandchild.pid");
13433 let mut child = std::process::Command::new(stub_path())
13434 .env("FAKE_AFT_NEVER_CONNECT", "1")
13435 .env(
13436 "FAKE_AFT_GRANDCHILD_PID_FILE",
13437 pid_file.display().to_string(),
13438 )
13439 .stdin(std::process::Stdio::null())
13440 .stdout(std::process::Stdio::null())
13441 .stderr(std::process::Stdio::null())
13442 .spawn()
13443 .expect("spawn the uncontained fixture");
13444 let grandchild = read_grandchild_pid(&pid_file);
13445
13446 child.kill().expect("kill the direct child");
13448 let _ = child.wait();
13449
13450 assert!(
13451 subc_jobobject::process_exists(grandchild),
13452 "grandchild {grandchild} died with the direct child, so this control no longer \
13453 distinguishes contained from uncontained teardown and the regression test is \
13454 passing vacuously"
13455 );
13456
13457 kill_tree(grandchild);
13460 }
13461
13462 #[tokio::test]
13471 async fn dropping_containment_reaps_the_grandchild() {
13472 let mut fixture = fixture("drop-containment", "tree-drop");
13473 let grandchild = fixture.grandchild;
13474
13475 assert!(subc_jobobject::process_exists(grandchild));
13476
13477 fixture.child.as_mut().expect("child present").job = None;
13479
13480 assert!(
13481 subc_jobobject::wait_for_process_exit(grandchild, Duration::from_secs(10)),
13482 "grandchild {grandchild} survived the containment handle closing, so a daemon \
13483 crash would leave the tree behind"
13484 );
13485 }
13486
13487 fn kill_tree(pid: u32) {
13489 let _ = std::process::Command::new("taskkill.exe")
13490 .args(["/PID", &pid.to_string(), "/T", "/F"])
13491 .stdin(std::process::Stdio::null())
13492 .stdout(std::process::Stdio::null())
13493 .stderr(std::process::Stdio::null())
13494 .status();
13495 assert!(
13496 subc_jobobject::wait_for_process_exit(pid, Duration::from_secs(10)),
13497 "could not clean up grandchild {pid}"
13498 );
13499 }
13500}
13501
13502#[cfg(test)]
13503mod privacy_trampoline_configuration_tests {
13504 #[tokio::test]
13505 #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
13506 async fn macos_spawn_without_a_configured_trampoline_refuses_by_name() {
13507 #[cfg(target_os = "macos")]
13508 {
13509 let supervisor = super::Supervisor::new(
13510 std::sync::Arc::new(crate::Registry::default()),
13511 super::RestartPolicy::default(),
13512 );
13513 let error = supervisor.spawn(spec()).unwrap_err();
13514 assert!(
13515 error
13516 .to_string()
13517 .contains("no privacy trampoline configured"),
13518 "{error}"
13519 );
13520 }
13521 }
13522
13523 #[tokio::test]
13524 #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
13525 async fn macos_wrong_trampoline_is_rejected_by_the_startup_probe() {
13526 #[cfg(target_os = "macos")]
13527 {
13528 let supervisor = super::Supervisor::new(
13529 std::sync::Arc::new(crate::Registry::default()),
13530 super::RestartPolicy::default(),
13531 )
13532 .with_privacy_trampoline(std::env::current_exe().unwrap());
13533 let error = supervisor.spawn(spec()).unwrap_err();
13534 assert!(
13535 error
13536 .to_string()
13537 .contains("binary does not implement the privacy trampoline protocol"),
13538 "{error}"
13539 );
13540 }
13541 }
13542
13543 #[cfg(target_os = "macos")]
13544 fn spec() -> super::ModuleSpec {
13545 super::ModuleSpec {
13546 module_id: "privacy-configuration".into(),
13547 program: "/bin/sleep".into(),
13548 args: vec!["30".into()],
13549 env: vec![],
13550 reserved: false,
13551 reserved_prefixes: vec![],
13552 protocol: subc_control::ModuleProtocol::None,
13553 overlap: super::ModuleOverlap::Exclusive,
13554 }
13555 }
13556}
13557
13558#[cfg(test)]
13559mod privacy_exec_boundary_tests {
13560 #[cfg(target_os = "macos")]
13561 use super::*;
13562 #[cfg(target_os = "macos")]
13563 use std::{
13564 io::{Read, Write},
13565 net::{TcpListener, TcpStream},
13566 };
13567
13568 #[cfg(target_os = "macos")]
13571 pub(super) fn before_image_sample(spec: &ModuleSpec, pid: u32) {
13572 if let Some((_, path)) = spec
13573 .env
13574 .iter()
13575 .find(|(key, _)| key == "SUBC_TEST_PRIVACY_SAMPLE_BARRIER")
13576 {
13577 let mut barrier = TcpStream::connect(path).unwrap();
13578 barrier
13579 .set_read_timeout(Some(Duration::from_secs(30)))
13580 .unwrap();
13581 barrier.write_all(&pid.to_ne_bytes()).unwrap();
13582 let mut release = [0];
13583 barrier.read_exact(&mut release).unwrap();
13584 assert_eq!(&release, b"X");
13585 }
13586 }
13587
13588 #[cfg(target_os = "macos")]
13589 fn spec(program: &str, args: &[&str]) -> ModuleSpec {
13590 ModuleSpec {
13591 module_id: "privacy-boundary".into(),
13592 program: program.into(),
13593 args: args.iter().map(|arg| (*arg).into()).collect(),
13594 env: vec![],
13595 reserved: false,
13596 reserved_prefixes: vec![],
13597 protocol: ModuleProtocol::None,
13598 overlap: ModuleOverlap::Exclusive,
13599 }
13600 }
13601
13602 #[cfg(target_os = "macos")]
13603 async fn accept(listener: TcpListener) -> TcpStream {
13604 let listener = tokio::net::TcpListener::from_std({
13606 listener.set_nonblocking(true).unwrap();
13607 listener
13608 })
13609 .unwrap();
13610 let (stream, _) = tokio::time::timeout(Duration::from_secs(30), listener.accept())
13611 .await
13612 .unwrap()
13613 .unwrap();
13614 let stream = stream.into_std().unwrap();
13615 stream.set_nonblocking(false).unwrap();
13616 stream
13617 .set_read_timeout(Some(Duration::from_secs(30)))
13618 .unwrap();
13619 stream
13620 }
13621
13622 #[tokio::test]
13623 #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
13624 async fn macos_roster_withholds_a_nonnull_trampoline_image_until_exec_confirmation() {
13625 #[cfg(target_os = "macos")]
13626 {
13627 let root = cortexkit_test_support::ScratchDir::new("privacy-roster-barrier");
13628 let exec_listener = TcpListener::bind("127.0.0.1:0").unwrap();
13631 let sample_listener = TcpListener::bind("127.0.0.1:0").unwrap();
13632 let record = root.join("live-children.json");
13633 let supervisor =
13634 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
13635 .with_live_children_record(&record);
13636 let runtime = supervisor.runtime_config();
13637 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
13638 let mut spec = spec("/bin/sleep", &["30"]);
13639 spec.env = vec![
13640 (
13641 "SUBC_TEST_PRIVACY_EXEC_BARRIER".into(),
13642 exec_listener.local_addr().unwrap().to_string(),
13643 ),
13644 (
13645 "SUBC_TEST_PRIVACY_SAMPLE_BARRIER".into(),
13646 sample_listener.local_addr().unwrap().to_string(),
13647 ),
13648 ];
13649 let spawn = tokio::task::spawn_blocking(move || {
13650 spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap()
13651 });
13652 let mut sample = accept(sample_listener).await;
13653 let mut pid = [0; 4];
13654 sample.read_exact(&mut pid).unwrap();
13655 let pid = u32::from_ne_bytes(pid);
13656 let mut exec = accept(exec_listener).await;
13657 let mut ready = [0];
13658 exec.read_exact(&mut ready).unwrap();
13659 assert_eq!(&ready, b"R");
13660 let trampoline = subc_os::file_identity(&test_privacy_trampoline()).unwrap();
13663 assert_eq!(
13664 observe_spawned_image(pid).unwrap().executable,
13665 Some(trampoline)
13666 );
13667 sample.write_all(b"X").unwrap();
13668 let mut child = spawn.await.unwrap();
13669 let early = crate::live_children::read_record(&record).unwrap();
13670 assert_eq!(early.len(), 1);
13671 assert_eq!(early[0].pid, pid);
13672 assert_eq!(
13673 early[0].executable, None,
13674 "unconfirmed trampoline image entered the roster"
13675 );
13676 assert!(child.report_ready.get().is_none());
13677 child.privacy_exec.as_mut().unwrap().deadline =
13680 tokio::time::Instant::now() + Duration::from_secs(30);
13681 exec.write_all(b"X").unwrap();
13682 child.confirm_privacy_exec().await;
13683 assert_eq!(child.spawn_failure, None);
13684 assert!(child.report_ready.get().is_some());
13685 let confirmed = crate::live_children::read_record(&record).unwrap();
13686 let module = subc_os::file_identity(std::path::Path::new("/bin/sleep")).unwrap();
13687 assert_ne!(module, trampoline);
13688 assert_eq!(confirmed[0].executable, Some(module.into()));
13689 child.start_kill().unwrap();
13690 child.wait().await.unwrap();
13691 child.release_roster();
13692 }
13693 }
13694
13695 #[tokio::test]
13696 #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
13697 async fn macos_already_exited_121_in_try_wait_is_a_module_exit_not_a_trampoline_refusal() {
13698 #[cfg(target_os = "macos")]
13699 {
13700 let registry = Arc::new(Registry::default());
13701 let policy = RestartPolicy::new(0, Duration::ZERO);
13702 let supervisor = Supervisor::new_for_test(Arc::clone(®istry), policy);
13703 let runtime = supervisor.runtime_config();
13704 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
13705 let spec = spec("/bin/sh", &["-c", "exit 121"]);
13706 let mut child = spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap();
13710 let pid = child.pid;
13711 tokio::task::spawn_blocking(move || {
13712 subc_os::fork_exec_test::wait_for_child_exit_without_reaping(pid)
13713 })
13714 .await
13715 .unwrap()
13716 .unwrap();
13717 child.privacy_exec.as_mut().unwrap().deadline =
13718 tokio::time::Instant::now() + Duration::from_secs(30);
13719 let status = child.wait().await.unwrap();
13720 assert_eq!(status.code(), Some(121));
13721 assert!(child.privacy_exec.is_none());
13722 assert!(
13723 child.report_ready.get().is_none(),
13724 "an exited module must not publish a live pid"
13725 );
13726 let report = classify_reaped_child_exit(&snapshot, &child, &status);
13727 on_child_exit(
13728 &spec,
13729 policy,
13730 ®istry,
13731 &snapshot,
13732 &runtime.terminal_ring,
13733 &runtime.spawn_events,
13734 &runtime.child_roster,
13735 report,
13736 )
13737 .await;
13738 let state = lock_snapshot(&snapshot).unwrap();
13739 assert_eq!(state.state, ModuleState::Failed);
13740 assert_eq!(state.last_exit.as_ref().unwrap().code, Some(121));
13741 assert_eq!(state.reported_pid(), None);
13742 drop(state);
13743 let history = runtime.terminal_ring.lock().unwrap().snapshot();
13744 assert_eq!(history.entries.len(), 1);
13745 let terminal = &history.entries[0];
13746 assert_eq!(terminal.exit_code, Some(121));
13747 assert_eq!(terminal.exit_kind, subc_control::TerminalExitKind::Crash);
13748 assert_eq!(terminal.disposition, TerminalDisposition::Failed);
13749 assert_eq!(
13750 terminal.disposition_detail.as_deref(),
13751 Some(policy.budget_exhausted_detail().as_str()),
13752 "module exit 121 was classified as a trampoline refusal: {terminal:?}"
13753 );
13754 assert_eq!(child.spawn_failure, None);
13755 child.release_roster();
13756 }
13757 }
13758}
13759
13760#[cfg(all(test, unix))]
13764mod launch_nonce_descriptor_tests {
13765 use super::{ChildRoster, ModuleProtocol, ModuleSpec, SupervisorHandle};
13766 use crate::stderr_tail::{StderrRing, StderrTailConfig};
13767 use cortexkit_test_support::ScratchDir;
13768 use std::{
13769 path::PathBuf,
13770 sync::{Arc, Mutex},
13771 time::{Duration, Instant},
13772 };
13773
13774 async fn probe(role: super::SpawnRole) {
13775 let scratch = ScratchDir::new("launch-nonce-descriptor");
13776 let fd_copy = scratch.join("from-descriptor");
13777 let env_copy = scratch.join("environment");
13778 let script = format!(
13779 "env > '{env}.tmp' && mv '{env}.tmp' '{env}'; cat <&3 > '{fd}.tmp' && mv '{fd}.tmp' '{fd}'; sleep 30",
13780 fd = fd_copy.display(), env = env_copy.display(),
13781 );
13782 let xdg = |name: &str| (name.to_string(), scratch.join(name).display().to_string());
13783 let spec = ModuleSpec {
13784 module_id: "nonce-descriptor-probe".to_string(),
13785 program: PathBuf::from("/bin/sh"),
13786 args: vec!["-c".to_string(), script],
13787 env: vec![
13788 xdg("XDG_DATA_HOME"),
13789 xdg("XDG_RUNTIME_DIR"),
13790 xdg("XDG_CONFIG_HOME"),
13791 ("SUBC_LAUNCH_NONCE".to_string(), "must-not-leak".to_string()),
13792 ],
13793 reserved: true,
13794 reserved_prefixes: Vec::new(),
13795 protocol: ModuleProtocol::Subc,
13796 overlap: Default::default(),
13797 };
13798 let handle = SupervisorHandle::new();
13799 let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
13800 let roster = ChildRoster::default();
13801 #[cfg(target_os = "macos")]
13802 {
13803 let path = super::test_privacy_trampoline();
13804 roster.set_privacy_trampoline(super::probe_privacy_trampoline(&path).map(|()| path));
13805 }
13806 let child = super::spawn_child_in_slot(
13807 &spec,
13808 None,
13809 Some(&handle),
13810 &ring,
13811 None,
13812 &roster,
13813 #[cfg(target_os = "linux")]
13814 None,
13815 role,
13816 matches!(role, super::SpawnRole::SwapCandidate),
13817 )
13818 .expect("spawn probe");
13819 let deadline = Instant::now() + Duration::from_secs(10);
13820 while !(fd_copy.exists() && env_copy.exists()) {
13821 assert!(Instant::now() < deadline, "probe never wrote its copies");
13822 tokio::time::sleep(Duration::from_millis(20)).await;
13823 }
13824 let nonce = std::fs::read_to_string(fd_copy).unwrap();
13825 assert!(!nonce.is_empty());
13826 let environment = std::fs::read_to_string(env_copy).unwrap();
13827 assert!(environment
13828 .lines()
13829 .any(|line| line.starts_with("SUBC_LAUNCH_NONCE_FD=3:")));
13830 let copy = environment
13831 .lines()
13832 .find_map(|line| line.strip_prefix("SUBC_LAUNCH_NONCE="));
13833 assert_eq!(
13834 copy, None,
13835 "Unix children must never receive the environment nonce"
13836 );
13837 if matches!(role, super::SpawnRole::Plain) {
13838 assert_eq!(
13839 handle.spawn_nonce(&spec.module_id).as_deref(),
13840 Some(nonce.as_str())
13841 );
13842 }
13843 drop(child);
13844 }
13845
13846 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
13847 async fn a_spawned_module_receives_its_nonce_only_on_descriptor_3() {
13848 probe(super::SpawnRole::Plain).await;
13849 }
13850
13851 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
13852 async fn swap_candidate_receives_its_nonce_only_on_descriptor_3() {
13853 probe(super::SpawnRole::SwapCandidate).await;
13854 }
13855}
13856
13857#[cfg(all(test, target_os = "linux"))]
13858mod cgroup_containment_tests {
13859 use super::*;
13860 use cortexkit_test_support::ScratchDir;
13861
13862 fn running(pid: u32) -> bool {
13863 std::fs::read_to_string(format!("/proc/{pid}/stat"))
13865 .ok()
13866 .and_then(|stat| {
13867 stat.rsplit_once(") ")
13868 .map(|(_, rest)| rest.starts_with('Z'))
13869 })
13870 .is_some_and(|zombie| !zombie)
13871 }
13872
13873 #[tokio::test]
13874 async fn linux_teardown_reaps_the_grandchild() {
13875 teardown_tree("linux_teardown_reaps_the_grandchild", false).await;
13876 }
13877
13878 #[tokio::test]
13879 async fn linux_shutdown_straggler_reaps_the_grandchild() {
13880 teardown_tree("linux_shutdown_straggler_reaps_the_grandchild", true).await;
13881 }
13882
13883 async fn teardown_tree(test_name: &str, shutdown: bool) {
13884 let dir = ScratchDir::new(test_name);
13885 let root = PathBuf::from(format!(
13886 "/sys/fs/cgroup/subc-tree-test-{test_name}-{}-{}",
13887 std::process::id(),
13888 unix_ms_now()
13889 ));
13890 if let Err(error) = std::fs::create_dir(&root) {
13891 assert!(
13892 std::env::var_os("SUBC_REQUIRE_CGROUP_TEST").is_none(),
13893 "required cgroup test cannot execute: {error}"
13894 );
13895 eprintln!(
13896 "SKIP {test_name}: no writable delegated cgroup at {}: {error}",
13897 root.display()
13898 );
13899 return;
13900 }
13901 let placement = subc_cgroup::prepare_at(&root)
13902 .expect("prepare isolated kernel cgroup")
13903 .expect("isolated cgroup is delegated");
13904 let module_id = "tree-teardown";
13905 let module = placement
13906 .module_path(module_id)
13907 .expect("create isolated module cgroup");
13908 if !module.join("cgroup.kill").exists() {
13909 std::fs::remove_dir(&module).unwrap();
13910 std::fs::remove_dir(root.join("subc-modules")).unwrap();
13911 std::fs::remove_dir(&root).unwrap();
13912 assert!(
13913 std::env::var_os("SUBC_REQUIRE_CGROUP_TEST").is_none(),
13914 "required cgroup.kill interface unavailable"
13915 );
13916 eprintln!("SKIP {test_name}: cgroup.kill unavailable (kernel < 5.14)");
13917 return;
13918 }
13919 let supervisor = Supervisor::new_for_test(
13920 Arc::new(Registry::default()),
13921 RestartPolicy::new(3, Duration::ZERO),
13922 )
13923 .with_cgroup_placement(Some(placement));
13924 let mut runtime = supervisor.runtime_config();
13925 runtime.child_roster = runtime
13926 .child_roster
13927 .for_module(Arc::new(Mutex::new(Duration::from_millis(100))));
13928 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
13929 let pid_file = dir.join("grandchild.pid");
13930 let spec = ModuleSpec {
13931 module_id: module_id.to_string(),
13932 program: PathBuf::from("/bin/sh"),
13933 args: vec![
13934 "-c".into(),
13935 "trap '' TERM; sleep 600 & echo $! > \"$1\"; wait".into(),
13936 "fixture".into(),
13937 pid_file.display().to_string(),
13938 ],
13939 env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
13940 .into_iter()
13941 .map(|key| (key.to_string(), dir.display().to_string()))
13942 .collect(),
13943 reserved: false,
13944 reserved_prefixes: Vec::new(),
13945 protocol: ModuleProtocol::None,
13946 overlap: Default::default(),
13947 };
13948 let child =
13949 spawn_and_mark_running(&spec, &runtime, &snapshot).expect("spawn supervised tree");
13950 let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
13951 let grandchild: u32 = loop {
13952 if let Ok(contents) = std::fs::read_to_string(&pid_file) {
13953 if let Ok(pid) = contents.trim().parse() {
13954 break pid;
13955 }
13956 }
13957 assert!(
13958 tokio::time::Instant::now() < deadline,
13959 "grandchild pid was not recorded"
13960 );
13961 tokio::time::sleep(Duration::from_millis(10)).await;
13962 };
13963 assert!(
13964 running(grandchild),
13965 "grandchild must be alive before teardown"
13966 );
13967 if shutdown {
13968 let mut child = child;
13969 crate::child_roster::end_children_for_daemon_shutdown(
13970 &runtime.child_roster,
13971 false,
13972 std::future::pending(),
13973 )
13974 .await;
13975 child.wait().await.expect("reap shutdown straggler");
13976 } else {
13977 drain_child_to_state(
13978 module_id,
13979 ModuleProtocol::None,
13980 StopNotice::NotSent,
13981 &Registry::default(),
13982 None,
13983 &snapshot,
13984 &runtime.terminal_ring,
13985 &SpawnEventFeed::default(),
13986 child,
13987 Duration::from_millis(100),
13988 ModuleState::Stopped,
13989 Some(false),
13990 )
13991 .await
13992 .expect("real supervisor teardown");
13993 }
13994 let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
13995 while running(grandchild) && tokio::time::Instant::now() < deadline {
13996 tokio::time::sleep(Duration::from_millis(10)).await;
13997 }
13998 let survived = running(grandchild);
13999 if survived {
14001 let pid = rustix::process::Pid::from_raw(grandchild as i32).unwrap();
14002 let _ = rustix::process::kill_process(pid, rustix::process::Signal::KILL);
14003 tokio::time::sleep(Duration::from_millis(100)).await;
14004 }
14005 if module.exists() {
14006 std::fs::remove_dir(&module).expect("remove empty module cgroup");
14007 }
14008 std::fs::remove_dir(root.join("subc-modules")).unwrap();
14009 std::fs::remove_dir(&root).unwrap();
14010 assert!(
14011 !survived,
14012 "grandchild {grandchild} outlived module teardown"
14013 );
14014 eprintln!("EXECUTED {test_name}: grandchild {grandchild} killed in isolated cgroup");
14015 }
14016}