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, Principal, Priority, SUBC_LAUNCH_NONCE_ENV, SUBC_MODULE_ID_ENV,
24};
25use tokio::{
26 process::{Child, Command},
27 sync::{mpsc, oneshot, watch, Mutex as AsyncMutex},
28 task::JoinHandle,
29 time::{sleep, sleep_until, timeout, timeout_at, Instant},
30};
31use tracing::{debug, error, info, warn};
32
33use crate::{
34 child_roster::ChildRoster,
35 daemon_config::{
36 CAPTURE_KEEP_ENV, CAPTURE_MAX_AGE_DAYS_ENV, CAPTURE_MAX_FILE_MB_ENV, CK_LOG_ENV,
37 },
38 forwarding::{
39 CloseReason, ForwardingError, ForwardingTable, GoodbyeTarget, ModuleControlRpcOutcome,
40 ModuleDrainTarget, PendingModuleControlRpc,
41 },
42 provenance::{ExecutableIdentityProbe, SpawnedFileIdentity},
43 registry::{ConnectionId, RegistrationEndReason, RegistryError},
44 stderr_tail::{
45 pump_stderr_to, pump_stdout_to, ChildOutputSink, StderrRing, StderrTailConfig,
46 StderrTailSnapshot,
47 },
48 terminal_ring::{TerminalHistorySnapshot, TerminalRecord, TerminalRing, TerminalRingConfig},
49 Frame, FrameSink, Registry,
50};
51
52#[cfg(not(windows))]
53use crate::provenance::spawned_file_identity;
54
55#[path = "supervise_swap.rs"]
56mod swap;
57
58#[path = "supervise_activity.rs"]
59mod activity;
60
61pub const SUBC_ARG: &str = "--subc";
67
68const DEFAULT_MAX_RESTARTS: u32 = 3;
69const DEFAULT_BACKOFF: Duration = Duration::from_millis(100);
70const DEFAULT_MAX_BACKOFF: Duration = Duration::from_secs(30);
71const DEFAULT_RESTART_WINDOW: Duration = Duration::from_secs(600);
75pub const DEFAULT_DRAIN_TIMEOUT: Duration = Duration::from_secs(30);
86const REGISTRY_RELEASE_TIMEOUT: Duration = Duration::from_secs(1);
87const REGISTRY_RELEASE_POLL: Duration = Duration::from_millis(10);
88const STDERR_PUMP_DRAIN_TIMEOUT: Duration = Duration::from_millis(250);
111pub const SPAWN_EVENT_RING_CAPACITY: usize = 4096;
113const SPAWN_SUBSCRIBER_BUFFER: usize = SPAWN_EVENT_RING_CAPACITY + 1;
114pub(crate) const SPAWN_SUBSCRIBER_LAGGED_CODE: &str = "spawn_subscriber_lagged";
119
120#[cfg(target_os = "macos")]
121struct PrivacyExec {
122 reader: tokio::io::unix::AsyncFd<std::io::PipeReader>,
123 deadline: tokio::time::Instant,
124 expected: Option<subc_os::FileIdentity>,
125 trampoline: Option<subc_os::FileIdentity>,
126 script: bool,
127 module_id: String,
128}
129
130#[cfg(all(test, target_os = "macos"))]
131pub(crate) fn test_privacy_trampoline() -> PathBuf {
132 let path = std::env::current_exe()
135 .unwrap()
136 .parent()
137 .unwrap()
138 .parent()
139 .unwrap()
140 .join("privacy-trampoline-fixture");
141 assert!(
145 path.exists(),
146 "privacy-trampoline-fixture not built at {}: run `cargo build -p subc-daemon \
147 --bins --features test-support` or `cargo test -p subc-daemon` first",
148 path.display()
149 );
150 path
151}
152
153#[cfg(target_os = "macos")]
154fn probe_privacy_trampoline(path: &std::path::Path) -> Result<(), String> {
155 use std::io::Read;
156 let mut probe = std::process::Command::new(path)
157 .args(["__disclaim-exec", "--probe"])
158 .stdin(Stdio::null())
159 .stdout(Stdio::piped())
160 .stderr(Stdio::piped())
161 .spawn()
162 .map_err(|error| {
163 format!(
164 "privacy trampoline probe failed for {}: {error}",
165 path.display()
166 )
167 })?;
168 let deadline = std::time::Instant::now() + Duration::from_secs(5);
169 let status = loop {
170 match probe.try_wait() {
171 Ok(Some(status)) => break status,
172 Ok(None) if std::time::Instant::now() < deadline => {
173 std::thread::sleep(Duration::from_millis(5))
174 }
175 result => {
176 let _ = probe.kill();
177 let _ = probe.wait();
178 return Err(format!(
179 "privacy trampoline probe failed or timed out for {}: {result:?}",
180 path.display()
181 ));
182 }
183 }
184 };
185 let mut answer = String::new();
186 if let Some(stdout) = probe.stdout.take() {
187 let _ = stdout.take(256).read_to_string(&mut answer);
188 }
189 if status.success() && answer.trim() == subc_os::privacy_identity::TRAMPOLINE_PROBE {
190 return Ok(());
191 }
192 let mut diagnostic = String::new();
193 if let Some(stderr) = probe.stderr.take() {
194 let _ = stderr.take(1024).read_to_string(&mut diagnostic);
195 }
196 let cause = diagnostic
197 .trim()
198 .strip_prefix("ck-subc: own privacy identity refused: ")
199 .unwrap_or("binary does not implement the privacy trampoline protocol");
200 Err(format!(
201 "{cause}: probe of {} exited {status}",
202 path.display()
203 ))
204}
205
206#[cfg(target_os = "macos")]
207fn privacy_command(
208 spec: &ModuleSpec,
209 roster: &ChildRoster,
210) -> Result<
211 (
212 Command,
213 Option<PrivacyExec>,
214 subc_os::privacy_identity::ExecAcknowledgement,
215 ),
216 SuperviseError,
217> {
218 let failure = |cause: String| {
219 warn!(module_id = %spec.module_id, %cause, "privacy identity trampoline refused module spawn");
220 SuperviseError::Spawn {
221 program: spec.program.clone(),
222 source: io::Error::other(cause),
223 cgroup_path: None,
224 }
225 };
226 let trampoline = roster.privacy_trampoline().map_err(failure)?;
227 let program = if spec.program.components().count() == 1 && !spec.program.is_absolute() {
232 let path = spec
233 .env
234 .iter()
235 .find(|(key, _)| key == "PATH")
236 .map(|(_, value)| std::ffi::OsString::from(value))
237 .or_else(|| std::env::var_os("PATH"))
238 .unwrap_or_else(|| "/usr/bin:/bin".into());
239 std::env::split_paths(&path)
240 .map(|dir| dir.join(&spec.program))
241 .find(|path| path.is_file())
242 .unwrap_or_else(|| spec.program.clone())
243 } else {
244 spec.program.clone()
245 };
246 let expected = subc_os::file_identity(&program);
247 let trampoline_image = subc_os::file_identity(&trampoline);
248 let script = {
249 use std::io::Read;
250 let mut prefix = [0u8; 2];
251 std::fs::File::open(&program)
252 .is_ok_and(|mut file| file.read_exact(&mut prefix).is_ok() && &prefix == b"#!")
253 };
254 if expected.is_none() || expected == subc_os::file_identity(&trampoline) {
255 return Err(failure(
256 "privacy identity module executable is missing or is the trampoline itself".to_string(),
257 ));
258 }
259 let (reader, ack) = subc_os::privacy_identity::ExecAcknowledgement::pipe()
260 .map_err(|error| failure(error.to_string()))?;
261 let reader =
262 tokio::io::unix::AsyncFd::new(reader).map_err(|error| failure(error.to_string()))?;
263 let mut command = Command::new(&trampoline);
264 command
265 .arg("__disclaim-exec")
266 .arg(ack.fd().to_string())
267 .arg(&program);
268 ack.install(command.as_std_mut());
269 Ok((
270 command,
271 Some(PrivacyExec {
272 reader,
273 deadline: tokio::time::Instant::now() + Duration::from_secs(5),
274 expected,
275 trampoline: trampoline_image,
276 script,
277 module_id: spec.module_id.clone(),
278 }),
279 ack,
280 ))
281}
282
283struct SupervisedChild {
284 child: Child,
285 #[cfg(windows)]
286 nonce_delivery: Option<subc_os::LaunchNoncePipeDelivery>,
287 #[cfg(target_os = "macos")]
288 privacy_exec: Option<PrivacyExec>,
289 #[cfg(target_os = "macos")]
296 report_ready: Arc<OnceLock<()>>,
297 spawn_failure: Option<String>,
299 protocol: ModuleProtocol,
304 #[cfg(target_os = "linux")]
310 module_id: String,
311 #[cfg(target_os = "linux")]
312 cgroup_placement: Option<subc_cgroup::Placement>,
313 #[cfg(windows)]
335 job: Option<subc_jobobject::JobObject>,
336 stdout_pump: Option<JoinHandle<()>>,
337 stderr_pump: Option<StderrPump>,
338 stderr_ring: Arc<Mutex<StderrRing>>,
339 spawned_at_ms: u64,
340 spawned_from: PathBuf,
341 spawned_file_identity: Option<SpawnedFileIdentity>,
342 process_start_time: Option<u64>,
343 process_identity: Option<ProcessIdentity>,
344 pid: u32,
345 roster_guard: Option<crate::child_roster::RosterGuard>,
348}
349
350impl SupervisedChild {
351 fn id(&self) -> Option<u32> {
352 Some(self.pid)
353 }
354
355 fn process_identity(&self) -> Option<ProcessIdentity> {
356 self.process_identity
357 }
358
359 async fn wait(&mut self) -> io::Result<ExitStatus> {
360 #[cfg(target_os = "macos")]
361 self.confirm_privacy_exec().await;
362 let result = self.child.wait().await;
370 #[cfg(target_os = "linux")]
371 if result.is_ok() {
372 if let Some(placement) = self.cgroup_placement.as_ref() {
373 cleanup_reaped_cgroup(placement, &self.module_id).await;
374 self.cgroup_placement = None;
377 }
378 }
379 result
380 }
381
382 #[cfg(target_os = "macos")]
383 async fn confirm_privacy_exec(&mut self) {
384 let Some(pending) = &mut self.privacy_exec else {
385 return;
386 };
387 let result = tokio::time::timeout_at(pending.deadline, async {
388 let mut record = Vec::new();
389 loop {
390 let mut ready = pending.reader.readable().await?;
391 let read = ready.try_io(|reader| {
392 use std::io::Read;
393 let mut reader = reader.get_ref();
394 let mut buffer = [0u8; 256];
395 reader.read(&mut buffer).map(|count| (count, buffer))
396 });
397 match read {
398 Ok(Ok((0, _))) => return Ok::<_, io::Error>(record),
399 Ok(Ok((count, buffer))) => {
400 if record.len() + count > 1024 {
401 return Err(io::Error::other(
402 "privacy exec refusal record is too long",
403 ));
404 }
405 record.extend_from_slice(&buffer[..count]);
406 }
407 Ok(Err(error)) => return Err(error),
408 Err(_) => continue,
409 }
410 }
411 })
412 .await;
413 let pending = self.privacy_exec.as_ref().expect("pending exec");
416 let cause = match result {
417 Err(_) => Some("privacy identity trampoline did not exec within 5s".to_string()),
418 Ok(Err(error)) => Some(format!(
419 "privacy identity exec acknowledgement failed: {error}"
420 )),
421 Ok(Ok(record)) if !record.is_empty() => Some(
422 std::str::from_utf8(&record)
423 .ok()
424 .and_then(|record| {
425 record
426 .trim()
427 .strip_prefix(subc_os::privacy_identity::EXEC_REFUSAL_TAG)
428 })
429 .filter(|cause| !cause.is_empty())
430 .unwrap_or("invalid privacy exec refusal record")
431 .to_string(),
432 ),
433 Ok(Ok(_)) => match self.child.try_wait() {
434 Ok(Some(_status)) => None,
438 Err(error) => Some(format!("privacy identity trampoline wait failed: {error}")),
439 Ok(None) => {
440 let image = observe_spawned_image(self.pid);
441 if let Some(image) = image.filter(|image| {
442 image.executable.is_some()
443 && image.executable != pending.trampoline
444 && (image.executable == pending.expected || pending.script)
445 }) {
446 if let Some(guard) = &self.roster_guard {
447 guard.confirm_executable(image);
448 }
449 let _ = self.report_ready.set(());
450 info!(module_id = %pending.module_id, pid = self.pid,
451 "module spawned with own privacy identity (responsibility disclaimed)");
452 None
453 } else if image.is_none()
454 || image.is_some_and(|image| image.executable.is_none())
455 {
456 match tokio::time::timeout_at(pending.deadline, self.child.wait()).await {
463 Ok(Ok(_status)) => None,
464 Ok(Err(error)) => Some(format!("privacy identity trampoline wait failed: {error}")),
465 Err(_) => Some("privacy identity module executable remained unreadable until the 5s exec deadline".to_string()),
466 }
467 } else {
468 Some("privacy identity trampoline executable mismatch: expected the module program, not ck-subc".to_string())
469 }
470 }
471 },
472 };
473 let pending = self.privacy_exec.take().expect("pending exec");
474 if let Some(cause) = cause {
475 warn!(module_id = %pending.module_id, pid = self.pid, %cause, "privacy identity trampoline refused module spawn");
476 self.spawn_failure = Some(cause);
477 if let Some(pid) = rustix::process::Pid::from_raw(self.pid as i32) {
480 let _ = rustix::process::kill_process_group(pid, rustix::process::Signal::KILL);
481 }
482 let _ = self.child.start_kill();
483 }
484 }
485
486 fn release_roster(&mut self) {
490 self.roster_guard = None;
491 }
492
493 fn start_kill(&mut self) -> io::Result<()> {
506 #[cfg(windows)]
507 if let Some(job) = &self.job {
508 if let Err(error) = job.terminate() {
509 debug!(
510 error = %error,
511 "job termination failed; the direct-child kill still owns the outcome"
512 );
513 }
514 }
515 #[cfg(target_os = "linux")]
516 kill_module_cgroup(self.cgroup_placement.as_ref(), &self.module_id);
517 self.child.start_kill()
518 }
519
520 async fn drain_stderr(&mut self, module_id: &str) {
521 if let Some(pump) = self.stdout_pump.take() {
522 settle_stdout_pump(module_id, pump, STDERR_PUMP_DRAIN_TIMEOUT).await;
523 }
524
525 let Some(pump) = self.stderr_pump.take() else {
526 return;
527 };
528 settle_stderr_pump(
529 module_id,
530 &self.stderr_ring,
531 pump,
532 STDERR_PUMP_DRAIN_TIMEOUT,
533 )
534 .await;
535 }
536}
537
538async fn settle_stdout_pump(module_id: &str, mut pump: JoinHandle<()>, bound: Duration) {
542 match timeout(bound, &mut pump).await {
543 Ok(Ok(())) => {}
544 Ok(Err(error)) => {
545 warn!(module_id, error = %error, "stdout pump ended unexpectedly");
546 }
547 Err(_) => {
548 drop(pump);
549 warn!(
550 module_id,
551 waited = ?bound,
552 "stdout pipe of the exited process has not drained; its reader keeps running without delaying the restart"
553 );
554 }
555 }
556}
557
558struct StderrPump {
561 task: JoinHandle<()>,
562 generation: u64,
563}
564
565async fn settle_stderr_pump(
571 module_id: &str,
572 ring: &Arc<Mutex<StderrRing>>,
573 pump: StderrPump,
574 bound: Duration,
575) {
576 let lock = || ring.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
577 let StderrPump {
578 mut task,
579 generation,
580 } = pump;
581 lock().retire_pump(generation);
582 match timeout(bound, &mut task).await {
583 Ok(Ok(())) => {}
584 Ok(Err(err)) => {
585 let mut ring = lock();
586 ring.mark_incomplete_from(generation, format!("stderr pump ended unexpectedly: {err}"));
587 ring.finish_pump(generation);
588 warn!(module_id, error = %err, "stderr pump ended before clean EOF");
589 }
590 Err(_) => {
591 drop(task);
593 lock().mark_pump_late(
594 generation,
595 format!(
596 "stderr of the exited process had not reached EOF {bound:?} after it was \
597 retired (a descendant may still hold the pipe open); lines it still \
598 writes are kept in that process's section"
599 ),
600 );
601 warn!(
602 module_id,
603 waited = ?bound,
604 "stderr pipe of the exited process is still open; its reader keeps running without delaying the restart"
605 );
606 }
607 }
608}
609
610fn registration_release_events() -> &'static watch::Sender<u64> {
611 static EVENTS: OnceLock<watch::Sender<u64>> = OnceLock::new();
612 EVENTS.get_or_init(|| {
613 let (sender, _receiver) = watch::channel(0);
614 sender
615 })
616}
617
618pub(crate) fn notify_registration_release() {
619 let events = registration_release_events();
620 let next_generation = (*events.borrow()).wrapping_add(1);
621 events.send_replace(next_generation);
622}
623
624#[derive(Debug, Clone, PartialEq, Eq)]
626pub struct ModuleSpec {
627 pub module_id: String,
628 pub program: PathBuf,
629 pub args: Vec<String>,
630 pub env: Vec<(String, String)>,
631 pub reserved: bool,
636 pub reserved_prefixes: Vec<String>,
641 pub protocol: ModuleProtocol,
660 pub overlap: ModuleOverlap,
665}
666
667#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
674pub enum ModuleOverlap {
675 #[default]
677 Exclusive,
678 Safe,
690}
691
692impl ModuleOverlap {
693 pub fn as_str(self) -> &'static str {
694 match self {
695 Self::Exclusive => "exclusive",
696 Self::Safe => "safe",
697 }
698 }
699}
700
701pub const SUBC_SPAWN_ROLE_ENV: &str = "SUBC_SPAWN_ROLE";
711pub const SPAWN_ROLE_SWAP_CANDIDATE: &str = "swap_candidate";
713pub const DEFAULT_SWAP_READY_TIMEOUT: Duration = Duration::from_secs(100);
718
719#[derive(Debug, Clone, Copy, PartialEq, Eq)]
737pub struct RestartPolicy {
738 pub max_restarts: u32,
739 pub backoff: Duration,
742 pub max_backoff: Duration,
744 pub window: Duration,
748}
749
750impl RestartPolicy {
751 pub fn new(max_restarts: u32, backoff: Duration) -> Self {
755 Self {
756 max_restarts,
757 backoff,
758 max_backoff: DEFAULT_MAX_BACKOFF,
759 window: DEFAULT_RESTART_WINDOW,
760 }
761 }
762
763 pub fn with_max_backoff(mut self, max_backoff: Duration) -> Self {
764 self.max_backoff = max_backoff;
765 self
766 }
767
768 pub fn with_window(mut self, window: Duration) -> Self {
769 self.window = window;
770 self
771 }
772
773 fn delay_for_restart(&self, restart_in_window: u32) -> Duration {
778 if self.backoff.is_zero() || self.max_backoff.is_zero() {
779 return Duration::ZERO;
780 }
781
782 let mut delay = self.backoff;
783 for _ in 0..restart_in_window {
784 if delay >= self.max_backoff {
785 return self.max_backoff;
786 }
787 delay = delay
788 .checked_mul(10)
789 .unwrap_or(self.max_backoff)
790 .min(self.max_backoff);
791 }
792 delay.min(self.max_backoff)
793 }
794
795 fn budget_exhausted_detail(&self) -> String {
800 format!(
801 "crash budget exhausted: max_restarts={} within window_secs={}",
802 self.max_restarts,
803 self.window.as_secs()
804 )
805 }
806}
807
808impl Default for RestartPolicy {
809 fn default() -> Self {
810 Self {
811 max_restarts: DEFAULT_MAX_RESTARTS,
812 backoff: DEFAULT_BACKOFF,
813 max_backoff: DEFAULT_MAX_BACKOFF,
814 window: DEFAULT_RESTART_WINDOW,
815 }
816 }
817}
818
819#[derive(Debug, Clone, Copy, PartialEq, Eq)]
820struct CrashRestartSchedule {
821 restart_in_window: u32,
822 delay: Duration,
823}
824
825fn daemon_will_restart(
832 state: &mut SupervisorSnapshot,
833 policy: &RestartPolicy,
834 now: Instant,
835) -> bool {
836 state.enabled && state.crash_restarts_in_window(policy.window, now) < policy.max_restarts
837}
838
839const DEFAULT_HEALTH_CADENCE: Duration = Duration::from_secs(30);
840const DEFAULT_HEALTH_DEADLINE: Duration = Duration::from_secs(5);
841const DEFAULT_HEALTH_FAILURE_THRESHOLD: u32 = 3;
842const MAX_HEALTH_METRICS_BYTES: usize = 16 * 1024;
843
844#[derive(Debug, Clone, Copy, PartialEq, Eq)]
845pub enum HealthAction {
846 Report,
847 Restart,
848 Alert,
849}
850
851impl fmt::Display for HealthAction {
852 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
853 f.write_str(match self {
854 Self::Report => "report",
855 Self::Restart => "restart",
856 Self::Alert => "alert",
857 })
858 }
859}
860
861#[derive(Debug, Clone, PartialEq, Eq)]
862pub struct HealthConfig {
863 pub http: Option<String>,
867 pub cadence: Duration,
868 pub deadline: Duration,
869 pub failure_threshold: u32,
870 pub on_degraded: HealthAction,
871 pub on_failing: HealthAction,
872 pub critical: bool,
873}
874
875impl Default for HealthConfig {
876 fn default() -> Self {
877 Self {
878 http: None,
879 cadence: DEFAULT_HEALTH_CADENCE,
880 deadline: DEFAULT_HEALTH_DEADLINE,
881 failure_threshold: DEFAULT_HEALTH_FAILURE_THRESHOLD,
882 on_degraded: HealthAction::Report,
883 on_failing: HealthAction::Report,
884 critical: false,
885 }
886 }
887}
888
889#[derive(Debug, Clone, PartialEq)]
907pub struct ModuleHealthStatus {
908 pub status: SupervisorHealthStatus,
909 pub last_probe_ms: Option<u64>,
910 pub detail: Option<String>,
911 pub metrics: Option<Value>,
912 pub consecutive_failures: u32,
913 pub late_answer_count: u64,
916 pub last_late_answer_latency_ms: Option<u64>,
918 pub last_action: Option<String>,
919 pub last_action_ms: Option<u64>,
923}
924
925impl Default for ModuleHealthStatus {
926 fn default() -> Self {
927 Self {
928 status: SupervisorHealthStatus::Unknown,
929 last_probe_ms: None,
930 detail: None,
931 metrics: None,
932 consecutive_failures: 0,
933 late_answer_count: 0,
934 last_late_answer_latency_ms: None,
935 last_action: None,
936 last_action_ms: None,
937 }
938 }
939}
940
941#[derive(Debug, Clone, Copy, PartialEq, Eq)]
943pub enum ModuleState {
944 Starting,
945 Running,
946 Unresponsive,
947 Restarting,
948 Draining,
949 Stopped,
950 Failed,
951 Disabled,
952}
953
954impl fmt::Display for ModuleState {
955 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
956 f.write_str(match self {
957 Self::Starting => "starting",
958 Self::Running => "running",
959 Self::Unresponsive => "unresponsive",
960 Self::Restarting => "restarting",
961 Self::Draining => "draining",
962 Self::Stopped => "stopped",
963 Self::Failed => "failed",
964 Self::Disabled => "disabled",
965 })
966 }
967}
968
969#[derive(Debug, Clone, Copy, PartialEq, Eq)]
971pub enum ExitKind {
972 Clean,
973 Crash,
974 DeliberateSeverance,
975}
976
977impl From<ExitKind> for TerminalExitKind {
978 fn from(kind: ExitKind) -> Self {
979 match kind {
980 ExitKind::Clean => Self::Clean,
981 ExitKind::Crash => Self::Crash,
982 ExitKind::DeliberateSeverance => Self::DeliberateSeverance,
983 }
984 }
985}
986
987#[derive(Debug, Clone, Copy, PartialEq, Eq)]
990pub(crate) struct ProcessIdentity {
991 pub(crate) pid: u32,
992 pub(crate) start_time: u64,
993}
994
995#[derive(Debug, Clone, PartialEq, Eq)]
997pub struct ExitReport {
998 pub kind: ExitKind,
999 pub code: Option<i32>,
1000 pub signal: Option<i32>,
1001 pub at_ms: u64,
1002}
1003
1004#[derive(Debug, Clone, PartialEq)]
1007pub struct ModuleStatus {
1008 pub module_id: String,
1009 pub waiting_for_previous_pid: Option<u32>,
1011 pub launch_nonce_source: Option<String>,
1016 pub state: ModuleState,
1017 pub enabled: bool,
1018 pub process_alive: bool,
1019 pub registration_active: bool,
1020 pub protocol: ModuleProtocol,
1025 pub live: bool,
1036 pub restart_count: u32,
1040 pub lifetime_restarts: u32,
1044 pub spawn_generation: u64,
1045 pub max_restarts: u32,
1050 pub restart_window: Duration,
1054 pub drain_timeout: Duration,
1058 pub restart_backoff: Duration,
1059 pub restart_max_backoff: Duration,
1060 pub pid: Option<u32>,
1065 pub spawned_at_ms: Option<u64>,
1066 pub spawned_from: Option<PathBuf>,
1067 pub process_start_time: Option<u64>,
1068 pub last_exit: Option<ExitReport>,
1069 pub health: ModuleHealthStatus,
1070}
1071
1072#[cfg(test)]
1073#[derive(Debug, Clone, Copy, PartialEq)]
1074struct ActorSelectCheckpoint {
1075 generation: u64,
1076 turn: u64,
1077 registered_connection: Option<ConnectionId>,
1078 next_probe_at: Option<Instant>,
1079 wake_after: Option<Duration>,
1080}
1081
1082#[derive(Debug, Clone, PartialEq)]
1083struct SupervisorSnapshot {
1084 #[cfg(windows)]
1085 pipe_consumed: Option<Arc<OnceLock<()>>>,
1086 #[cfg(test)]
1088 actor_turns: u64,
1089 #[cfg(test)]
1092 actor_select: Option<ActorSelectCheckpoint>,
1093 state: ModuleState,
1094 enabled: bool,
1095 process_alive: bool,
1096 spawned_protocol: Option<ModuleProtocol>,
1097 spawn_failure: Option<String>,
1098 waiting_for_previous_pid: Option<u32>,
1099 crash_restarts: VecDeque<Instant>,
1105 lifetime_restarts: u32,
1106 spawn_generation: u64,
1115 pid: Option<u32>,
1116 #[cfg(target_os = "macos")]
1117 report_ready: Option<Arc<OnceLock<()>>>,
1118 reaped_pid: Option<u32>,
1120 respawn_pending: bool,
1122 coalesced_restart_pending: bool,
1124 spawned_at_ms: Option<u64>,
1125 spawned_from: Option<PathBuf>,
1126 spawned_file_identity: Option<SpawnedFileIdentity>,
1127 process_start_time: Option<u64>,
1128 deliberate_severance: Option<ProcessIdentity>,
1129 last_exit: Option<ExitReport>,
1130 drain_disposition_detail: Option<String>,
1132 health: ModuleHealthStatus,
1133 in_alternate_slot: bool,
1138 draining_to_replace: bool,
1145 configuration_updated_since_spawn: bool,
1151}
1152
1153impl SupervisorSnapshot {
1154 fn reported_pid(&self) -> Option<u32> {
1159 #[cfg(target_os = "macos")]
1160 if self
1161 .report_ready
1162 .as_ref()
1163 .is_some_and(|ready| ready.get().is_none())
1164 {
1165 return None;
1166 }
1167 self.pid
1168 }
1169
1170 fn starting() -> Self {
1171 Self::new(ModuleState::Starting, true)
1172 }
1173
1174 fn disabled() -> Self {
1175 Self::new(ModuleState::Disabled, false)
1176 }
1177
1178 fn failed() -> Self {
1179 Self::new(ModuleState::Failed, true)
1180 }
1181
1182 fn crash_restarts_in_window(&mut self, window: Duration, now: Instant) -> u32 {
1186 while let Some(oldest) = self.crash_restarts.front() {
1187 if now.duration_since(*oldest) > window {
1188 self.crash_restarts.pop_front();
1189 } else {
1190 break;
1191 }
1192 }
1193 u32::try_from(self.crash_restarts.len()).unwrap_or(u32::MAX)
1194 }
1195
1196 fn record_crash_restart(&mut self, policy: &RestartPolicy, now: Instant) {
1202 self.crash_restarts.push_back(now);
1203 while self.crash_restarts.len() > policy.max_restarts as usize {
1204 self.crash_restarts.pop_front();
1205 }
1206 self.lifetime_restarts += 1;
1207 }
1208
1209 fn next_crash_restart(
1213 &mut self,
1214 policy: &RestartPolicy,
1215 now: Instant,
1216 ) -> Option<CrashRestartSchedule> {
1217 let restart_in_window = self.crash_restarts_in_window(policy.window, now);
1218 if restart_in_window >= policy.max_restarts {
1219 return None;
1220 }
1221 self.record_crash_restart(policy, now);
1222 Some(CrashRestartSchedule {
1223 restart_in_window,
1224 delay: policy.delay_for_restart(restart_in_window),
1225 })
1226 }
1227
1228 fn clear_crash_restarts(&mut self) {
1233 self.crash_restarts.clear();
1234 }
1235
1236 fn new(state: ModuleState, enabled: bool) -> Self {
1237 Self {
1238 #[cfg(test)]
1239 actor_turns: 0,
1240 #[cfg(test)]
1241 actor_select: None,
1242 state,
1243 enabled,
1244 process_alive: false,
1245 #[cfg(windows)]
1246 pipe_consumed: None,
1247 spawned_protocol: None,
1248 spawn_failure: None,
1249 waiting_for_previous_pid: None,
1250 crash_restarts: VecDeque::new(),
1251 lifetime_restarts: 0,
1252 spawn_generation: 0,
1253 pid: None,
1254 #[cfg(target_os = "macos")]
1255 report_ready: None,
1256 reaped_pid: None,
1257 respawn_pending: false,
1258 coalesced_restart_pending: false,
1259 spawned_at_ms: None,
1260 spawned_from: None,
1261 spawned_file_identity: None,
1262 process_start_time: None,
1263 deliberate_severance: None,
1264 last_exit: None,
1265 drain_disposition_detail: None,
1266 health: ModuleHealthStatus::default(),
1267 in_alternate_slot: false,
1268 draining_to_replace: false,
1269 configuration_updated_since_spawn: false,
1270 }
1271 }
1272}
1273
1274type SharedSnapshot = Arc<Mutex<SupervisorSnapshot>>;
1275
1276type SpawnSubscriberKey = (ConnectionId, u64);
1277
1278#[derive(Debug)]
1279struct SpawnSubscriber {
1280 version: u8,
1281 frames: mpsc::Sender<Frame>,
1282 lagged: Option<oneshot::Sender<SpawnCursor>>,
1286}
1287
1288#[derive(Debug)]
1289struct SpawnEventState {
1290 daemon_incarnation: String,
1291 seq: u64,
1292 capacity: usize,
1293 live: HashMap<String, LiveSpawn>,
1294 generations: HashMap<String, u64>,
1295 events: VecDeque<SpawnEvent>,
1296 subscribers: HashMap<SpawnSubscriberKey, SpawnSubscriber>,
1297}
1298
1299impl Default for SpawnEventState {
1300 fn default() -> Self {
1301 Self {
1302 daemon_incarnation: "unconfigured".to_string(),
1303 seq: 0,
1304 capacity: SPAWN_EVENT_RING_CAPACITY,
1305 live: HashMap::new(),
1306 generations: HashMap::new(),
1307 events: VecDeque::new(),
1308 subscribers: HashMap::new(),
1309 }
1310 }
1311}
1312
1313#[derive(Debug, Clone, Default)]
1314struct SpawnEventFeed(
1315 Arc<Mutex<SpawnEventState>>,
1316 #[cfg(test)] Arc<Mutex<Option<std::net::TcpStream>>>,
1317);
1318
1319#[derive(Debug, Clone, PartialEq, Eq)]
1320pub(crate) enum SpawnSubscribeRefusal {
1321 ForeignIncarnation { current: String },
1322 TooOld { oldest: SpawnCursor },
1323 Frame(String),
1324}
1325
1326impl SpawnEventFeed {
1327 fn configure_incarnation(&self, daemon_incarnation: String) {
1328 let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1329 state.daemon_incarnation = daemon_incarnation;
1330 state.seq = 0;
1331 state.live.clear();
1332 state.generations.clear();
1333 state.events.clear();
1334 state.subscribers.clear();
1335 }
1336
1337 fn cursor(state: &SpawnEventState) -> SpawnCursor {
1338 SpawnCursor {
1339 daemon_incarnation: state.daemon_incarnation.clone(),
1340 seq: state.seq,
1341 }
1342 }
1343
1344 fn snapshot(&self) -> SpawnSnapshot {
1345 let state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1346 let mut live = state.live.values().cloned().collect::<Vec<_>>();
1347 live.sort_by(|left, right| left.module_id.cmp(&right.module_id));
1348 SpawnSnapshot {
1349 cursor: Self::cursor(&state),
1350 ring_bound: state.capacity as u64,
1351 live,
1352 }
1353 }
1354
1355 fn emit_spawned(&self, module_id: &str, pid: u32, spawned_at_ms: u64) -> u64 {
1356 let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1357 let generation = state
1358 .generations
1359 .get(module_id)
1360 .copied()
1361 .unwrap_or(0)
1362 .checked_add(1)
1363 .expect("spawn generation exhausted");
1364 state.generations.insert(module_id.to_string(), generation);
1365 let live = LiveSpawn {
1366 module_id: module_id.to_string(),
1367 spawn_generation: generation,
1368 pid,
1369 spawned_at_ms,
1370 };
1371 state.live.insert(module_id.to_string(), live);
1372 Self::emit_locked(
1373 &mut state,
1374 SpawnEventKind::Spawned,
1375 module_id.to_string(),
1376 generation,
1377 pid,
1378 None,
1379 None,
1380 );
1381 generation
1382 }
1383
1384 fn emit_exited(&self, module_id: &str, exit_code: Option<i32>, exit_signal: Option<i32>) {
1385 let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1386 let Some(live) = state.live.remove(module_id) else {
1387 warn!(
1388 module_id,
1389 "terminal record had no live spawn event identity"
1390 );
1391 return;
1392 };
1393 Self::emit_locked(
1394 &mut state,
1395 SpawnEventKind::Exited,
1396 module_id.to_string(),
1397 live.spawn_generation,
1398 live.pid,
1399 exit_code,
1400 exit_signal,
1401 );
1402 }
1403
1404 fn emit_superseded_exited(
1411 &self,
1412 module_id: &str,
1413 spawn_generation: u64,
1414 pid: u32,
1415 exit_code: Option<i32>,
1416 exit_signal: Option<i32>,
1417 ) {
1418 let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1419 if state
1420 .live
1421 .get(module_id)
1422 .is_some_and(|live| live.spawn_generation == spawn_generation)
1423 {
1424 state.live.remove(module_id);
1425 }
1426 Self::emit_locked(
1427 &mut state,
1428 SpawnEventKind::Exited,
1429 module_id.to_string(),
1430 spawn_generation,
1431 pid,
1432 exit_code,
1433 exit_signal,
1434 );
1435 }
1436
1437 #[allow(clippy::too_many_arguments)]
1438 fn emit_locked(
1439 state: &mut SpawnEventState,
1440 kind: SpawnEventKind,
1441 module_id: String,
1442 spawn_generation: u64,
1443 pid: u32,
1444 exit_code: Option<i32>,
1445 exit_signal: Option<i32>,
1446 ) {
1447 state.seq = state
1448 .seq
1449 .checked_add(1)
1450 .expect("spawn event sequence exhausted");
1451 let event = SpawnEvent {
1452 cursor: Self::cursor(state),
1453 kind,
1454 module_id,
1455 spawn_generation,
1456 pid,
1457 exit_code,
1458 exit_signal,
1459 };
1460 state.events.push_back(event.clone());
1461 while state.events.len() > state.capacity {
1462 state.events.pop_front();
1463 }
1464 let body = match serde_json::to_vec(&event) {
1465 Ok(body) => body,
1466 Err(error) => {
1467 error!(%error, "failed to serialize supervisor spawn event");
1468 return;
1469 }
1470 };
1471 state.subscribers.retain(|(connection_id, corr), subscriber| {
1472 let frame = Frame::build_with_version(
1473 subscriber.version,
1474 FrameType::StreamData,
1475 control_flags(),
1476 0,
1477 0,
1478 *corr,
1479 body.clone(),
1480 );
1481 match frame {
1482 Ok(frame) => {
1483 if subscriber.frames.try_send(frame).is_ok() {
1484 true
1485 } else {
1486 warn!(connection_id = connection_id.get(), corr, "dropping lagged supervisor spawn subscriber");
1487 if let Some(lagged) = subscriber.lagged.take() {
1488 let _ = lagged.send(event.cursor.clone());
1489 }
1490 false
1491 }
1492 }
1493 Err(error) => {
1494 warn!(connection_id = connection_id.get(), corr, %error, "dropping supervisor spawn subscriber after frame build failure");
1495 false
1496 }
1497 }
1498 });
1499 }
1500
1501 fn subscribe(
1502 &self,
1503 connection_id: ConnectionId,
1504 corr: u64,
1505 version: u8,
1506 since: Option<SpawnCursor>,
1507 sink: FrameSink,
1508 ) -> Result<(), SpawnSubscribeRefusal> {
1509 let (frames, mut receiver) = mpsc::channel(SPAWN_SUBSCRIBER_BUFFER);
1510 let (lagged, mut lagged_rx) = oneshot::channel::<SpawnCursor>();
1511 {
1512 let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1513 let replay = if let Some(since) = since {
1514 if since.daemon_incarnation != state.daemon_incarnation {
1515 return Err(SpawnSubscribeRefusal::ForeignIncarnation {
1516 current: state.daemon_incarnation.clone(),
1517 });
1518 }
1519 if let Some(oldest) = state.events.front().map(|event| event.cursor.clone()) {
1520 if since.seq < oldest.seq.saturating_sub(1) {
1521 return Err(SpawnSubscribeRefusal::TooOld { oldest });
1522 }
1523 }
1524 state
1525 .events
1526 .iter()
1527 .filter(|event| event.cursor.seq > since.seq)
1528 .cloned()
1529 .collect::<Vec<_>>()
1530 } else {
1531 Vec::new()
1532 };
1533 for event in replay {
1534 let body = serde_json::to_vec(&event)
1535 .map_err(|error| SpawnSubscribeRefusal::Frame(error.to_string()))?;
1536 let frame = Frame::build_with_version(
1537 version,
1538 FrameType::StreamData,
1539 control_flags(),
1540 0,
1541 0,
1542 corr,
1543 body,
1544 )
1545 .map_err(|error| SpawnSubscribeRefusal::Frame(error.to_string()))?;
1546 frames
1547 .try_send(frame)
1548 .map_err(|error| SpawnSubscribeRefusal::Frame(error.to_string()))?;
1549 }
1550 state.subscribers.insert(
1551 (connection_id, corr),
1552 SpawnSubscriber {
1553 version,
1554 frames,
1555 lagged: Some(lagged),
1556 },
1557 );
1558 }
1559 tokio::spawn(async move {
1570 while let Some(frame) = receiver.recv().await {
1571 if sink.send(frame).await.is_err() {
1572 return;
1573 }
1574 }
1575 let Ok(first_undelivered) = lagged_rx.try_recv() else {
1576 return;
1577 };
1578 match spawn_subscriber_lagged_frame(version, corr, first_undelivered) {
1579 Ok(frame) => {
1580 let _ = sink.send(frame).await;
1581 }
1582 Err(error) => {
1583 error!(%error, corr, "failed to build lagged spawn subscriber terminal frame");
1584 }
1585 }
1586 });
1587 Ok(())
1588 }
1589
1590 fn cancel(&self, connection_id: ConnectionId, corr: u64) -> bool {
1591 let Some(subscriber) = self
1592 .0
1593 .lock()
1594 .unwrap_or_else(|p| p.into_inner())
1595 .subscribers
1596 .remove(&(connection_id, corr))
1597 else {
1598 return false;
1599 };
1600 if let Ok(frame) = Frame::build_with_version(
1601 subscriber.version,
1602 FrameType::StreamEnd,
1603 control_flags(),
1604 0,
1605 0,
1606 corr,
1607 Vec::new(),
1608 ) {
1609 tokio::spawn(async move {
1610 let _ = subscriber.frames.send(frame).await;
1611 });
1612 }
1613 true
1614 }
1615
1616 fn remove_connection(&self, connection_id: ConnectionId) {
1617 self.0
1618 .lock()
1619 .unwrap_or_else(|p| p.into_inner())
1620 .subscribers
1621 .retain(|(subscriber_connection, _), _| *subscriber_connection != connection_id);
1622 }
1623
1624 #[cfg(any(test, feature = "test-support"))]
1625 fn set_capacity(&self, capacity: usize) {
1626 self.0.lock().unwrap_or_else(|p| p.into_inner()).capacity = capacity;
1627 }
1628
1629 #[cfg(any(test, feature = "test-support"))]
1630 fn subscriber_count(&self) -> usize {
1631 self.0
1632 .lock()
1633 .unwrap_or_else(|p| p.into_inner())
1634 .subscribers
1635 .len()
1636 }
1637}
1638
1639fn spawn_subscriber_lagged_frame(
1642 version: u8,
1643 corr: u64,
1644 first_undelivered: SpawnCursor,
1645) -> Result<Frame, String> {
1646 let body = serde_json::to_vec(&subc_protocol::ErrorBody {
1647 code: SPAWN_SUBSCRIBER_LAGGED_CODE.to_string(),
1648 message: "spawn subscriber fell behind and was dropped; resubscribe from the last cursor received"
1649 .to_string(),
1650 detail: Some(serde_json::json!({
1651 "first_undelivered_cursor": first_undelivered
1652 })),
1653 })
1654 .map_err(|error| error.to_string())?;
1655 Frame::build_with_version(version, FrameType::Error, control_flags(), 0, 0, corr, body)
1656 .map_err(|error| error.to_string())
1657}
1658
1659pub trait ModuleProcessLiveness: Send + Sync {
1660 fn process_live(&self, module_id: &str) -> Option<bool>;
1661
1662 fn process_replacing(&self, _module_id: &str) -> bool {
1668 false
1669 }
1670}
1671
1672#[derive(Debug, Clone, Default)]
1674pub struct SupervisorProcessLiveness {
1675 snapshots: Arc<Mutex<HashMap<String, SharedSnapshot>>>,
1676}
1677
1678impl SupervisorProcessLiveness {
1679 pub fn new() -> Self {
1680 Self::default()
1681 }
1682
1683 fn track(&self, module_id: String, snapshot: SharedSnapshot) {
1684 let mut snapshots = self
1685 .snapshots
1686 .lock()
1687 .unwrap_or_else(|poisoned| poisoned.into_inner());
1688 snapshots.insert(module_id, snapshot);
1689 }
1690
1691 fn untrack_if_current(&self, module_id: &str, snapshot: &SharedSnapshot) {
1692 let mut snapshots = self
1693 .snapshots
1694 .lock()
1695 .unwrap_or_else(|poisoned| poisoned.into_inner());
1696 let is_current = snapshots
1697 .get(module_id)
1698 .map(|tracked| Arc::ptr_eq(tracked, snapshot))
1699 .unwrap_or(false);
1700 if is_current {
1701 snapshots.remove(module_id);
1702 }
1703 }
1704}
1705
1706impl ModuleProcessLiveness for SupervisorProcessLiveness {
1707 fn process_live(&self, module_id: &str) -> Option<bool> {
1708 let snapshot = {
1709 let snapshots = self
1710 .snapshots
1711 .lock()
1712 .unwrap_or_else(|poisoned| poisoned.into_inner());
1713 snapshots.get(module_id).cloned()
1714 }?;
1715 let snapshot = snapshot
1716 .lock()
1717 .unwrap_or_else(|poisoned| poisoned.into_inner());
1718 Some(snapshot.state == ModuleState::Running && snapshot.process_alive)
1719 }
1720
1721 fn process_replacing(&self, module_id: &str) -> bool {
1722 let Some(snapshot) = self
1723 .snapshots
1724 .lock()
1725 .unwrap_or_else(|poisoned| poisoned.into_inner())
1726 .get(module_id)
1727 .cloned()
1728 else {
1729 return false;
1730 };
1731 let snapshot = snapshot
1732 .lock()
1733 .unwrap_or_else(|poisoned| poisoned.into_inner());
1734 snapshot.enabled
1735 && match snapshot.state {
1736 ModuleState::Restarting => true,
1737 ModuleState::Draining => snapshot.draining_to_replace,
1738 ModuleState::Starting
1739 | ModuleState::Running
1740 | ModuleState::Unresponsive
1741 | ModuleState::Stopped
1742 | ModuleState::Failed
1743 | ModuleState::Disabled => false,
1744 }
1745 }
1746}
1747
1748#[cfg(test)]
1749#[derive(Debug, Default)]
1750struct ReloadExitRecordGate {
1751 reached: tokio::sync::Notify,
1752 resume: tokio::sync::Notify,
1753}
1754
1755#[derive(Debug, Clone, Copy)]
1756enum RespawnKind {
1757 Spawn,
1758 Reload,
1759}
1760
1761#[derive(Debug, Clone, Copy)]
1762struct PendingRespawn {
1763 deadline: Instant,
1764 kind: RespawnKind,
1765}
1766
1767type ReloadReply = oneshot::Sender<Result<(), SuperviseError>>;
1768
1769#[derive(Debug, Clone)]
1770struct SupervisorRuntimeConfig {
1771 start_requester: Option<Principal>,
1772 pending_activity_restart: bool,
1773 provenance_probe: ExecutableIdentityProbe,
1774 scheduled_respawn: Arc<Mutex<Option<PendingRespawn>>>,
1776 deferred_reload_reply: Arc<Mutex<Option<ReloadReply>>>,
1778 restart_policy: RestartPolicy,
1779 drain_timeout: Duration,
1782 effective_drain_timeout: Arc<Mutex<Duration>>,
1785 default_drain_timeout: Duration,
1788 health: HealthConfig,
1789 connection_file_path: Option<PathBuf>,
1790 capture_logs_dir: Option<PathBuf>,
1791 forwarding: Option<Arc<ForwardingTable>>,
1792 supervisor_handle: Option<SupervisorHandle>,
1795 stderr_ring: Arc<Mutex<StderrRing>>,
1802 terminal_ring: Arc<Mutex<TerminalRing>>,
1803 spawn_events: SpawnEventFeed,
1804 child_roster: ChildRoster,
1805 #[cfg(target_os = "linux")]
1806 cgroup_placement: Option<subc_cgroup::Placement>,
1807 #[cfg(test)]
1808 test_seed_stale_facts_before_enable_spawn: bool,
1809 #[cfg(test)]
1810 test_reload_exit_record_gate: Option<Arc<ReloadExitRecordGate>>,
1811}
1812
1813#[derive(Debug, Clone, PartialEq, Eq)]
1814struct SupervisedConfiguration {
1815 spec: ModuleSpec,
1816 health: HealthConfig,
1817}
1818
1819#[derive(Debug, Clone, Default)]
1825pub struct SupervisorHandle {
1826 activity: Arc<Mutex<Option<crate::activity::ActivityLedger>>>,
1827 activity_images: Arc<Mutex<HashMap<String, activity::ActivityImage>>>,
1828 modules: Arc<Mutex<HashMap<String, SupervisedModule>>>,
1829 configured_ids: Arc<Mutex<HashSet<String>>>,
1841 spawn_events: SpawnEventFeed,
1842 reserved_nonces: Arc<Mutex<HashMap<String, Option<String>>>>,
1853 removal_tombstones: Arc<Mutex<HashMap<String, u64>>>,
1859 spawn_nonces: Arc<Mutex<HashMap<String, String>>>,
1863 reserved_prefix_owners: Arc<Mutex<HashMap<String, String>>>,
1871 swaps: Arc<Mutex<HashMap<String, OpenSwap>>>,
1877 promotion_observer: PromotionObserverSlot,
1879 operation_lock: Arc<AsyncMutex<()>>,
1883}
1884
1885pub(crate) trait SwapPromotionObserver: Send + Sync {
1894 fn swap_promoted(&self, registration: &crate::registry::ModuleRegistration);
1895}
1896
1897#[derive(Clone, Default)]
1901struct PromotionObserverSlot(Arc<Mutex<Option<std::sync::Weak<dyn SwapPromotionObserver>>>>);
1902
1903impl fmt::Debug for PromotionObserverSlot {
1904 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1905 f.write_str("PromotionObserverSlot")
1906 }
1907}
1908
1909#[derive(Debug, Clone)]
1911struct OpenSwap {
1912 candidate_nonce: String,
1915 incumbent_nonce: Option<String>,
1920 candidate_admitted: bool,
1924}
1925
1926#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1929pub(crate) enum SwapHelloAdmission {
1930 NotSwapping,
1933 Candidate,
1935 Refused,
1938}
1939
1940#[derive(Debug, Clone, PartialEq, Eq)]
1941pub(crate) enum ReservedHelloRejection {
1942 Exact {
1943 module_id: String,
1944 },
1945 Prefix {
1946 prefix: String,
1947 owner_module_id: String,
1948 },
1949}
1950
1951impl SupervisorHandle {
1952 pub fn new() -> Self {
1953 Self::default()
1954 }
1955
1956 pub(crate) fn spawn_snapshot(&self) -> SpawnSnapshot {
1957 self.spawn_events.snapshot()
1958 }
1959
1960 pub(crate) fn subscribe_spawns(
1961 &self,
1962 connection_id: ConnectionId,
1963 corr: u64,
1964 version: u8,
1965 since: Option<SpawnCursor>,
1966 sink: FrameSink,
1967 ) -> Result<(), SpawnSubscribeRefusal> {
1968 self.spawn_events
1969 .subscribe(connection_id, corr, version, since, sink)
1970 }
1971
1972 pub(crate) fn cancel_spawn_subscription(&self, connection_id: ConnectionId, corr: u64) -> bool {
1973 self.spawn_events.cancel(connection_id, corr)
1974 }
1975
1976 pub(crate) fn remove_spawn_subscribers(&self, connection_id: ConnectionId) {
1977 self.spawn_events.remove_connection(connection_id);
1978 }
1979
1980 #[cfg(any(test, feature = "test-support"))]
1981 pub fn set_spawn_event_capacity_for_test(&self, capacity: usize) {
1982 assert!(capacity > 0, "spawn event capacity must be non-zero");
1983 self.spawn_events.set_capacity(capacity);
1984 }
1985
1986 #[cfg(any(test, feature = "test-support"))]
1987 pub fn spawn_subscriber_count_for_test(&self) -> usize {
1988 self.spawn_events.subscriber_count()
1989 }
1990
1991 pub fn set_spawn_nonce(&self, module_id: &str, nonce: String) {
1994 self.spawn_nonces
1995 .lock()
1996 .unwrap_or_else(|poisoned| poisoned.into_inner())
1997 .insert(module_id.to_string(), nonce);
1998 }
1999
2000 pub fn set_reserved_nonce(&self, module_id: &str, nonce: String) {
2003 self.reserved_nonces
2004 .lock()
2005 .unwrap_or_else(|poisoned| poisoned.into_inner())
2006 .insert(module_id.to_string(), Some(nonce));
2007 }
2008
2009 pub fn set_reserved_prefixes(&self, owner_module_id: &str, prefixes: &[String]) {
2011 let mut owners = self
2012 .reserved_prefix_owners
2013 .lock()
2014 .unwrap_or_else(|poisoned| poisoned.into_inner());
2015 owners.retain(|_, owner| owner != owner_module_id);
2016 for prefix in prefixes {
2017 owners.insert(prefix.clone(), owner_module_id.to_string());
2018 }
2019 }
2020
2021 #[cfg(test)]
2023 pub(crate) fn spawn_nonce(&self, module_id: &str) -> Option<String> {
2024 self.spawn_nonces
2025 .lock()
2026 .unwrap_or_else(|poisoned| poisoned.into_inner())
2027 .get(module_id)
2028 .cloned()
2029 }
2030
2031 fn apply_identity_configuration(&self, spec: &ModuleSpec) {
2032 self.set_reserved_prefixes(&spec.module_id, &spec.reserved_prefixes);
2033 let spawn_nonce = self
2034 .spawn_nonces
2035 .lock()
2036 .unwrap_or_else(|poisoned| poisoned.into_inner())
2037 .get(&spec.module_id)
2038 .cloned();
2039 let mut reserved_nonces = self
2040 .reserved_nonces
2041 .lock()
2042 .unwrap_or_else(|poisoned| poisoned.into_inner());
2043 if spec.reserved {
2044 reserved_nonces.insert(spec.module_id.clone(), spawn_nonce);
2049 }
2050 drop(reserved_nonces);
2051 self.removal_tombstones
2055 .lock()
2056 .unwrap_or_else(|poisoned| poisoned.into_inner())
2057 .remove(&spec.module_id);
2058 }
2059
2060 pub fn reserved_hello_authorized(&self, module_id: &str, presented: Option<&str>) -> bool {
2065 self.reserved_hello_rejection(module_id, presented)
2066 .is_none()
2067 }
2068
2069 pub(crate) fn reserved_hello_rejection(
2070 &self,
2071 module_id: &str,
2072 presented: Option<&str>,
2073 ) -> Option<ReservedHelloRejection> {
2074 let nonces = self
2075 .reserved_nonces
2076 .lock()
2077 .unwrap_or_else(|poisoned| poisoned.into_inner());
2078 if let Some(expected) = nonces.get(module_id) {
2079 let authorized = match expected {
2083 Some(expected) => {
2084 presented.is_some_and(|p| constant_time_eq(expected.as_bytes(), p.as_bytes()))
2085 }
2086 None => false,
2087 };
2088 if authorized {
2089 return None;
2090 }
2091 return Some(ReservedHelloRejection::Exact {
2092 module_id: module_id.to_string(),
2093 });
2094 }
2095 drop(nonces);
2096
2097 let matched_prefix = self
2098 .reserved_prefix_owners
2099 .lock()
2100 .unwrap_or_else(|poisoned| poisoned.into_inner())
2101 .iter()
2102 .filter(|(prefix, _)| module_id.starts_with(prefix.as_str()))
2103 .max_by_key(|(prefix, _)| prefix.len())
2104 .map(|(prefix, owner)| (prefix.clone(), owner.clone()));
2105 let (prefix, owner_module_id) = matched_prefix?;
2106
2107 let authorized = presented.is_some_and(|presented| {
2108 self.spawn_nonces
2109 .lock()
2110 .unwrap_or_else(|poisoned| poisoned.into_inner())
2111 .get(&owner_module_id)
2112 .is_some_and(|expected| constant_time_eq(expected.as_bytes(), presented.as_bytes()))
2113 || self.swap_nonce_matches(&owner_module_id, presented)
2116 });
2117 if authorized {
2118 None
2119 } else {
2120 Some(ReservedHelloRejection::Prefix {
2121 prefix,
2122 owner_module_id,
2123 })
2124 }
2125 }
2126
2127 pub fn spawned_consumer_authorized(&self, module_id: &str, presented: &str) -> bool {
2132 if presented.is_empty() {
2133 return false;
2134 }
2135 let nonces = self
2136 .spawn_nonces
2137 .lock()
2138 .unwrap_or_else(|poisoned| poisoned.into_inner());
2139 let current = nonces
2140 .get(module_id)
2141 .is_some_and(|expected| constant_time_eq(expected.as_bytes(), presented.as_bytes()));
2142 drop(nonces);
2143 current || self.swap_nonce_matches(module_id, presented)
2148 }
2149
2150 fn swap_nonce_matches(&self, module_id: &str, presented: &str) -> bool {
2152 let swaps = self
2153 .swaps
2154 .lock()
2155 .unwrap_or_else(|poisoned| poisoned.into_inner());
2156 swaps.get(module_id).is_some_and(|swap| {
2157 constant_time_eq(swap.candidate_nonce.as_bytes(), presented.as_bytes())
2158 || swap.incumbent_nonce.as_deref().is_some_and(|incumbent| {
2159 constant_time_eq(incumbent.as_bytes(), presented.as_bytes())
2160 })
2161 })
2162 }
2163
2164 pub(crate) fn open_swap(&self, module_id: &str, candidate_nonce: String) {
2167 let incumbent_nonce = self
2168 .spawn_nonces
2169 .lock()
2170 .unwrap_or_else(|poisoned| poisoned.into_inner())
2171 .get(module_id)
2172 .cloned();
2173 self.swaps
2174 .lock()
2175 .unwrap_or_else(|poisoned| poisoned.into_inner())
2176 .insert(
2177 module_id.to_string(),
2178 OpenSwap {
2179 candidate_nonce,
2180 incumbent_nonce,
2181 candidate_admitted: false,
2182 },
2183 );
2184 }
2185
2186 pub(crate) fn close_swap(&self, module_id: &str) {
2189 self.swaps
2190 .lock()
2191 .unwrap_or_else(|poisoned| poisoned.into_inner())
2192 .remove(module_id);
2193 }
2194
2195 pub(crate) fn set_swap_promotion_observer(
2198 &self,
2199 observer: std::sync::Weak<dyn SwapPromotionObserver>,
2200 ) {
2201 *self
2202 .promotion_observer
2203 .0
2204 .lock()
2205 .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(observer);
2206 }
2207
2208 fn notify_swap_promoted(&self, registration: &crate::registry::ModuleRegistration) {
2211 let observer = self
2212 .promotion_observer
2213 .0
2214 .lock()
2215 .unwrap_or_else(|poisoned| poisoned.into_inner())
2216 .as_ref()
2217 .and_then(std::sync::Weak::upgrade);
2218 if let Some(observer) = observer {
2219 observer.swap_promoted(registration);
2220 }
2221 }
2222
2223 pub(crate) fn swap_open(&self, module_id: &str) -> bool {
2225 self.swaps
2226 .lock()
2227 .unwrap_or_else(|poisoned| poisoned.into_inner())
2228 .contains_key(module_id)
2229 }
2230
2231 fn promote_swap_nonce(&self, module_id: &str, reserved: bool) {
2236 let candidate_nonce = self
2237 .swaps
2238 .lock()
2239 .unwrap_or_else(|poisoned| poisoned.into_inner())
2240 .get(module_id)
2241 .map(|swap| swap.candidate_nonce.clone());
2242 let Some(nonce) = candidate_nonce else {
2243 return;
2244 };
2245 self.set_spawn_nonce(module_id, nonce.clone());
2246 if reserved {
2247 self.set_reserved_nonce(module_id, nonce);
2248 }
2249 }
2250
2251 pub(crate) fn swap_hello_admission(
2266 &self,
2267 module_id: &str,
2268 presented: Option<&str>,
2269 ) -> SwapHelloAdmission {
2270 let swaps = self
2271 .swaps
2272 .lock()
2273 .unwrap_or_else(|poisoned| poisoned.into_inner());
2274 let Some(swap) = swaps.get(module_id) else {
2275 return SwapHelloAdmission::NotSwapping;
2276 };
2277 let Some(presented) = presented else {
2278 return SwapHelloAdmission::Refused;
2279 };
2280 if constant_time_eq(swap.candidate_nonce.as_bytes(), presented.as_bytes()) {
2281 return if swap.candidate_admitted {
2282 SwapHelloAdmission::Refused
2283 } else {
2284 SwapHelloAdmission::Candidate
2285 };
2286 }
2287 if swap
2288 .incumbent_nonce
2289 .as_deref()
2290 .is_some_and(|incumbent| constant_time_eq(incumbent.as_bytes(), presented.as_bytes()))
2291 {
2292 return SwapHelloAdmission::NotSwapping;
2293 }
2294 SwapHelloAdmission::Refused
2295 }
2296
2297 pub(crate) fn mark_swap_candidate_admitted(&self, module_id: &str) {
2300 if let Some(swap) = self
2301 .swaps
2302 .lock()
2303 .unwrap_or_else(|poisoned| poisoned.into_inner())
2304 .get_mut(module_id)
2305 {
2306 swap.candidate_admitted = true;
2307 }
2308 }
2309
2310 pub fn spawn_launch_nonce_for(&self, module_id: &str) -> Option<String> {
2312 self.spawn_nonces
2313 .lock()
2314 .unwrap_or_else(|poisoned| poisoned.into_inner())
2315 .get(module_id)
2316 .cloned()
2317 }
2318
2319 pub fn reserved_launch_nonce_for(&self, module_id: &str) -> Option<String> {
2321 self.reserved_nonces
2322 .lock()
2323 .unwrap_or_else(|poisoned| poisoned.into_inner())
2324 .get(module_id)
2325 .cloned()
2326 .flatten()
2327 }
2328
2329 pub fn insert(&self, module: SupervisedModule) -> Option<SupervisedModule> {
2330 self.mark_configured(module.module_id());
2334 let mut modules = self
2335 .modules
2336 .lock()
2337 .unwrap_or_else(|poisoned| poisoned.into_inner());
2338 modules.insert(module.module_id().to_string(), module)
2339 }
2340
2341 fn mark_configured(&self, module_id: &str) {
2345 self.configured_ids
2346 .lock()
2347 .unwrap_or_else(|poisoned| poisoned.into_inner())
2348 .insert(module_id.to_string());
2349 }
2350
2351 fn unmark_configured_unless_rostered(&self, module_id: &str) {
2355 let modules = self
2356 .modules
2357 .lock()
2358 .unwrap_or_else(|poisoned| poisoned.into_inner());
2359 if !modules.contains_key(module_id) {
2360 self.configured_ids
2361 .lock()
2362 .unwrap_or_else(|poisoned| poisoned.into_inner())
2363 .remove(module_id);
2364 }
2365 }
2366
2367 pub(crate) fn is_configured(&self, module_id: &str) -> bool {
2375 self.configured_ids
2376 .lock()
2377 .unwrap_or_else(|poisoned| poisoned.into_inner())
2378 .contains(module_id)
2379 }
2380
2381 pub fn get(&self, module_id: &str) -> Option<SupervisedModule> {
2382 let modules = self
2383 .modules
2384 .lock()
2385 .unwrap_or_else(|poisoned| poisoned.into_inner());
2386 modules.get(module_id).cloned()
2387 }
2388
2389 pub(crate) fn record_late_health_answer(
2390 &self,
2391 module_id: &str,
2392 latency_ms: u64,
2393 ) -> Result<bool, SuperviseError> {
2394 let Some(module) = self.get(module_id) else {
2395 return Ok(false);
2396 };
2397 update_snapshot(&module.inner.snapshot, Some(module_id), |state| {
2398 state.health.late_answer_count = state.health.late_answer_count.saturating_add(1);
2399 state.health.last_late_answer_latency_ms = Some(latency_ms);
2400 state.health.consecutive_failures = 0;
2408 })?;
2409 Ok(true)
2410 }
2411
2412 pub fn record_deliberate_severance(&self, module_id: &str) -> Result<bool, SuperviseError> {
2418 let Some(module) = self.get(module_id) else {
2419 return Ok(false);
2420 };
2421 let snapshot = lock_snapshot(&module.inner.snapshot)?;
2422 let Some((pid, start_time)) = snapshot.pid.zip(snapshot.process_start_time) else {
2423 return Ok(false);
2424 };
2425 drop(snapshot);
2426 module.record_deliberate_severance(ProcessIdentity { pid, start_time })
2427 }
2428
2429 pub fn list(&self) -> Vec<SupervisedModule> {
2430 let modules = self
2431 .modules
2432 .lock()
2433 .unwrap_or_else(|poisoned| poisoned.into_inner());
2434 let mut modules = modules.values().cloned().collect::<Vec<_>>();
2435 modules.sort_by(|left, right| left.module_id().cmp(right.module_id()));
2436 modules
2437 }
2438
2439 pub(crate) fn retire(&self, module_id: &str) -> Option<SupervisedModule> {
2440 self.activity_images
2441 .lock()
2442 .unwrap_or_else(|p| p.into_inner())
2443 .remove(module_id);
2444 self.spawn_nonces
2445 .lock()
2446 .unwrap_or_else(|poisoned| poisoned.into_inner())
2447 .remove(module_id);
2448 self.close_swap(module_id);
2449 let mut reserved_nonces = self
2450 .reserved_nonces
2451 .lock()
2452 .unwrap_or_else(|poisoned| poisoned.into_inner());
2453 if reserved_nonces.contains_key(module_id) {
2454 reserved_nonces.insert(module_id.to_string(), None);
2457 }
2458 drop(reserved_nonces);
2459 self.reserved_prefix_owners
2460 .lock()
2461 .unwrap_or_else(|poisoned| poisoned.into_inner())
2462 .retain(|_, owner| owner != module_id);
2463 let removed = self
2464 .modules
2465 .lock()
2466 .unwrap_or_else(|poisoned| poisoned.into_inner())
2467 .remove(module_id);
2468 self.configured_ids
2469 .lock()
2470 .unwrap_or_else(|poisoned| poisoned.into_inner())
2471 .remove(module_id);
2472 removed
2473 }
2474
2475 pub(crate) fn record_rescan_removal(&self, module_id: &str) {
2478 self.removal_tombstones
2479 .lock()
2480 .unwrap_or_else(|poisoned| poisoned.into_inner())
2481 .insert(module_id.to_string(), unix_ms_now());
2482 }
2483
2484 pub(crate) fn removal_tombstone_age_ms(&self, module_id: &str) -> Option<u64> {
2486 self.removal_tombstones
2487 .lock()
2488 .unwrap_or_else(|poisoned| poisoned.into_inner())
2489 .get(module_id)
2490 .copied()
2491 .map(|removed_at_ms| unix_ms_now().saturating_sub(removed_at_ms))
2492 }
2493
2494 pub(crate) fn release_retained_reserved_gate(&self, module_id: &str) -> bool {
2499 if self.get(module_id).is_some() {
2500 return false;
2501 }
2502 let mut reserved_nonces = self
2503 .reserved_nonces
2504 .lock()
2505 .unwrap_or_else(|poisoned| poisoned.into_inner());
2506 if !matches!(reserved_nonces.get(module_id), Some(None)) {
2507 return false;
2508 }
2509 reserved_nonces.remove(module_id);
2510 true
2511 }
2512
2513 pub(crate) fn operation_lock(&self) -> Arc<AsyncMutex<()>> {
2514 Arc::clone(&self.operation_lock)
2515 }
2516}
2517
2518#[derive(Debug, Clone)]
2520pub struct Supervisor {
2521 registry: Arc<Registry>,
2522 restart_policy: RestartPolicy,
2523 drain_timeout: Duration,
2524 connection_file_path: Option<PathBuf>,
2525 capture_logs_dir: Option<PathBuf>,
2526 forwarding: Option<Arc<ForwardingTable>>,
2527 process_liveness: Arc<SupervisorProcessLiveness>,
2528 supervisor_handle: Option<SupervisorHandle>,
2529 health: HealthConfig,
2530 daemon_start_clock: crate::clock::StartClock,
2531 terminal_journal: Option<Arc<crate::terminal_journal::TerminalJournal>>,
2532 spawn_events: SpawnEventFeed,
2533 provenance_probe: ExecutableIdentityProbe,
2534 child_roster: ChildRoster,
2537 #[cfg(target_os = "linux")]
2538 cgroup_placement: Option<subc_cgroup::Placement>,
2539 #[cfg(test)]
2540 test_after_first_spawn: AfterFirstSpawnHook,
2541}
2542
2543#[cfg(test)]
2549#[derive(Clone, Default)]
2550struct AfterFirstSpawnHook(Option<AfterFirstSpawnFn>);
2551
2552#[cfg(test)]
2553type AfterFirstSpawnFn = Arc<dyn Fn(&str) + Send + Sync>;
2554
2555#[cfg(test)]
2556impl fmt::Debug for AfterFirstSpawnHook {
2557 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2558 f.write_str("AfterFirstSpawnHook")
2559 }
2560}
2561
2562#[cfg(test)]
2563impl AfterFirstSpawnHook {
2564 fn run(&self, module_id: &str) {
2565 if let Some(hook) = &self.0 {
2566 hook(module_id);
2567 }
2568 }
2569}
2570
2571impl Supervisor {
2572 #[cfg(test)]
2573 pub(crate) fn new_for_test(registry: Arc<Registry>, policy: RestartPolicy) -> Self {
2574 let supervisor = Self::new(registry, policy);
2575 #[cfg(target_os = "macos")]
2576 let supervisor = supervisor.with_privacy_trampoline(test_privacy_trampoline());
2577 supervisor
2578 }
2579 pub fn with_privacy_trampoline(self, path: impl Into<PathBuf>) -> Self {
2584 let path = path.into();
2585 #[cfg(target_os = "macos")]
2586 {
2587 let result = probe_privacy_trampoline(&path).map(|()| path);
2588 if let Err(cause) = &result {
2589 error!(%cause, "macOS privacy identity unavailable; supervised launches will refuse");
2590 }
2591 self.child_roster.set_privacy_trampoline(result);
2592 }
2593 #[cfg(not(target_os = "macos"))]
2594 let _ = path;
2595 self
2596 }
2597 #[cfg(unix)]
2608 pub(crate) fn begin_daemon_shutdown(&self) {
2609 self.child_roster.close();
2610 if let Some(journal) = &self.terminal_journal {
2611 journal.stamp_shutdown();
2612 }
2613 }
2614
2615 #[cfg(unix)]
2619 pub(crate) async fn drain_for_daemon_shutdown(&self) -> Result<(), SuperviseError> {
2620 const NOTICE_BUDGET: Duration = Duration::from_millis(500);
2621 const DRAIN_BUDGET: Duration = Duration::from_secs(2);
2622 let Some(forwarding) = &self.forwarding else {
2623 return Ok(());
2624 };
2625 let modules = forwarding
2626 .begin_daemon_drain()
2627 .map_err(SuperviseError::Forwarding)?;
2628 let deadline_ms =
2629 unix_ms_now().saturating_add((NOTICE_BUDGET + DRAIN_BUDGET).as_millis() as u64);
2630 let mut notices = tokio::task::JoinSet::new();
2631 let mut drains = Vec::new();
2632 for (module_id, endpoint) in modules {
2633 let Some(target) = forwarding
2634 .begin_endpoint_drain(endpoint, RouteCloseReason::Restart)
2635 .map_err(SuperviseError::Forwarding)?
2636 else {
2637 continue;
2638 };
2639 let routes = forwarding
2640 .endpoint_routes(target.endpoint)
2641 .map_err(SuperviseError::Forwarding)?;
2642 let command = serde_json::to_vec(&ModuleControlCommand::Draining {
2648 reason: RouteCloseReason::Restart,
2649 deadline_ms,
2650 })
2651 .expect("module draining serializes");
2652 let mut recipients = vec![(target.sink.clone(), target.negotiated_ver, command)];
2653 let mut clients: Vec<(crate::forwarding::GoodbyeTarget, Vec<u16>)> = Vec::new();
2654 for route in routes {
2655 let client = route.goodbye_target;
2656 if let Some((_, channels)) = clients
2657 .iter_mut()
2658 .find(|(existing, _)| existing.connection_id == client.connection_id)
2659 {
2660 channels.push(client.channel);
2661 } else {
2662 let channel = client.channel;
2663 clients.push((client, vec![channel]));
2664 }
2665 }
2666 for (client, mut channels) in clients {
2667 channels.sort_unstable();
2668 channels.dedup();
2669 let closing = serde_json::to_vec(&ClientControlPush::RouteClosing {
2670 module_id: module_id.clone(),
2671 channels,
2672 reason: RouteCloseReason::Restart,
2673 })
2674 .expect("route closing serializes");
2675 recipients.push((client.sink, client.negotiated_ver, closing));
2676 }
2677 for (sink, version, body) in recipients {
2678 notices.spawn(async move {
2679 let frame = Frame::build_with_version(
2680 version,
2681 FrameType::Push,
2682 control_flags(),
2683 0,
2684 0,
2685 0,
2686 body,
2687 )
2688 .expect("bounded lifecycle notice frame builds");
2689 sink.send_flushed(frame).await
2690 });
2691 }
2692 let gauges = declared_busy_gauges(&self.registry, &module_id)?;
2693 drains.push((module_id, target.endpoint, gauges));
2694 }
2695 let notice_deadline = Instant::now() + NOTICE_BUDGET;
2698 while let Ok(Some(result)) = timeout_at(notice_deadline, notices.join_next()).await {
2699 if !matches!(result, Ok(Ok(()))) {
2700 warn!(?result, "daemon shutdown notice delivery failed");
2701 }
2702 }
2703 notices.abort_all();
2704 let deadline = Instant::now() + DRAIN_BUDGET;
2705 let mut waits = tokio::task::JoinSet::new();
2706 for (module_id, endpoint, gauges) in drains {
2707 let forwarding = Arc::clone(forwarding);
2708 let mut runtime = self.runtime_config();
2709 runtime.health.cadence = Duration::from_millis(100);
2710 waits.spawn(async move {
2711 let wait_result = wait_for_forwarding_quiescence(
2712 &forwarding,
2713 &module_id,
2714 &runtime,
2715 endpoint,
2716 deadline,
2717 &gauges,
2718 DrainScope::Endpoint(endpoint),
2719 )
2720 .await;
2721 forwarding
2725 .record_daemon_drain_result(
2726 endpoint,
2727 drained_after_quiescence_wait(&wait_result),
2728 )
2729 .map_err(SuperviseError::Forwarding)?;
2730 wait_result
2731 });
2732 }
2733 while let Ok(Some(result)) = timeout_at(deadline, waits.join_next()).await {
2734 if !matches!(result, Ok(Ok(true))) {
2735 warn!(?result, "daemon shutdown drain did not reach quiescence");
2736 }
2737 }
2738 Ok(())
2739 }
2740
2741 #[cfg(unix)]
2753 pub(crate) async fn end_children_for_daemon_shutdown(
2754 &self,
2755 already_escalated: bool,
2756 escalate: impl std::future::Future<Output = ()>,
2757 ) {
2758 tokio::pin!(escalate);
2759 let mut escalated = already_escalated;
2760 if let Some(forwarding) = &self.forwarding {
2761 let reason = CloseReason::new(
2762 "daemon_shutdown",
2763 "the daemon is exiting after its shutdown notice and drain",
2764 );
2765 if escalated {
2766 send_module_goodbyes_for_daemon_shutdown(forwarding, &reason, false).await;
2769 } else {
2770 tokio::select! {
2771 biased;
2772 _ = escalate.as_mut() => {
2773 info!("second SIGTERM: abandoning module GOODBYE delivery");
2774 escalated = true;
2775 }
2776 _ = send_module_goodbyes_for_daemon_shutdown(forwarding, &reason, true) => {}
2777 }
2778 }
2779 let closed = forwarding.close_all_connections(&reason);
2780 debug!(closed, "closed established connections for daemon shutdown");
2781 }
2782 let escalated_here = escalated && !already_escalated;
2786 let remaining_escalate = async move {
2787 if escalated_here {
2788 std::future::pending::<()>().await;
2789 } else {
2790 escalate.await;
2791 }
2792 };
2793 crate::child_roster::end_children_for_daemon_shutdown(
2794 &self.child_roster,
2795 escalated,
2796 remaining_escalate,
2797 )
2798 .await;
2799 }
2800
2801 pub fn new(registry: Arc<Registry>, restart_policy: RestartPolicy) -> Self {
2802 Self {
2803 registry,
2804 restart_policy,
2805 drain_timeout: DEFAULT_DRAIN_TIMEOUT,
2806 connection_file_path: None,
2807 capture_logs_dir: None,
2808 forwarding: None,
2809 process_liveness: Arc::new(SupervisorProcessLiveness::default()),
2810 supervisor_handle: None,
2811 health: HealthConfig::default(),
2812 daemon_start_clock: crate::clock::StartClock::capture(),
2813 terminal_journal: None,
2814 spawn_events: SpawnEventFeed::default(),
2815 provenance_probe: ExecutableIdentityProbe::default(),
2816 child_roster: ChildRoster::default(),
2817 #[cfg(target_os = "linux")]
2818 cgroup_placement: None,
2819 #[cfg(test)]
2820 test_after_first_spawn: AfterFirstSpawnHook::default(),
2821 }
2822 }
2823
2824 pub fn with_drain_timeout(mut self, drain_timeout: Duration) -> Self {
2825 self.drain_timeout = drain_timeout;
2826 self
2827 }
2828
2829 pub fn with_process_liveness(
2830 mut self,
2831 process_liveness: Arc<SupervisorProcessLiveness>,
2832 ) -> Self {
2833 self.process_liveness = process_liveness;
2834 self
2835 }
2836
2837 pub fn with_connection_file_path(mut self, connection_file_path: impl Into<PathBuf>) -> Self {
2838 self.connection_file_path = Some(connection_file_path.into());
2839 self
2840 }
2841
2842 pub fn with_capture_logs_dir(mut self, logs_dir: impl Into<PathBuf>) -> Self {
2844 self.capture_logs_dir = Some(logs_dir.into());
2845 self
2846 }
2847
2848 pub fn with_daemon_incarnation(self, daemon_incarnation: String) -> Self {
2851 self.spawn_events.configure_incarnation(daemon_incarnation);
2855 self
2856 }
2857
2858 pub fn with_terminal_journal(self, path: PathBuf, daemon_incarnation: String) -> Self {
2861 let mut this = self.with_daemon_incarnation(daemon_incarnation.clone());
2862 this.terminal_journal = Some(Arc::new(crate::terminal_journal::TerminalJournal::open(
2863 path,
2864 daemon_incarnation,
2865 )));
2866 this
2867 }
2868
2869 pub fn with_forwarding(mut self, forwarding: Arc<ForwardingTable>) -> Self {
2870 self.forwarding = Some(forwarding);
2871 self
2872 }
2873
2874 pub fn with_handle(mut self, supervisor_handle: SupervisorHandle) -> Self {
2875 self.spawn_events = supervisor_handle.spawn_events.clone();
2876 self.supervisor_handle = Some(supervisor_handle);
2877 self
2878 }
2879
2880 pub fn with_health_config(mut self, health: HealthConfig) -> Self {
2881 self.health = health;
2882 self
2883 }
2884
2885 pub fn with_live_children_record(self, path: impl Into<PathBuf>) -> Self {
2889 self.child_roster.record_to(path.into());
2890 self
2891 }
2892
2893 #[cfg(target_os = "linux")]
2894 pub fn with_cgroup_placement(
2895 mut self,
2896 cgroup_placement: Option<subc_cgroup::Placement>,
2897 ) -> Self {
2898 self.cgroup_placement = cgroup_placement;
2899 self
2900 }
2901
2902 pub fn spawn(&self, spec: ModuleSpec) -> Result<SupervisedModule, SuperviseError> {
2908 validate_spec(&spec)?;
2909 self.establish_identity(&spec);
2910
2911 let runtime = self.runtime_config();
2912 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
2913 let activity_spawn = runtime.begin_activity_spawn(&spec.module_id);
2914 let spawned = spawn_child(
2915 &spec,
2916 runtime.connection_file_path.as_deref(),
2917 self.supervisor_handle.as_ref(),
2918 &runtime.stderr_ring,
2919 runtime.capture_logs_dir.as_deref(),
2920 &runtime.child_roster,
2921 #[cfg(target_os = "linux")]
2922 runtime.cgroup_placement.as_ref(),
2923 );
2924 #[cfg(test)]
2925 self.test_after_first_spawn.run(&spec.module_id);
2926 let child = match spawned {
2927 Ok(child) => child,
2928 Err(err) => {
2929 self.abandon_unrostered(&spec.module_id);
2932 return Err(err);
2933 }
2934 };
2935 self.mark_first_process_running(&spec, &runtime, &snapshot, &child)?;
2936 if let Some(spawn) = activity_spawn {
2937 spawn.complete(&snapshot);
2938 }
2939
2940 Ok(self.supervised_module(spec, runtime, snapshot, Some(child)))
2941 }
2942
2943 fn establish_identity(&self, spec: &ModuleSpec) {
2956 if let Some(supervisor_handle) = &self.supervisor_handle {
2957 supervisor_handle.apply_identity_configuration(spec);
2958 supervisor_handle.mark_configured(&spec.module_id);
2959 }
2960 }
2961
2962 fn abandon_unrostered(&self, module_id: &str) {
2965 if let Some(supervisor_handle) = &self.supervisor_handle {
2966 supervisor_handle.unmark_configured_unless_rostered(module_id);
2967 }
2968 }
2969
2970 fn mark_first_process_running(
2973 &self,
2974 spec: &ModuleSpec,
2975 runtime: &SupervisorRuntimeConfig,
2976 snapshot: &SharedSnapshot,
2977 child: &SupervisedChild,
2978 ) -> Result<(), SuperviseError> {
2979 if let Err(err) = set_running(snapshot, child, &spec.module_id, &runtime.spawn_events) {
2980 self.abandon_unrostered(&spec.module_id);
2981 return Err(err);
2982 }
2983 self.process_liveness
2984 .track(spec.module_id.clone(), Arc::clone(snapshot));
2985 Ok(())
2986 }
2987
2988 pub fn supervise_configured(
2994 &self,
2995 spec: ModuleSpec,
2996 enabled: bool,
2997 ) -> Result<SupervisedModule, SuperviseError> {
2998 validate_spec(&spec)?;
2999 self.establish_identity(&spec);
3000
3001 let runtime = self.runtime_config();
3002 if !enabled {
3003 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::disabled()));
3004 return Ok(self.supervised_module(spec, runtime, snapshot, None));
3005 }
3006
3007 #[cfg(target_os = "linux")]
3008 if let Some(pid) = runtime.child_roster.previous_instance_pid(&spec.module_id) {
3009 let mut snapshot = SupervisorSnapshot::starting();
3010 snapshot.waiting_for_previous_pid = Some(pid);
3011 return Ok(self.supervised_module(spec, runtime, Arc::new(Mutex::new(snapshot)), None));
3012 }
3013
3014 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
3015 let activity_spawn = runtime.begin_activity_spawn(&spec.module_id);
3016 let spawned = spawn_child(
3017 &spec,
3018 runtime.connection_file_path.as_deref(),
3019 self.supervisor_handle.as_ref(),
3020 &runtime.stderr_ring,
3021 runtime.capture_logs_dir.as_deref(),
3022 &runtime.child_roster,
3023 #[cfg(target_os = "linux")]
3024 runtime.cgroup_placement.as_ref(),
3025 );
3026 #[cfg(test)]
3027 self.test_after_first_spawn.run(&spec.module_id);
3028 match spawned {
3029 Ok(child) => {
3030 self.mark_first_process_running(&spec, &runtime, &snapshot, &child)?;
3031 if let Some(spawn) = activity_spawn {
3032 spawn.complete(&snapshot);
3033 }
3034 Ok(self.supervised_module(spec, runtime, snapshot, Some(child)))
3035 }
3036 Err(err) => {
3037 error!(
3038 module_id = %spec.module_id,
3039 program = %spec.program.display(),
3040 error = %err,
3041 "configured module failed to spawn; marking failed and continuing"
3042 );
3043 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::failed()));
3044 Ok(self.supervised_module(spec, runtime, snapshot, None))
3045 }
3046 }
3047 }
3048
3049 pub fn supervise_configured_with_health(
3055 &self,
3056 spec: ModuleSpec,
3057 enabled: bool,
3058 health: HealthConfig,
3059 drain_timeout_ms: Option<u64>,
3060 restart_policy: RestartPolicy,
3061 ) -> Result<SupervisedModule, SuperviseError> {
3062 self.supervise_configured_requested(
3063 spec,
3064 enabled,
3065 health,
3066 drain_timeout_ms,
3067 restart_policy,
3068 None,
3069 )
3070 }
3071
3072 pub(crate) fn supervise_configured_requested(
3073 &self,
3074 spec: ModuleSpec,
3075 enabled: bool,
3076 health: HealthConfig,
3077 drain_timeout_ms: Option<u64>,
3078 restart_policy: RestartPolicy,
3079 requester: Option<Principal>,
3080 ) -> Result<SupervisedModule, SuperviseError> {
3081 validate_spec(&spec)?;
3082 self.establish_identity(&spec);
3083
3084 let mut runtime = self.runtime_config();
3085 runtime.start_requester = requester;
3086 runtime.health = health.clone();
3087 runtime.restart_policy = restart_policy;
3088 if let Some(ms) = drain_timeout_ms {
3089 runtime.drain_timeout = Duration::from_millis(ms);
3090 *runtime
3091 .effective_drain_timeout
3092 .lock()
3093 .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.drain_timeout;
3094 }
3095 if !enabled {
3096 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::disabled()));
3097 return Ok(self.supervised_module(spec, runtime, snapshot, None));
3098 }
3099
3100 #[cfg(target_os = "linux")]
3101 if let Some(pid) = runtime.child_roster.previous_instance_pid(&spec.module_id) {
3102 let mut snapshot = SupervisorSnapshot::starting();
3103 snapshot.waiting_for_previous_pid = Some(pid);
3104 return Ok(self.supervised_module(spec, runtime, Arc::new(Mutex::new(snapshot)), None));
3105 }
3106
3107 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
3108 let activity_spawn = runtime.begin_activity_spawn(&spec.module_id);
3109 let spawned = spawn_child(
3110 &spec,
3111 runtime.connection_file_path.as_deref(),
3112 self.supervisor_handle.as_ref(),
3113 &runtime.stderr_ring,
3114 runtime.capture_logs_dir.as_deref(),
3115 &runtime.child_roster,
3116 #[cfg(target_os = "linux")]
3117 runtime.cgroup_placement.as_ref(),
3118 );
3119 #[cfg(test)]
3120 self.test_after_first_spawn.run(&spec.module_id);
3121 match spawned {
3122 Ok(child) => {
3123 self.mark_first_process_running(&spec, &runtime, &snapshot, &child)?;
3124 if let Some(spawn) = activity_spawn {
3125 spawn.complete(&snapshot);
3126 }
3127 Ok(self.supervised_module(spec, runtime, snapshot, Some(child)))
3128 }
3129 Err(err) => {
3130 if health.critical {
3131 error!(
3132 module_id = %spec.module_id,
3133 program = %spec.program.display(),
3134 error = %err,
3135 "critical configured module failed to spawn; marking failed and alerting"
3136 );
3137 } else {
3138 error!(
3139 module_id = %spec.module_id,
3140 program = %spec.program.display(),
3141 error = %err,
3142 "configured module failed to spawn; marking failed and continuing"
3143 );
3144 }
3145 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::failed()));
3146 Ok(self.supervised_module(spec, runtime, snapshot, None))
3147 }
3148 }
3149 }
3150
3151 fn runtime_config(&self) -> SupervisorRuntimeConfig {
3152 let effective_drain_timeout = Arc::new(Mutex::new(self.drain_timeout));
3153 SupervisorRuntimeConfig {
3154 start_requester: None,
3155 pending_activity_restart: false,
3156 provenance_probe: self.provenance_probe.clone(),
3157 scheduled_respawn: Arc::default(),
3158 deferred_reload_reply: Arc::default(),
3159 restart_policy: self.restart_policy,
3160 drain_timeout: self.drain_timeout,
3161 child_roster: self
3164 .child_roster
3165 .for_module(Arc::clone(&effective_drain_timeout)),
3166 effective_drain_timeout,
3167 default_drain_timeout: self.drain_timeout,
3168 health: self.health.clone(),
3169 connection_file_path: self.connection_file_path.clone(),
3170 capture_logs_dir: self.capture_logs_dir.clone(),
3171 forwarding: self.forwarding.clone(),
3172 supervisor_handle: self.supervisor_handle.clone(),
3173 stderr_ring: Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default()))),
3174 terminal_ring: Arc::new(Mutex::new(
3175 TerminalRing::new(
3176 TerminalRingConfig::default(),
3177 self.daemon_start_clock.started_at_ms(),
3178 )
3179 .with_start_clock(self.daemon_start_clock)
3180 .with_journal(self.terminal_journal.clone())
3181 .with_daemon_shutdown(self.child_roster.shutdown_flag()),
3182 )),
3183 spawn_events: self.spawn_events.clone(),
3184 #[cfg(target_os = "linux")]
3185 cgroup_placement: self.cgroup_placement.clone(),
3186 #[cfg(test)]
3187 test_seed_stale_facts_before_enable_spawn: false,
3188 #[cfg(test)]
3189 test_reload_exit_record_gate: None,
3190 }
3191 }
3192
3193 fn supervised_module(
3194 &self,
3195 spec: ModuleSpec,
3196 runtime: SupervisorRuntimeConfig,
3197 snapshot: SharedSnapshot,
3198 child: Option<SupervisedChild>,
3199 ) -> SupervisedModule {
3200 let configuration = Arc::new(Mutex::new(SupervisedConfiguration {
3201 spec: spec.clone(),
3202 health: runtime.health.clone(),
3203 }));
3204 let stderr_ring = Arc::clone(&runtime.stderr_ring);
3205 let terminal_ring = Arc::clone(&runtime.terminal_ring);
3206 let restart_policy = runtime.restart_policy;
3210 let effective_drain_timeout = Arc::clone(&runtime.effective_drain_timeout);
3211 let (tx, rx) = mpsc::channel(4);
3212 let monitor = tokio::spawn(supervise_loop(
3213 spec.clone(),
3214 runtime,
3215 Arc::clone(&self.registry),
3216 Arc::clone(&self.process_liveness),
3217 Arc::clone(&snapshot),
3218 child,
3219 rx,
3220 ));
3221
3222 let module_id = spec.module_id.clone();
3223 let module = SupervisedModule {
3224 inner: Arc::new(SupervisedModuleInner {
3225 module_id: module_id.clone(),
3226 registry: Arc::clone(&self.registry),
3227 snapshot,
3228 configuration,
3229 stderr_ring,
3230 terminal_ring,
3231 commands: tx,
3232 monitor: Mutex::new(Some(monitor)),
3233 restart_policy,
3234 effective_drain_timeout,
3235 provenance_probe: self.provenance_probe.clone(),
3236 }),
3237 };
3238 if let Some(supervisor_handle) = &self.supervisor_handle {
3242 supervisor_handle.insert(module.clone());
3243 }
3244 module
3245 }
3246}
3247
3248impl Default for Supervisor {
3249 fn default() -> Self {
3250 Self::new(Arc::new(Registry::default()), RestartPolicy::default())
3251 }
3252}
3253
3254#[derive(Clone)]
3256pub struct SupervisedModule {
3257 inner: Arc<SupervisedModuleInner>,
3258}
3259
3260struct SupervisedModuleInner {
3261 module_id: String,
3262 registry: Arc<Registry>,
3263 snapshot: SharedSnapshot,
3264 configuration: Arc<Mutex<SupervisedConfiguration>>,
3265 stderr_ring: Arc<Mutex<StderrRing>>,
3266 terminal_ring: Arc<Mutex<TerminalRing>>,
3267 commands: mpsc::Sender<SupervisorCommand>,
3268 monitor: Mutex<Option<JoinHandle<()>>>,
3269 restart_policy: RestartPolicy,
3273 effective_drain_timeout: Arc<Mutex<Duration>>,
3274 provenance_probe: ExecutableIdentityProbe,
3275}
3276
3277impl fmt::Debug for SupervisedModule {
3278 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3279 f.debug_struct("SupervisedModule")
3280 .field("module_id", &self.inner.module_id)
3281 .field("status", &self.status())
3282 .finish_non_exhaustive()
3283 }
3284}
3285
3286impl SupervisedModule {
3287 pub fn module_id(&self) -> &str {
3288 &self.inner.module_id
3289 }
3290
3291 #[cfg(test)]
3295 pub(crate) fn record_health_probe_failure_for_test(
3296 &self,
3297 detail: &str,
3298 ) -> Result<(), SuperviseError> {
3299 update_snapshot(&self.inner.snapshot, Some(&self.inner.module_id), |state| {
3300 state.health.consecutive_failures = state.health.consecutive_failures.saturating_add(1);
3301 state.health.detail = Some(detail.to_string());
3302 })
3303 }
3304
3305 pub fn state(&self) -> Result<ModuleState, SuperviseError> {
3306 Ok(lock_snapshot(&self.inner.snapshot)?.state)
3307 }
3308
3309 pub(crate) fn registration_end_reason(
3310 &self,
3311 ) -> Result<Option<RegistrationEndReason>, SuperviseError> {
3312 let snapshot = lock_snapshot(&self.inner.snapshot)?;
3313 Ok(match snapshot.state {
3314 ModuleState::Draining if snapshot.draining_to_replace => {
3315 Some(RegistrationEndReason::SupervisorRestart)
3316 }
3317 ModuleState::Draining => Some(RegistrationEndReason::SupervisorStop),
3318 ModuleState::Restarting if snapshot.draining_to_replace => {
3319 Some(RegistrationEndReason::SupervisorRestart)
3320 }
3321 _ => None,
3322 })
3323 }
3324
3325 pub fn stderr_tail(
3332 &self,
3333 max_lines: Option<usize>,
3334 max_bytes: Option<usize>,
3335 ) -> StderrTailSnapshot {
3336 self.inner
3337 .stderr_ring
3338 .lock()
3339 .unwrap_or_else(|poisoned| poisoned.into_inner())
3340 .snapshot(max_lines, max_bytes)
3341 }
3342
3343 pub fn terminal_history(&self) -> TerminalHistorySnapshot {
3348 self.inner
3349 .terminal_ring
3350 .lock()
3351 .unwrap_or_else(|poisoned| poisoned.into_inner())
3352 .snapshot()
3353 }
3354
3355 pub fn durable_terminal_history(&self) -> subc_control::TerminalHistory {
3360 durable_terminal_history_of(&self.inner.terminal_ring, &self.inner.module_id)
3361 }
3362
3363 pub(crate) async fn read_durable_terminal_history(
3368 &self,
3369 ) -> Result<subc_control::TerminalHistory, tokio::task::JoinError> {
3370 let terminal_ring = Arc::clone(&self.inner.terminal_ring);
3371 let module_id = self.inner.module_id.clone();
3372 tokio::task::spawn_blocking(move || durable_terminal_history_of(&terminal_ring, &module_id))
3373 .await
3374 }
3375
3376 pub fn status(&self) -> Result<ModuleStatus, SuperviseError> {
3377 self.status_with_snapshot_lock(&self.inner.snapshot, None)
3378 .map(|(status, _)| status)
3379 }
3380
3381 pub(crate) fn record_deliberate_severance(
3382 &self,
3383 identity: ProcessIdentity,
3384 ) -> Result<bool, SuperviseError> {
3385 let mut snapshot = lock_snapshot(&self.inner.snapshot)?;
3386 if snapshot.pid != Some(identity.pid)
3387 || snapshot.process_start_time != Some(identity.start_time)
3388 {
3389 return Ok(false);
3390 }
3391 snapshot.deliberate_severance = Some(identity);
3392 Ok(true)
3393 }
3394
3395 pub(crate) fn status_for_control(
3400 &self,
3401 caller: &'static str,
3402 ) -> Result<ModuleStatus, SuperviseError> {
3403 self.status_with_snapshot_lock(&self.inner.snapshot, Some(caller))
3404 .map(|(status, _)| status)
3405 }
3406
3407 fn status_with_snapshot_lock(
3408 &self,
3409 snapshot: &SharedSnapshot,
3410 caller: Option<&'static str>,
3411 ) -> Result<(ModuleStatus, Option<SpawnedFileIdentity>), SuperviseError> {
3412 let mut guard = match caller {
3413 Some(caller) => lock_snapshot_for_control(snapshot, &self.inner.module_id, caller)?,
3414 None => lock_snapshot(snapshot)?,
3415 };
3416 let restart_count =
3419 guard.crash_restarts_in_window(self.inner.restart_policy.window, Instant::now());
3420 let snapshot = guard.clone();
3421 drop(guard);
3422 let drain_timeout = *self.inner.effective_drain_timeout.lock().map_err(|_| {
3423 SuperviseError::StatePoisoned {
3424 module_id: Some(self.inner.module_id.clone()),
3425 }
3426 })?;
3427 let registration_active = self
3428 .inner
3429 .registry
3430 .get_module(&self.inner.module_id)
3431 .map_err(SuperviseError::Registry)?
3432 .is_some();
3433 let protocol = snapshot
3434 .spawned_protocol
3435 .unwrap_or(self.declared_protocol()?);
3436 let running_process =
3437 snapshot.enabled && snapshot.state == ModuleState::Running && snapshot.process_alive;
3438 let live = match protocol {
3444 ModuleProtocol::Subc => running_process && registration_active,
3445 ModuleProtocol::None => running_process,
3446 };
3447
3448 Ok((
3449 ModuleStatus {
3450 waiting_for_previous_pid: snapshot.waiting_for_previous_pid,
3451 module_id: self.inner.module_id.clone(),
3452 launch_nonce_source: observed_launch_nonce_source(&snapshot),
3453 state: snapshot.state,
3454 enabled: snapshot.enabled,
3455 process_alive: snapshot.process_alive,
3456 registration_active,
3457 protocol,
3458 live,
3459 restart_count,
3460 lifetime_restarts: snapshot.lifetime_restarts,
3461 spawn_generation: snapshot.spawn_generation,
3462 max_restarts: self.inner.restart_policy.max_restarts,
3463 restart_window: self.inner.restart_policy.window,
3464 drain_timeout,
3465 restart_backoff: self.inner.restart_policy.backoff,
3466 restart_max_backoff: self.inner.restart_policy.max_backoff,
3467 pid: snapshot.reported_pid(),
3468 spawned_at_ms: snapshot.spawned_at_ms,
3469 spawned_from: snapshot.spawned_from,
3470 process_start_time: snapshot.process_start_time,
3471 last_exit: snapshot.last_exit,
3472 health: snapshot.health,
3473 },
3474 snapshot.spawned_file_identity.clone(),
3475 ))
3476 }
3477
3478 #[cfg(test)]
3479 pub(crate) fn hold_snapshot_for_test(
3480 &self,
3481 acquired: std::sync::mpsc::Sender<()>,
3482 hold: Duration,
3483 ) -> std::thread::JoinHandle<()> {
3484 let snapshot = Arc::clone(&self.inner.snapshot);
3485 std::thread::spawn(move || {
3486 let _guard = snapshot.lock().expect("test snapshot lock is not poisoned");
3487 acquired
3488 .send(())
3489 .expect("test receiver waits for snapshot lock");
3490 std::thread::sleep(hold);
3491 })
3492 }
3493
3494 pub(crate) async fn status_and_running_image_agreement(
3500 &self,
3501 ) -> Result<(ModuleStatus, subc_control::RunningImageAgreement), SuperviseError> {
3502 let (status, identity) = self.status_with_snapshot_lock(&self.inner.snapshot, None)?;
3503 let image = self
3504 .inner
3505 .provenance_probe
3506 .observe(
3507 status.pid,
3508 status.spawned_from.as_deref(),
3509 identity,
3510 status.process_start_time,
3511 )
3512 .await;
3513 Ok((status, image))
3514 }
3515
3516 pub(crate) async fn running_image_agreement(&self) -> subc_control::RunningImageAgreement {
3517 let snapshot = match lock_snapshot(&self.inner.snapshot) {
3518 Ok(snapshot) => snapshot.clone(),
3519 Err(_) => {
3520 return subc_control::RunningImageAgreement::Unavailable {
3521 reason: subc_control::RunningImageUnavailableReason::NotRunning,
3522 };
3523 }
3524 };
3525 self.inner
3526 .provenance_probe
3527 .observe(
3528 snapshot.reported_pid(),
3529 snapshot.spawned_from.as_deref(),
3530 snapshot.spawned_file_identity.clone(),
3531 snapshot.process_start_time,
3532 )
3533 .await
3534 }
3535
3536 pub(crate) fn child_resource_usage(&self) -> subc_control::ChildResourceUsage {
3539 #[cfg(windows)]
3540 {
3541 let image = lock_snapshot(&self.inner.snapshot)
3542 .ok()
3543 .and_then(|snapshot| {
3544 snapshot
3545 .spawned_file_identity
3546 .as_ref()
3547 .and_then(|identity| identity.windows_image.clone())
3548 });
3549 match image {
3550 Some(image) => {
3551 crate::child_resources::read_windows_handle(image.process(), image.start_time())
3552 }
3553 None => subc_control::ChildResourceUsage::Unavailable {
3554 reason: subc_control::ChildResourceUnavailableReason::NotRunning,
3555 },
3556 }
3557 }
3558 #[cfg(not(windows))]
3559 {
3560 let (pid, start_time) = match lock_snapshot(&self.inner.snapshot) {
3561 Ok(snapshot) => (snapshot.reported_pid(), snapshot.process_start_time),
3562 Err(_) => {
3563 return subc_control::ChildResourceUsage::Unavailable {
3564 reason: subc_control::ChildResourceUnavailableReason::Unreadable,
3565 }
3566 }
3567 };
3568 crate::child_resources::read(pid, start_time)
3569 }
3570 }
3571
3572 pub(crate) fn will_recover_after_connection_loss(&self) -> Result<bool, SuperviseError> {
3573 let mut snapshot = lock_snapshot(&self.inner.snapshot)?;
3574 Ok(match snapshot.state {
3575 ModuleState::Restarting => true,
3576 ModuleState::Failed | ModuleState::Disabled => false,
3577 _ => daemon_will_restart(&mut snapshot, &self.inner.restart_policy, Instant::now()),
3578 })
3579 }
3580
3581 #[cfg(test)]
3582 pub(crate) fn is_warming(&self) -> Result<bool, SuperviseError> {
3583 self.is_warming_with_snapshot_lock(None)
3584 }
3585
3586 pub(crate) fn is_warming_for_control(
3587 &self,
3588 caller: &'static str,
3589 ) -> Result<bool, SuperviseError> {
3590 self.is_warming_with_snapshot_lock(Some(caller))
3591 }
3592
3593 fn is_warming_with_snapshot_lock(
3594 &self,
3595 caller: Option<&'static str>,
3596 ) -> Result<bool, SuperviseError> {
3597 let snapshot = match caller {
3598 Some(caller) => {
3599 lock_snapshot_for_control(&self.inner.snapshot, &self.inner.module_id, caller)?
3600 }
3601 None => lock_snapshot(&self.inner.snapshot)?,
3602 }
3603 .clone();
3604 Ok(matches!(
3605 snapshot.state,
3606 ModuleState::Starting | ModuleState::Running | ModuleState::Restarting
3607 ))
3608 }
3609
3610 pub async fn drain(&self) -> Result<(), SuperviseError> {
3612 self.stop().await
3613 }
3614
3615 pub(crate) async fn retire_requested(
3616 &self,
3617 requester: Option<Principal>,
3618 ) -> Result<(), SuperviseError> {
3619 match self.state()? {
3620 ModuleState::Stopped | ModuleState::Failed => return Ok(()),
3621 ModuleState::Starting
3622 | ModuleState::Running
3623 | ModuleState::Unresponsive
3624 | ModuleState::Restarting
3625 | ModuleState::Draining
3626 | ModuleState::Disabled => {}
3627 }
3628
3629 let (reply_tx, reply_rx) = oneshot::channel();
3630 self.inner
3631 .commands
3632 .send(SupervisorCommand::Retire {
3633 requester,
3634 reply: reply_tx,
3635 })
3636 .await
3637 .map_err(|_| SuperviseError::CommandClosed {
3638 module_id: self.inner.module_id.clone(),
3639 })?;
3640 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3641 module_id: self.inner.module_id.clone(),
3642 })?
3643 }
3644
3645 pub async fn stop(&self) -> Result<(), SuperviseError> {
3646 self.stop_requested(None).await
3647 }
3648
3649 pub(crate) async fn stop_requested(
3650 &self,
3651 requester: Option<Principal>,
3652 ) -> Result<(), SuperviseError> {
3653 match self.state()? {
3654 ModuleState::Stopped | ModuleState::Failed => return Ok(()),
3655 ModuleState::Starting
3656 | ModuleState::Running
3657 | ModuleState::Unresponsive
3658 | ModuleState::Restarting
3659 | ModuleState::Draining
3660 | ModuleState::Disabled => {}
3661 }
3662
3663 let (reply_tx, reply_rx) = oneshot::channel();
3664 self.inner
3665 .commands
3666 .send(SupervisorCommand::Drain {
3667 requester,
3668 reply: reply_tx,
3669 })
3670 .await
3671 .map_err(|_| SuperviseError::CommandClosed {
3672 module_id: self.inner.module_id.clone(),
3673 })?;
3674 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3675 module_id: self.inner.module_id.clone(),
3676 })?
3677 }
3678
3679 pub async fn restart(&self, drain_timeout_ms: Option<u64>) -> Result<(), SuperviseError> {
3680 self.restart_requested(drain_timeout_ms, None).await
3681 }
3682
3683 pub(crate) async fn restart_requested(
3684 &self,
3685 drain_timeout_ms: Option<u64>,
3686 requester: Option<Principal>,
3687 ) -> Result<(), SuperviseError> {
3688 let received_at_generation = lock_snapshot(&self.inner.snapshot)?.spawn_generation;
3689 let (reply_tx, reply_rx) = oneshot::channel();
3690 self.inner
3691 .commands
3692 .send(SupervisorCommand::Restart {
3693 requester,
3694 drain_timeout_ms,
3695 received_at_generation,
3696 queued_at: Instant::now(),
3697 reply: reply_tx,
3698 })
3699 .await
3700 .map_err(|_| SuperviseError::CommandClosed {
3701 module_id: self.inner.module_id.clone(),
3702 })?;
3703 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3704 module_id: self.inner.module_id.clone(),
3705 })?
3706 }
3707
3708 pub async fn swap(&self, ready_timeout: Option<Duration>) -> Result<(), SuperviseError> {
3713 self.swap_requested(ready_timeout, None).await
3714 }
3715
3716 pub(crate) async fn swap_requested(
3717 &self,
3718 ready_timeout: Option<Duration>,
3719 requester: Option<Principal>,
3720 ) -> Result<(), SuperviseError> {
3721 let (reply_tx, reply_rx) = oneshot::channel();
3722 self.inner
3723 .commands
3724 .send(SupervisorCommand::Swap {
3725 requester,
3726 ready_timeout,
3727 reply: reply_tx,
3728 })
3729 .await
3730 .map_err(|_| SuperviseError::CommandClosed {
3731 module_id: self.inner.module_id.clone(),
3732 })?;
3733 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3734 module_id: self.inner.module_id.clone(),
3735 })?
3736 }
3737
3738 pub async fn reload(&self) -> Result<(), SuperviseError> {
3739 self.reload_requested(None).await
3740 }
3741
3742 pub(crate) async fn reload_requested(
3743 &self,
3744 requester: Option<Principal>,
3745 ) -> Result<(), SuperviseError> {
3746 let (reply_tx, reply_rx) = oneshot::channel();
3747 self.inner
3748 .commands
3749 .send(SupervisorCommand::Reload {
3750 requester,
3751 reply: reply_tx,
3752 })
3753 .await
3754 .map_err(|_| SuperviseError::CommandClosed {
3755 module_id: self.inner.module_id.clone(),
3756 })?;
3757 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3758 module_id: self.inner.module_id.clone(),
3759 })?
3760 }
3761
3762 pub async fn set_enabled(&self, enabled: bool) -> Result<bool, SuperviseError> {
3763 self.set_enabled_requested(enabled, None).await
3764 }
3765
3766 pub(crate) async fn set_enabled_requested(
3767 &self,
3768 enabled: bool,
3769 requester: Option<Principal>,
3770 ) -> Result<bool, SuperviseError> {
3771 let (reply_tx, reply_rx) = oneshot::channel();
3772 self.inner
3773 .commands
3774 .send(SupervisorCommand::SetEnabled {
3775 requester,
3776 enabled,
3777 reply: reply_tx,
3778 })
3779 .await
3780 .map_err(|_| SuperviseError::CommandClosed {
3781 module_id: self.inner.module_id.clone(),
3782 })?;
3783 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3784 module_id: self.inner.module_id.clone(),
3785 })?
3786 }
3787
3788 pub(crate) fn declared_protocol(&self) -> Result<ModuleProtocol, SuperviseError> {
3792 let configured = self
3793 .inner
3794 .configuration
3795 .lock()
3796 .map_err(|_| SuperviseError::StatePoisoned {
3797 module_id: Some(self.inner.module_id.clone()),
3798 })?
3799 .spec
3800 .protocol;
3801 let state = lock_snapshot(&self.inner.snapshot)?;
3802 Ok(state.spawned_protocol.unwrap_or(configured))
3803 }
3804
3805 pub(crate) fn configuration(&self) -> Result<(ModuleSpec, HealthConfig), SuperviseError> {
3806 let configuration =
3807 self.inner
3808 .configuration
3809 .lock()
3810 .map_err(|_| SuperviseError::StatePoisoned {
3811 module_id: Some(self.inner.module_id.clone()),
3812 })?;
3813 Ok((configuration.spec.clone(), configuration.health.clone()))
3814 }
3815
3816 #[cfg(any(test, feature = "test-support"))]
3820 pub async fn update_spec_for_test(&self, spec: ModuleSpec) -> Result<(), SuperviseError> {
3821 let (_, health) = self.configuration()?;
3822 let drain_timeout_ms = u64::try_from(
3823 self.inner
3824 .effective_drain_timeout
3825 .lock()
3826 .unwrap_or_else(|poisoned| poisoned.into_inner())
3827 .as_millis(),
3828 )
3829 .ok();
3830 self.update_configuration(spec, health, drain_timeout_ms)
3831 .await
3832 }
3833
3834 pub(crate) async fn update_configuration(
3835 &self,
3836 spec: ModuleSpec,
3837 health: HealthConfig,
3838 drain_timeout_ms: Option<u64>,
3839 ) -> Result<(), SuperviseError> {
3840 if spec.module_id != self.inner.module_id {
3841 return Err(SuperviseError::InvalidSpec {
3842 reason: "a supervised module's module_id cannot be changed".to_string(),
3843 });
3844 }
3845 validate_spec(&spec)?;
3846 let (reply_tx, reply_rx) = oneshot::channel();
3847 self.inner
3848 .commands
3849 .send(SupervisorCommand::UpdateConfiguration {
3850 spec: spec.clone(),
3851 health: health.clone(),
3852 drain_timeout_ms,
3853 reply: reply_tx,
3854 })
3855 .await
3856 .map_err(|_| SuperviseError::CommandClosed {
3857 module_id: self.inner.module_id.clone(),
3858 })?;
3859 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3860 module_id: self.inner.module_id.clone(),
3861 })?;
3862 let mut configuration =
3863 self.inner
3864 .configuration
3865 .lock()
3866 .map_err(|_| SuperviseError::StatePoisoned {
3867 module_id: Some(self.inner.module_id.clone()),
3868 })?;
3869 configuration.spec = spec;
3870 configuration.health = health;
3871 Ok(())
3872 }
3873}
3874
3875impl Drop for SupervisedModuleInner {
3876 fn drop(&mut self) {
3877 let Ok(mut monitor) = self.monitor.lock() else {
3878 return;
3879 };
3880 if let Some(monitor) = monitor.as_ref().filter(|monitor| !monitor.is_finished()) {
3881 let _ = update_snapshot(&self.snapshot, Some(&self.module_id), |state| {
3882 state.state = ModuleState::Stopped;
3883 clear_current_process_facts(state);
3884 });
3885 monitor.abort();
3886 }
3887 let _ = monitor.take();
3888 }
3889}
3890
3891#[derive(Debug)]
3892enum SupervisorCommand {
3893 Drain {
3894 requester: Option<Principal>,
3895 reply: oneshot::Sender<Result<(), SuperviseError>>,
3896 },
3897 Retire {
3898 requester: Option<Principal>,
3899 reply: oneshot::Sender<Result<(), SuperviseError>>,
3900 },
3901 Restart {
3902 requester: Option<Principal>,
3903 drain_timeout_ms: Option<u64>,
3908 received_at_generation: u64,
3912 queued_at: Instant,
3915 reply: oneshot::Sender<Result<(), SuperviseError>>,
3916 },
3917 Reload {
3918 requester: Option<Principal>,
3919 reply: oneshot::Sender<Result<(), SuperviseError>>,
3920 },
3921 SetEnabled {
3922 requester: Option<Principal>,
3923 enabled: bool,
3924 reply: oneshot::Sender<Result<bool, SuperviseError>>,
3925 },
3926 UpdateConfiguration {
3927 spec: ModuleSpec,
3928 health: HealthConfig,
3929 drain_timeout_ms: Option<u64>,
3932 reply: oneshot::Sender<()>,
3933 },
3934 Swap {
3935 requester: Option<Principal>,
3936 ready_timeout: Option<Duration>,
3939 reply: oneshot::Sender<Result<(), SuperviseError>>,
3941 },
3942}
3943
3944#[derive(Debug)]
3945pub enum SuperviseError {
3946 InvalidSpec {
3947 reason: String,
3948 },
3949 Spawn {
3950 program: PathBuf,
3951 source: io::Error,
3952 cgroup_path: Option<PathBuf>,
3953 },
3954 Cgroup {
3955 module_id: String,
3956 source: io::Error,
3957 },
3958 LaunchNonce {
3961 reason: String,
3962 },
3963 Wait {
3964 module_id: String,
3965 source: io::Error,
3966 },
3967 Kill {
3968 module_id: String,
3969 source: io::Error,
3970 },
3971 Forwarding(ForwardingError),
3972 Registry(RegistryError),
3973 ReloadUnavailable {
3974 module_id: String,
3975 reason: String,
3976 },
3977 Disabled {
3982 module_id: String,
3983 },
3984 ReloadFailed {
3985 module_id: String,
3986 reason: String,
3987 },
3988 RegistrationStillActive {
3989 module_id: String,
3990 waited: Duration,
3991 },
3992 StatePoisoned {
3993 module_id: Option<String>,
3994 },
3995 CommandClosed {
3996 module_id: String,
3997 },
3998 SwapInProgress {
4002 module_id: String,
4003 },
4004 SwapRefused {
4006 module_id: String,
4007 reason: SwapRefusal,
4008 },
4009 SwapFailed {
4013 module_id: String,
4014 arm: SwapFailureArm,
4015 detail: String,
4016 candidate_exit: Option<ExitReport>,
4019 },
4020}
4021
4022#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4024pub enum SwapRefusal {
4025 OverlapExclusive,
4027 NotRegistered,
4030 ProtocolNone,
4033 NotConfigured,
4036 AlreadySwapping,
4038}
4039
4040impl SwapRefusal {
4041 pub fn as_str(self) -> &'static str {
4042 match self {
4043 Self::OverlapExclusive => "overlap_exclusive",
4044 Self::NotRegistered => "not_registered",
4045 Self::ProtocolNone => "protocol_none",
4046 Self::NotConfigured => "not_configured",
4047 Self::AlreadySwapping => "already_swapping",
4048 }
4049 }
4050}
4051
4052#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4055pub enum SwapFailureArm {
4056 SpawnFailed,
4058 NeverRegistered,
4060 NeverReady,
4062 CandidateExited,
4064 CandidateUnhealthy,
4066 Interrupted,
4070 CutoverLost,
4075}
4076
4077impl SwapFailureArm {
4078 pub fn as_str(self) -> &'static str {
4079 match self {
4080 Self::SpawnFailed => "spawn_failed",
4081 Self::NeverRegistered => "never_registered",
4082 Self::NeverReady => "never_ready",
4083 Self::CandidateExited => "candidate_exited",
4084 Self::CandidateUnhealthy => "candidate_unhealthy",
4085 Self::Interrupted => "interrupted",
4086 Self::CutoverLost => "cutover_lost",
4087 }
4088 }
4089}
4090
4091impl fmt::Display for SuperviseError {
4092 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4093 match self {
4094 Self::InvalidSpec { reason } => write!(f, "invalid module spec: {reason}"),
4095 Self::Spawn {
4096 program,
4097 source,
4098 cgroup_path: Some(cgroup_path),
4099 } => write!(
4100 f,
4101 "failed to place module in cgroup '{}' while spawning '{}': {source}",
4102 cgroup_path.display(),
4103 program.display()
4104 ),
4105 Self::Spawn {
4106 program,
4107 source,
4108 cgroup_path: None,
4109 } => write!(
4110 f,
4111 "failed to spawn module '{}': {source}",
4112 program.display()
4113 ),
4114 Self::Cgroup { module_id, source } => {
4115 write!(
4116 f,
4117 "failed to prepare cgroup for module '{module_id}': {source}"
4118 )
4119 }
4120 Self::LaunchNonce { reason } => {
4121 write!(
4122 f,
4123 "failed to generate reserved-module launch nonce: {reason}"
4124 )
4125 }
4126 Self::Wait { module_id, source } => {
4127 write!(f, "failed to wait for module '{module_id}': {source}")
4128 }
4129 Self::Kill { module_id, source } => {
4130 write!(f, "failed to kill module '{module_id}': {source}")
4131 }
4132 Self::Forwarding(err) => write!(f, "forwarding error: {err}"),
4133 Self::Registry(err) => write!(f, "registry error: {err}"),
4134 Self::ReloadUnavailable { module_id, reason } => {
4135 write!(f, "reload unavailable for module '{module_id}': {reason}")
4136 }
4137 Self::Disabled { module_id } => {
4138 write!(
4139 f,
4140 "module '{module_id}' is disabled; enable it before restart or reload"
4141 )
4142 }
4143 Self::ReloadFailed { module_id, reason } => {
4144 write!(f, "reload failed for module '{module_id}': {reason}")
4145 }
4146 Self::RegistrationStillActive { module_id, waited } => write!(
4147 f,
4148 "module '{module_id}' registration remained active after waiting {waited:?}"
4149 ),
4150 Self::StatePoisoned { module_id } => match module_id {
4151 Some(module_id) => {
4152 write!(f, "supervisor state for module '{module_id}' was poisoned")
4153 }
4154 None => write!(f, "supervisor state was poisoned"),
4155 },
4156 Self::CommandClosed { module_id } => {
4157 write!(
4158 f,
4159 "supervisor command channel for module '{module_id}' is closed"
4160 )
4161 }
4162 Self::SwapInProgress { module_id } => write!(
4163 f,
4164 "module '{module_id}' is being swapped; retry once the swap has cut over or failed, or stop the module to abort the swap"
4165 ),
4166 Self::SwapRefused { module_id, reason } => match reason {
4167 SwapRefusal::OverlapExclusive => write!(
4168 f,
4169 "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"
4170 ),
4171 SwapRefusal::NotRegistered => write!(
4172 f,
4173 "module '{module_id}' is not registered, so there is no serving process to keep while a replacement warms; use a plain restart"
4174 ),
4175 SwapRefusal::ProtocolNone => write!(
4176 f,
4177 "module '{module_id}' is protocol: \"none\" and never registers, so a swap could never see its replacement become ready; use a plain restart"
4178 ),
4179 SwapRefusal::NotConfigured => write!(
4180 f,
4181 "module '{module_id}' cannot be swapped: the supervisor was built without the forwarding table or shared handle a swap needs"
4182 ),
4183 SwapRefusal::AlreadySwapping => {
4184 write!(f, "module '{module_id}' is already being swapped")
4185 }
4186 },
4187 Self::SwapFailed {
4188 module_id,
4189 arm,
4190 detail,
4191 ..
4192 } => write!(
4193 f,
4194 "swap of module '{module_id}' failed ({}): {detail}; the running process was left serving",
4195 arm.as_str()
4196 ),
4197 }
4198 }
4199}
4200
4201impl Error for SuperviseError {
4202 fn source(&self) -> Option<&(dyn Error + 'static)> {
4203 match self {
4204 Self::Spawn { source, .. }
4205 | Self::Cgroup { source, .. }
4206 | Self::Wait { source, .. }
4207 | Self::Kill { source, .. } => Some(source),
4208 Self::Forwarding(err) => Some(err),
4209 Self::Registry(err) => Some(err),
4210 Self::LaunchNonce { .. }
4211 | Self::InvalidSpec { .. }
4212 | Self::ReloadUnavailable { .. }
4213 | Self::Disabled { .. }
4214 | Self::ReloadFailed { .. }
4215 | Self::RegistrationStillActive { .. }
4216 | Self::StatePoisoned { .. }
4217 | Self::CommandClosed { .. }
4218 | Self::SwapInProgress { .. }
4219 | Self::SwapRefused { .. }
4220 | Self::SwapFailed { .. } => None,
4221 }
4222 }
4223}
4224
4225pub(crate) fn validate_spec(spec: &ModuleSpec) -> Result<(), SuperviseError> {
4226 if spec.module_id.trim().is_empty() {
4227 return Err(SuperviseError::InvalidSpec {
4228 reason: "module_id must not be empty".to_string(),
4229 });
4230 }
4231
4232 Ok(())
4233}
4234
4235#[derive(Debug, Default)]
4236struct HealthProbeRuntime {
4237 configured_health: Option<HealthConfig>,
4238 registered_connection: Option<crate::ConnectionId>,
4239 advertised: bool,
4240 next_probe_at: Option<Instant>,
4241 probe_index: u64,
4242}
4243
4244fn running_protocol(spec: &ModuleSpec, snapshot: &SharedSnapshot) -> ModuleProtocol {
4245 lock_snapshot(snapshot)
4246 .ok()
4247 .and_then(|state| state.spawned_protocol)
4248 .unwrap_or(spec.protocol)
4249}
4250
4251impl HealthProbeRuntime {
4252 fn refresh_registration(
4253 &mut self,
4254 spec: &ModuleSpec,
4255 runtime: &SupervisorRuntimeConfig,
4256 registry: &Registry,
4257 snapshot: &SharedSnapshot,
4258 ) {
4259 if self.configured_health.as_ref() != Some(&runtime.health) {
4260 self.configured_health = Some(runtime.health.clone());
4261 self.next_probe_at = None;
4262 self.registered_connection = None;
4263 self.probe_index = 0;
4264 }
4265 if running_protocol(spec, snapshot) == ModuleProtocol::None {
4269 self.registered_connection = None;
4270 self.advertised = runtime.health.http.is_some();
4271 if !self.advertised {
4272 self.next_probe_at = None;
4273 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4274 let unknown = ModuleHealthStatus::default();
4275 if state.health != unknown {
4276 state.health = unknown;
4277 }
4278 });
4279 } else if self.next_probe_at.is_none() {
4280 self.next_probe_at = Some(
4281 Instant::now()
4282 + jittered_health_delay(&spec.module_id, 0, runtime.health.cadence),
4283 );
4284 }
4285 return;
4286 }
4287
4288 let registration = match registry.get_module(&spec.module_id) {
4289 Ok(registration) => registration,
4290 Err(err) => {
4291 warn!(module_id = %spec.module_id, error = %err, "health prober could not read registry");
4292 self.advertised = false;
4293 self.next_probe_at = None;
4294 return;
4295 }
4296 };
4297
4298 let Some(registration) = registration else {
4299 self.registered_connection = None;
4300 self.advertised = false;
4301 self.next_probe_at = None;
4302 return;
4303 };
4304
4305 let advertised = registration
4306 .control_ops
4307 .iter()
4308 .any(|op| op == MODULE_CONTROL_OP_HEALTH_CHECK);
4309 if !advertised {
4310 self.registered_connection = Some(registration.connection_id);
4311 self.advertised = false;
4312 self.next_probe_at = None;
4313 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4314 if state.health.status != SupervisorHealthStatus::Unknown
4315 || state.health.consecutive_failures != 0
4316 || state.health.last_probe_ms.is_some()
4317 || state.health.detail.is_some()
4318 || state.health.metrics.is_some()
4319 {
4320 state.health.status = SupervisorHealthStatus::Unknown;
4321 state.health.consecutive_failures = 0;
4322 state.health.last_probe_ms = None;
4323 state.health.detail = None;
4324 state.health.metrics = None;
4325 }
4326 });
4327 return;
4328 }
4329
4330 let reregistered = self.registered_connection != Some(registration.connection_id);
4331 self.registered_connection = Some(registration.connection_id);
4332 self.advertised = true;
4333 if reregistered || self.next_probe_at.is_none() {
4334 self.probe_index = 0;
4335 self.next_probe_at = Some(
4336 Instant::now() + jittered_health_delay(&spec.module_id, 0, runtime.health.cadence),
4337 );
4338 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4339 state.health.status = SupervisorHealthStatus::Unknown;
4340 state.health.consecutive_failures = 0;
4341 state.health.detail = None;
4342 state.health.metrics = None;
4343 });
4344 }
4345 }
4346
4347 fn wake_after(&self) -> Option<Duration> {
4348 if !self.advertised {
4349 return None;
4350 }
4351 self.next_probe_at
4352 .map(|next| next.saturating_duration_since(Instant::now()))
4353 }
4354
4355 fn due(&self) -> bool {
4356 self.advertised
4357 && self
4358 .next_probe_at
4359 .is_some_and(|next| Instant::now() >= next)
4360 }
4361
4362 fn schedule_next(&mut self, spec: &ModuleSpec, cadence: Duration) {
4363 self.probe_index = self.probe_index.wrapping_add(1);
4364 self.next_probe_at = Some(
4365 Instant::now() + jittered_health_delay(&spec.module_id, self.probe_index, cadence),
4366 );
4367 }
4368}
4369
4370#[derive(Debug)]
4405enum HealthProbeEvidence {
4406 LaneDead,
4408 NoAnswer,
4410 BadAnswer,
4412 Misconfigured,
4414}
4415
4416#[derive(Debug)]
4417struct HealthProbeError {
4418 evidence: HealthProbeEvidence,
4419 message: String,
4420}
4421
4422impl HealthProbeError {
4423 fn lane_dead(message: impl Into<String>) -> Self {
4424 Self::with(HealthProbeEvidence::LaneDead, message)
4425 }
4426
4427 fn no_answer(message: impl Into<String>) -> Self {
4428 Self::with(HealthProbeEvidence::NoAnswer, message)
4429 }
4430
4431 fn bad_answer(message: impl Into<String>) -> Self {
4432 Self::with(HealthProbeEvidence::BadAnswer, message)
4433 }
4434
4435 fn misconfigured(message: impl Into<String>) -> Self {
4436 Self::with(HealthProbeEvidence::Misconfigured, message)
4437 }
4438
4439 fn with(evidence: HealthProbeEvidence, message: impl Into<String>) -> Self {
4440 Self {
4441 evidence,
4442 message: message.into(),
4443 }
4444 }
4445
4446 #[allow(dead_code)]
4460 fn is_proof_of_death(&self) -> bool {
4461 matches!(self.evidence, HealthProbeEvidence::LaneDead)
4462 }
4463
4464 fn label(&self) -> &'static str {
4472 match self.evidence {
4473 HealthProbeEvidence::LaneDead => "lane-dead",
4474 HealthProbeEvidence::NoAnswer => "no-answer",
4475 HealthProbeEvidence::BadAnswer => "bad-answer",
4476 HealthProbeEvidence::Misconfigured => "daemon-misconfigured",
4477 }
4478 }
4479}
4480
4481impl fmt::Display for HealthProbeError {
4482 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4483 f.write_str(&self.message)
4484 }
4485}
4486
4487async fn run_health_probe_cycle(
4488 spec: &ModuleSpec,
4489 runtime: &SupervisorRuntimeConfig,
4490 registry: &Registry,
4491 process_liveness: &SupervisorProcessLiveness,
4492 snapshot: &SharedSnapshot,
4493 child: &mut Option<SupervisedChild>,
4494) {
4495 let now_ms = unix_ms_now();
4496 let http = (running_protocol(spec, snapshot) == ModuleProtocol::None)
4497 .then_some(runtime.health.http.as_deref())
4498 .flatten();
4499 let result = match http {
4500 Some(url) => probe_http_health(url, runtime.health.deadline).await,
4501 None => probe_module_health(&spec.module_id, runtime, None).await,
4502 };
4503 match result {
4504 Ok(report) => {
4505 handle_health_report(
4506 spec,
4507 runtime,
4508 registry,
4509 process_liveness,
4510 snapshot,
4511 child,
4512 report,
4513 now_ms,
4514 )
4515 .await;
4516 }
4517 Err(err) => {
4518 if http.is_some() {
4519 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4520 state.health.status = SupervisorHealthStatus::Failing;
4521 });
4522 }
4523 handle_health_probe_failure(
4524 spec,
4525 runtime,
4526 registry,
4527 process_liveness,
4528 snapshot,
4529 child,
4530 err,
4531 now_ms,
4532 )
4533 .await;
4534 }
4535 }
4536}
4537
4538pub(crate) struct HttpProbeTarget<'a> {
4539 address: std::net::SocketAddr,
4540 localhost: bool,
4541 authority: &'a str,
4542 path: String,
4543}
4544
4545pub(crate) fn parse_http_probe_url(url: &str) -> Result<HttpProbeTarget<'_>, String> {
4548 if url.bytes().any(|b| b <= b' ' || b == 127) || url.contains('#') {
4549 return Err("must not contain whitespace, controls, or a fragment".into());
4550 }
4551 let rest = url
4552 .strip_prefix("http://")
4553 .ok_or("must use plain http://")?;
4554 let split = rest.find(['/', '?']).unwrap_or(rest.len());
4555 let (authority, suffix) = rest.split_at(split);
4556 let (host, port) = if let Some(rest) = authority.strip_prefix("[::1]") {
4557 ("::1", rest)
4558 } else {
4559 let split = authority.find(':').unwrap_or(authority.len());
4560 authority.split_at(split)
4561 };
4562 let ip: std::net::IpAddr = match host {
4563 "127.0.0.1" | "localhost" => std::net::Ipv4Addr::LOCALHOST.into(),
4564 "::1" => std::net::Ipv6Addr::LOCALHOST.into(),
4565 _ => return Err("host must be 127.0.0.1, [::1], or localhost".into()),
4566 };
4567 let port = if port.is_empty() {
4568 80
4569 } else {
4570 port.strip_prefix(':')
4571 .and_then(|p| p.parse::<u16>().ok())
4572 .filter(|p| *p > 0)
4573 .ok_or("must have a valid nonzero TCP port")?
4574 };
4575 let path = if suffix.is_empty() {
4576 "/".into()
4577 } else if suffix.starts_with('?') {
4578 format!("/{suffix}")
4579 } else {
4580 suffix.into()
4581 };
4582 Ok(HttpProbeTarget {
4583 address: std::net::SocketAddr::new(ip, port),
4584 localhost: host == "localhost",
4585 authority,
4586 path,
4587 })
4588}
4589
4590async fn probe_http_health(
4591 url: &str,
4592 deadline: Duration,
4593) -> Result<HealthReport, HealthProbeError> {
4594 use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader};
4595 let target = parse_http_probe_url(url).map_err(HealthProbeError::misconfigured)?;
4596 let mut response_status = String::new();
4599 let mut body = Vec::new();
4600 let probe = async {
4601 let connection = match tokio::net::TcpStream::connect(target.address).await {
4604 Err(_) if target.localhost => {
4605 tokio::net::TcpStream::connect((
4606 std::net::Ipv6Addr::LOCALHOST,
4607 target.address.port(),
4608 ))
4609 .await
4610 }
4611 result => result,
4612 };
4613 let mut stream = connection.map_err(|error| {
4614 HealthProbeError::no_answer(format!("HTTP connect failed: {error}"))
4615 })?;
4616 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
4617 .map_err(|error| HealthProbeError::no_answer(format!("HTTP write failed: {error}")))?;
4618 let mut reader = BufReader::new(stream);
4619 let mut budget = 16 * 1024;
4620 let status = http_line(&mut reader, &mut budget).await?;
4621 let mut words = status.split_ascii_whitespace();
4622 let version = words.next();
4623 let code = words
4624 .next()
4625 .filter(|word| word.len() == 3)
4626 .and_then(|word| word.parse::<u16>().ok());
4627 if !matches!(version, Some("HTTP/1.1" | "HTTP/1.0"))
4628 || !code.is_some_and(|code| (100..600).contains(&code))
4629 {
4630 return Err(HealthProbeError::bad_answer(format!(
4631 "invalid HTTP status: {status}"
4632 )));
4633 }
4634 let code = code.expect("validated status code");
4635 response_status = status.clone();
4636 let mut length = None;
4637 let mut chunked = false;
4638 loop {
4639 let line = http_line(&mut reader, &mut budget).await?;
4640 if line.is_empty() {
4641 break;
4642 }
4643 if let Some((name, value)) = line.split_once(':') {
4644 if name.eq_ignore_ascii_case("content-length") {
4645 length = Some(value.trim().parse::<u64>().map_err(|_| {
4646 HealthProbeError::bad_answer("invalid HTTP Content-Length")
4647 })?);
4648 } else if name.eq_ignore_ascii_case("transfer-encoding") {
4649 chunked = value.trim().eq_ignore_ascii_case("chunked");
4650 }
4651 }
4652 }
4653 if chunked {
4654 while body.len() < 200 {
4655 let line = http_line(&mut reader, &mut budget).await?;
4656 let size = u64::from_str_radix(line.split(';').next().unwrap_or("").trim(), 16)
4657 .map_err(|_| HealthProbeError::bad_answer("invalid HTTP chunk size"))?;
4658 if size == 0 {
4659 break;
4660 }
4661 let count = size.min((200 - body.len()) as u64) as usize;
4662 let start = body.len();
4663 (&mut reader)
4664 .take(count as u64)
4665 .read_to_end(&mut body)
4666 .await
4667 .map_err(|error| {
4668 HealthProbeError::no_answer(format!("HTTP body read failed: {error}"))
4669 })?;
4670 if body.len() - start != count {
4671 return Err(HealthProbeError::bad_answer("truncated HTTP chunk"));
4672 }
4673 if size > count as u64 || body.len() == 200 {
4674 break;
4675 }
4676 if !http_line(&mut reader, &mut budget).await?.is_empty() {
4677 return Err(HealthProbeError::bad_answer("invalid HTTP chunk delimiter"));
4678 }
4679 }
4680 } else {
4681 reader
4682 .take(length.unwrap_or(200).min(200))
4683 .read_to_end(&mut body)
4684 .await
4685 .map_err(|error| {
4686 HealthProbeError::no_answer(format!("HTTP body read failed: {error}"))
4687 })?;
4688 }
4689 if (200..300).contains(&code) {
4690 Ok(HealthReport::ok())
4691 } else {
4692 Err(HealthProbeError::bad_answer(
4693 "HTTP health endpoint returned non-2xx",
4694 ))
4695 }
4696 };
4697 let mut result = timeout(deadline, probe).await.unwrap_or_else(|_| {
4698 Err(HealthProbeError::no_answer(format!(
4699 "HTTP probe timed out after {deadline:?}"
4700 )))
4701 });
4702 if let Err(error) = &mut result {
4703 if !response_status.is_empty() {
4704 error.message = format!(
4705 "{}; {response_status}: {}",
4706 error.message,
4707 String::from_utf8_lossy(&body)
4708 );
4709 }
4710 }
4711 result
4712}
4713
4714async fn http_line(
4715 reader: &mut tokio::io::BufReader<tokio::net::TcpStream>,
4716 remaining: &mut usize,
4717) -> Result<String, HealthProbeError> {
4718 use tokio::io::{AsyncBufReadExt, AsyncReadExt};
4719 let mut line = Vec::new();
4720 (&mut *reader)
4721 .take(*remaining as u64)
4722 .read_until(b'\n', &mut line)
4723 .await
4724 .map_err(|error| {
4725 HealthProbeError::no_answer(format!("HTTP header read failed: {error}"))
4726 })?;
4727 *remaining -= line.len();
4728 if !line.ends_with(b"\r\n") {
4729 return Err(HealthProbeError::bad_answer(
4730 "HTTP headers are incomplete or exceed 16 KiB",
4731 ));
4732 }
4733 line.truncate(line.len() - 2);
4734 String::from_utf8(line).map_err(|_| HealthProbeError::bad_answer("HTTP header is not UTF-8"))
4735}
4736
4737async fn probe_module_health(
4738 module_id: &str,
4739 runtime: &SupervisorRuntimeConfig,
4740 drain_deadline: Option<Instant>,
4741) -> Result<HealthReport, HealthProbeError> {
4742 let Some(forwarding) = runtime.forwarding.as_ref() else {
4743 return Err(HealthProbeError::misconfigured(
4744 "supervisor was not configured with a forwarding table",
4745 ));
4746 };
4747 let probe_started_at = Instant::now();
4748 let mut deadline = probe_started_at + runtime.health.deadline;
4749 if let Some(drain_deadline) = drain_deadline {
4750 deadline = deadline.min(drain_deadline);
4751 }
4752 let pending = if drain_deadline.is_some() {
4753 forwarding.begin_drain_health_probe_rpc_for(
4754 module_id,
4755 MODULE_CONTROL_OP_HEALTH_CHECK,
4756 probe_started_at,
4757 deadline,
4758 )
4759 } else {
4760 forwarding.begin_health_probe_rpc_for(
4761 module_id,
4762 MODULE_CONTROL_OP_HEALTH_CHECK,
4763 probe_started_at,
4764 deadline,
4765 )
4766 }
4767 .map_err(|err| {
4768 HealthProbeError::lane_dead(format!("failed to begin health.check RPC: {err}"))
4771 })?;
4772 await_health_probe(forwarding, pending, deadline, runtime.health.deadline).await
4773}
4774
4775async fn probe_endpoint_health(
4782 endpoint: crate::ModuleEndpointId,
4783 runtime: &SupervisorRuntimeConfig,
4784 deadline_cap: Option<Instant>,
4785) -> Result<HealthReport, HealthProbeError> {
4786 let Some(forwarding) = runtime.forwarding.as_ref() else {
4787 return Err(HealthProbeError::misconfigured(
4788 "supervisor was not configured with a forwarding table",
4789 ));
4790 };
4791 let probe_started_at = Instant::now();
4792 let mut deadline = probe_started_at + runtime.health.deadline;
4793 if let Some(cap) = deadline_cap {
4794 deadline = deadline.min(cap);
4795 }
4796 let pending = forwarding
4797 .begin_endpoint_health_probe_rpc_for(
4798 endpoint,
4799 MODULE_CONTROL_OP_HEALTH_CHECK,
4800 probe_started_at,
4801 deadline,
4802 )
4803 .map_err(|err| {
4804 HealthProbeError::lane_dead(format!("failed to begin health.check RPC: {err}"))
4805 })?;
4806 await_health_probe(forwarding, pending, deadline, runtime.health.deadline).await
4807}
4808
4809async fn await_health_probe(
4811 forwarding: &ForwardingTable,
4812 pending: PendingModuleControlRpc,
4813 deadline: Instant,
4814 probe_budget: Duration,
4815) -> Result<HealthReport, HealthProbeError> {
4816 let PendingModuleControlRpc {
4817 endpoint,
4818 module_sink,
4819 negotiated_ver,
4820 corr,
4821 receiver,
4822 } = pending;
4823 let body = serde_json::to_vec(&ModuleControlRequest::HealthCheck {}).map_err(|err| {
4824 HealthProbeError::misconfigured(format!("failed to encode health.check: {err}"))
4825 })?;
4826 let frame = Frame::build_with_version(
4827 negotiated_ver,
4828 FrameType::Request,
4829 control_flags(),
4830 0,
4831 0,
4832 corr,
4833 body,
4834 )
4835 .map_err(|err| {
4836 HealthProbeError::misconfigured(format!("failed to build health.check frame: {err}"))
4837 })?;
4838
4839 match timeout_at(deadline, module_sink.send(frame)).await {
4845 Ok(Ok(())) => {}
4846 Ok(Err(err)) => {
4847 let _ = forwarding.cancel_module_control_rpc(endpoint, corr);
4848 return Err(HealthProbeError::lane_dead(format!(
4851 "failed to send health.check: {err}"
4852 )));
4853 }
4854 Err(_elapsed) => {
4855 let _ = forwarding.cancel_module_control_rpc(endpoint, corr);
4856 return Err(HealthProbeError::no_answer(
4860 "health.check send timed out before enqueue (module egress full)",
4861 ));
4862 }
4863 }
4864
4865 match timeout_at(deadline, receiver).await {
4866 Ok(Ok(ModuleControlRpcOutcome::Response(response))) => {
4870 response.health_report().ok_or_else(|| {
4871 HealthProbeError::bad_answer("health.check RPC returned a non-health response")
4872 })
4873 }
4874 Ok(Ok(ModuleControlRpcOutcome::Rejected(body))) => Err(HealthProbeError::bad_answer(
4875 format!("health.check rejected: {}", body.message),
4876 )),
4877 Ok(Ok(ModuleControlRpcOutcome::ModuleGone(message))) => {
4878 Err(HealthProbeError::lane_dead(message))
4879 }
4880 Ok(Ok(ModuleControlRpcOutcome::MalformedResponse(message))) => {
4881 Err(HealthProbeError::bad_answer(message))
4882 }
4883 Ok(Ok(ModuleControlRpcOutcome::UnexpectedOp { expected, actual })) => {
4884 Err(HealthProbeError::bad_answer(format!(
4885 "expected module-control op '{expected}', got '{actual}'"
4886 )))
4887 }
4888 Ok(Ok(ModuleControlRpcOutcome::DeadlineElapsed)) => Err(HealthProbeError::bad_answer(
4892 "module answered health.check after its daemon deadline",
4893 )),
4894 Ok(Err(_)) => Err(HealthProbeError::misconfigured(
4895 "health.check waiter was canceled before the module responded",
4896 )),
4897 Err(_) => {
4898 let _ = forwarding.tombstone_health_probe_rpc(endpoint, corr);
4899 Err(HealthProbeError::no_answer(format!(
4900 "module did not answer health.check within {probe_budget:?}"
4901 )))
4902 }
4903 }
4904}
4905
4906#[allow(clippy::too_many_arguments)]
4907async fn handle_health_report(
4908 spec: &ModuleSpec,
4909 runtime: &SupervisorRuntimeConfig,
4910 registry: &Registry,
4911 process_liveness: &SupervisorProcessLiveness,
4912 snapshot: &SharedSnapshot,
4913 child: &mut Option<SupervisedChild>,
4914 report: HealthReport,
4915 now_ms: u64,
4916) {
4917 let status = supervisor_health_status(report.status);
4918 let detail = report.detail.clone();
4919 let metrics = truncate_health_metrics(report.metrics);
4920 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4921 state.health.status = status;
4922 state.health.last_probe_ms = Some(now_ms);
4923 state.health.detail = detail.clone();
4924 state.health.metrics = metrics.clone();
4925 state.health.consecutive_failures = 0;
4926 });
4927
4928 let action = match report.status {
4929 HealthStatus::Ok => return,
4930 HealthStatus::Degraded => runtime.health.on_degraded,
4931 HealthStatus::Failing => runtime.health.on_failing,
4932 };
4933 apply_l3_health_action(
4934 spec,
4935 runtime,
4936 registry,
4937 process_liveness,
4938 snapshot,
4939 child,
4940 status,
4941 detail.as_deref(),
4942 action,
4943 now_ms,
4944 )
4945 .await;
4946}
4947
4948#[allow(clippy::too_many_arguments)]
4949async fn handle_health_probe_failure(
4950 spec: &ModuleSpec,
4951 runtime: &SupervisorRuntimeConfig,
4952 registry: &Registry,
4953 process_liveness: &SupervisorProcessLiveness,
4954 snapshot: &SharedSnapshot,
4955 child: &mut Option<SupervisedChild>,
4956 err: HealthProbeError,
4957 now_ms: u64,
4958) {
4959 let threshold = runtime.health.failure_threshold.max(1);
4960 let mut failures = 0;
4961 let detail = format!("[{}] {err}", err.label());
4966 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4967 if state.spawned_protocol.unwrap_or(spec.protocol) == ModuleProtocol::Subc {
4970 state.health.status = SupervisorHealthStatus::Unknown;
4971 }
4972 state.health.last_probe_ms = Some(now_ms);
4973 state.health.consecutive_failures = state.health.consecutive_failures.saturating_add(1);
4974 state.health.detail = Some(detail.clone());
4975 state.health.metrics = None;
4976 failures = state.health.consecutive_failures;
4977 });
4978
4979 if failures < threshold {
4980 warn!(
4981 module_id = %spec.module_id,
4982 consecutive_failures = failures,
4983 threshold,
4984 evidence = err.label(),
4985 detail = %detail,
4986 "health.check probe failed"
4987 );
4988 return;
4989 }
4990
4991 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4992 state.state = ModuleState::Unresponsive;
4993 state.health.status = SupervisorHealthStatus::Unresponsive;
4994 });
4995 if runtime.health.critical {
4999 error!(
5000 module_id = %spec.module_id,
5001 status = "unresponsive",
5002 evidence = err.label(),
5003 detail = %detail,
5004 "critical module health alert"
5005 );
5006 } else {
5007 warn!(
5008 module_id = %spec.module_id,
5009 status = "unresponsive",
5010 evidence = err.label(),
5011 detail = %detail,
5012 "module health threshold breached"
5013 );
5014 }
5015 if let Err(err) = health_restart_child(
5016 spec,
5017 runtime,
5018 registry,
5019 process_liveness,
5020 snapshot,
5021 child,
5022 SupervisorHealthStatus::Unresponsive,
5023 Some(&detail),
5024 now_ms,
5025 )
5026 .await
5027 {
5028 error!(module_id = %spec.module_id, error = %err, "health-triggered restart failed");
5029 }
5030}
5031
5032#[allow(clippy::too_many_arguments)]
5033async fn apply_l3_health_action(
5034 spec: &ModuleSpec,
5035 runtime: &SupervisorRuntimeConfig,
5036 registry: &Registry,
5037 process_liveness: &SupervisorProcessLiveness,
5038 snapshot: &SharedSnapshot,
5039 child: &mut Option<SupervisedChild>,
5040 status: SupervisorHealthStatus,
5041 detail: Option<&str>,
5042 action: HealthAction,
5043 now_ms: u64,
5044) {
5045 record_health_action(snapshot, &spec.module_id, action.to_string(), now_ms);
5046 match action {
5047 HealthAction::Report => {
5048 info!(
5049 module_id = %spec.module_id,
5050 status = ?status,
5051 detail,
5052 "module reported non-ok health"
5053 );
5054 }
5055 HealthAction::Alert => {
5056 error!(
5057 module_id = %spec.module_id,
5058 status = ?status,
5059 detail,
5060 "module health alert"
5061 );
5062 }
5063 HealthAction::Restart => {
5064 if let Err(err) = health_restart_child(
5065 spec,
5066 runtime,
5067 registry,
5068 process_liveness,
5069 snapshot,
5070 child,
5071 status,
5072 detail,
5073 now_ms,
5074 )
5075 .await
5076 {
5077 error!(module_id = %spec.module_id, error = %err, "health-triggered restart failed");
5078 }
5079 }
5080 }
5081}
5082
5083#[allow(clippy::too_many_arguments)]
5084async fn health_restart_child(
5085 spec: &ModuleSpec,
5086 runtime: &SupervisorRuntimeConfig,
5087 registry: &Registry,
5088 process_liveness: &SupervisorProcessLiveness,
5089 snapshot: &SharedSnapshot,
5090 child: &mut Option<SupervisedChild>,
5091 status: SupervisorHealthStatus,
5092 detail: Option<&str>,
5093 now_ms: u64,
5094) -> Result<(), SuperviseError> {
5095 let (enabled, schedule) = {
5096 let mut state = lock_snapshot(snapshot)?;
5097 let enabled = state.enabled;
5098 let schedule = if enabled {
5099 state.next_crash_restart(&runtime.restart_policy, Instant::now())
5100 } else {
5101 None
5102 };
5103 (enabled, schedule)
5104 };
5105
5106 if !enabled {
5107 return Err(SuperviseError::Disabled {
5108 module_id: spec.module_id.clone(),
5109 });
5110 }
5111
5112 if schedule.is_none() {
5113 record_health_action(snapshot, &spec.module_id, "failed".to_string(), now_ms);
5114 error!(
5115 module_id = %spec.module_id,
5116 status = ?status,
5117 detail,
5118 max_restarts = runtime.restart_policy.max_restarts,
5119 window_secs = runtime.restart_policy.window.as_secs(),
5120 reason = %runtime.restart_policy.budget_exhausted_detail(),
5121 "health restart budget exhausted; marking module failed"
5122 );
5123 let stop_notice = begin_forwarding_drain_if_configured(
5124 spec,
5125 runtime,
5126 registry,
5127 snapshot,
5128 Some(true),
5129 RouteCloseReason::Disable,
5130 )
5131 .await?;
5132 update_snapshot(snapshot, Some(&spec.module_id), |state| {
5133 state.drain_disposition_detail = Some(runtime.restart_policy.budget_exhausted_detail());
5134 })?;
5135 drain_optional_child(
5136 &spec.module_id,
5137 spec.protocol,
5138 stop_notice,
5139 registry,
5140 runtime.forwarding.as_deref(),
5141 snapshot,
5142 &runtime.terminal_ring,
5143 &runtime.spawn_events,
5144 child,
5145 runtime.drain_timeout,
5146 ModuleState::Failed,
5147 Some(true),
5148 )
5149 .await?;
5150 process_liveness.untrack_if_current(&spec.module_id, snapshot);
5151 return Ok(());
5152 }
5153
5154 let schedule = schedule.expect("a health restart must have a crash-restart schedule");
5155 let mut restart_count = 0;
5156 update_snapshot(snapshot, Some(&spec.module_id), |state| {
5157 restart_count = state.crash_restarts.len();
5158 state.state = ModuleState::Unresponsive;
5159 state.health.status = status;
5160 state.health.last_action = Some(HealthAction::Restart.to_string());
5161 state.health.last_action_ms = Some(now_ms);
5162 })?;
5163 warn!(
5164 module_id = %spec.module_id,
5165 status = ?status,
5166 detail,
5167 restart_count,
5168 restart_in_window = schedule.restart_in_window,
5169 delay_ms = schedule.delay.as_millis() as u64,
5170 "health-triggered module restart"
5171 );
5172
5173 let stop_notice = begin_forwarding_drain_if_configured(
5174 spec,
5175 runtime,
5176 registry,
5177 snapshot,
5178 Some(true),
5179 RouteCloseReason::Restart,
5180 )
5181 .await?;
5182 drain_optional_child(
5183 &spec.module_id,
5184 spec.protocol,
5185 stop_notice,
5186 registry,
5187 runtime.forwarding.as_deref(),
5188 snapshot,
5189 &runtime.terminal_ring,
5190 &runtime.spawn_events,
5191 child,
5192 runtime.drain_timeout,
5193 ModuleState::Restarting,
5194 Some(true),
5195 )
5196 .await?;
5197 schedule_respawn(
5198 runtime,
5199 snapshot,
5200 &spec.module_id,
5201 schedule.delay,
5202 RespawnKind::Spawn,
5203 )
5204}
5205
5206fn cancel_deferred_reload(runtime: &SupervisorRuntimeConfig, module_id: &str, reason: &str) {
5207 if let Some(reply) = runtime
5208 .deferred_reload_reply
5209 .lock()
5210 .unwrap_or_else(|p| p.into_inner())
5211 .take()
5212 {
5213 let _ = reply.send(Err(SuperviseError::ReloadFailed {
5214 module_id: module_id.to_string(),
5215 reason: reason.to_string(),
5216 }));
5217 }
5218}
5219
5220fn schedule_respawn(
5221 runtime: &SupervisorRuntimeConfig,
5222 snapshot: &SharedSnapshot,
5223 module_id: &str,
5224 delay: Duration,
5225 kind: RespawnKind,
5226) -> Result<(), SuperviseError> {
5227 cancel_deferred_reload(runtime, module_id, "respawn superseded by another restart");
5228 update_snapshot(snapshot, Some(module_id), |state| {
5229 state.respawn_pending = true
5230 })?;
5231 *runtime
5232 .scheduled_respawn
5233 .lock()
5234 .unwrap_or_else(|p| p.into_inner()) = Some(PendingRespawn {
5235 deadline: Instant::now() + delay,
5236 kind,
5237 });
5238 Ok(())
5239}
5240
5241fn record_health_action(snapshot: &SharedSnapshot, module_id: &str, action: String, now_ms: u64) {
5242 let _ = update_snapshot(snapshot, Some(module_id), |state| {
5243 state.health.last_action = Some(action);
5244 state.health.last_action_ms = Some(now_ms);
5245 });
5246}
5247
5248fn supervisor_health_status(status: HealthStatus) -> SupervisorHealthStatus {
5249 match status {
5250 HealthStatus::Ok => SupervisorHealthStatus::Ok,
5251 HealthStatus::Degraded => SupervisorHealthStatus::Degraded,
5252 HealthStatus::Failing => SupervisorHealthStatus::Failing,
5253 }
5254}
5255
5256fn truncate_health_metrics(metrics: Option<Value>) -> Option<Value> {
5268 let metrics = metrics?;
5269 match serde_json::to_vec(&metrics) {
5270 Ok(encoded) if encoded.len() > MAX_HEALTH_METRICS_BYTES => Some(serde_json::json!({
5271 "truncated": true,
5272 "original_bytes": encoded.len(),
5273 })),
5274 Ok(_) | Err(_) => Some(metrics),
5275 }
5276}
5277
5278fn jittered_health_delay(module_id: &str, probe_index: u64, cadence: Duration) -> Duration {
5284 if cadence.is_zero() {
5285 return Duration::ZERO;
5286 }
5287 let cadence_ms = cadence.as_millis() as u64;
5288 if cadence_ms == 0 {
5304 return cadence;
5305 }
5306 let jitter_span = (cadence_ms / 10).max(1);
5321 let hash = module_id.as_bytes().iter().fold(
5322 probe_index.wrapping_mul(0x9E37_79B9_7F4A_7C15),
5323 |acc, byte| {
5324 acc.wrapping_mul(1099511628211)
5325 .wrapping_add(u64::from(*byte))
5326 },
5327 );
5328 cadence + Duration::from_millis(hash % jitter_span)
5329}
5330
5331#[cfg(test)]
5332mod tests {
5333 use super::*;
5334
5335 #[test]
5336 fn readding_a_module_clears_its_rescan_removal_tombstone() {
5337 let handle = SupervisorHandle::new();
5338 let module_id = "readded-tombstone";
5339 handle.record_rescan_removal(module_id);
5340 assert!(handle.removal_tombstone_age_ms(module_id).is_some());
5341
5342 handle.apply_identity_configuration(&ModuleSpec {
5343 module_id: module_id.to_string(),
5344 program: PathBuf::from("/test/module"),
5345 args: Vec::new(),
5346 env: Vec::new(),
5347 reserved: false,
5348 reserved_prefixes: Vec::new(),
5349 protocol: ModuleProtocol::Subc,
5350 overlap: Default::default(),
5351 });
5352
5353 assert!(
5354 handle.removal_tombstone_age_ms(module_id).is_none(),
5355 "a re-added module must not retain a stale removal tombstone"
5356 );
5357 }
5358
5359 #[derive(Debug, PartialEq, Eq)]
5362 struct OwnerInSpawnWindow {
5363 module_id: String,
5364 configured: bool,
5365 on_roster: bool,
5366 admission_refusal: Option<&'static str>,
5367 }
5368
5369 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5380 async fn a_new_module_is_configured_before_its_first_process_can_register() {
5381 use crate::scopes::ScopeTable;
5382 use subc_protocol::{error_codes, scope::ScopeSelector, Principal};
5383
5384 let dir = cortexkit_test_support::ScratchDir::new("configured-before-spawn");
5385 let stub = |module_id: &str, program: PathBuf| ModuleSpec {
5386 module_id: module_id.to_string(),
5387 program,
5388 args: Vec::new(),
5389 env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
5390 .into_iter()
5391 .map(|key| (key.to_string(), dir.path().display().to_string()))
5392 .chain([("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string())])
5393 .collect(),
5394 reserved: false,
5395 reserved_prefixes: Vec::new(),
5396 protocol: ModuleProtocol::Subc,
5397 overlap: Default::default(),
5398 };
5399 let live = super::terminal_history_tests::fake_aft_stub_path();
5400 let missing = dir.path().join("definitely-missing-module");
5401
5402 let handle = SupervisorHandle::new();
5403 let mut supervisor =
5404 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5405 .with_handle(handle.clone());
5406 let observed = Arc::new(Mutex::new(Vec::<OwnerInSpawnWindow>::new()));
5407 let hook_handle = handle.clone();
5408 let hook_observed = Arc::clone(&observed);
5409 supervisor.test_after_first_spawn = AfterFirstSpawnHook(Some(Arc::new(move |module_id| {
5410 let configured = hook_handle.is_configured(module_id);
5413 let selector = ScopeSelector {
5414 owner: Principal::Reserved {
5415 module_id: module_id.to_string(),
5416 },
5417 scope_ref: "s".to_string(),
5418 scope_epoch: Some(1),
5419 };
5420 let carrier = Principal::Reserved {
5421 module_id: "carrier".to_string(),
5422 };
5423 let admission_refusal = match ScopeTable::new(Vec::<String>::new())
5424 .admit(&carrier, module_id, &selector, configured)
5425 {
5426 Ok(_) => None,
5427 Err(refusal) => Some(refusal.code),
5428 };
5429 hook_observed.lock().unwrap().push(OwnerInSpawnWindow {
5430 module_id: module_id.to_string(),
5431 configured,
5432 on_roster: hook_handle.get(module_id).is_some(),
5433 admission_refusal,
5434 });
5435 })));
5436
5437 let plain = supervisor.spawn(stub("plain", live.clone())).unwrap();
5438 let configured = supervisor
5439 .supervise_configured(stub("configured", live.clone()), true)
5440 .unwrap();
5441 let with_health = supervisor
5442 .supervise_configured_with_health(
5443 stub("with-health", live.clone()),
5444 true,
5445 HealthConfig::default(),
5446 None,
5447 RestartPolicy::default(),
5448 )
5449 .unwrap();
5450 let failed = supervisor
5453 .supervise_configured_with_health(
5454 stub("failed-spawn", missing.clone()),
5455 true,
5456 HealthConfig::default(),
5457 None,
5458 RestartPolicy::default(),
5459 )
5460 .unwrap();
5461 assert!(supervisor.spawn(stub("spawn-error", missing)).is_err());
5464
5465 let expected = [
5466 "plain",
5467 "configured",
5468 "with-health",
5469 "failed-spawn",
5470 "spawn-error",
5471 ]
5472 .into_iter()
5473 .map(|module_id| OwnerInSpawnWindow {
5474 module_id: module_id.to_string(),
5475 configured: true,
5476 on_roster: false,
5477 admission_refusal: Some(error_codes::SCOPE_NOT_SYNCED),
5478 })
5479 .collect::<Vec<_>>();
5480 assert_eq!(*observed.lock().unwrap(), expected);
5481
5482 for module_id in ["plain", "configured", "with-health", "failed-spawn"] {
5483 assert!(
5484 handle.get(module_id).is_some(),
5485 "{module_id} is on the roster"
5486 );
5487 assert!(
5488 handle.is_configured(module_id),
5489 "{module_id} stays configured"
5490 );
5491 }
5492 assert!(handle.get("spawn-error").is_none());
5493 assert!(
5494 !handle.is_configured("spawn-error"),
5495 "a plain spawn that failed must not leave its module marked configured"
5496 );
5497
5498 handle.retire("failed-spawn");
5500 assert!(!handle.is_configured("failed-spawn"));
5501
5502 for module in [plain, configured, with_health] {
5503 module.stop().await.unwrap();
5504 }
5505 drop(failed);
5506 }
5507
5508 fn stale_process_snapshot(state: ModuleState, enabled: bool) -> SharedSnapshot {
5509 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(state, enabled)));
5510 update_snapshot(&snapshot, Some("stale-process-facts"), |snapshot| {
5511 snapshot.process_alive = true;
5512 snapshot.pid = Some(41);
5513 snapshot.spawned_at_ms = Some(42);
5514 snapshot.spawned_from = Some(PathBuf::from("/spawned/module"));
5515 snapshot.spawned_file_identity = Some(SpawnedFileIdentity {
5516 device: 43,
5517 inode: 44,
5518 #[cfg(windows)]
5519 windows_image: None,
5520 });
5521 })
5522 .unwrap();
5523 snapshot
5524 }
5525
5526 fn assert_snapshot_process_facts_cleared(snapshot: &SharedSnapshot) {
5527 let snapshot = lock_snapshot(snapshot).unwrap();
5528 assert!(!snapshot.process_alive);
5529 assert_eq!(snapshot.pid, None);
5530 assert_eq!(snapshot.spawned_at_ms, None);
5531 assert_eq!(snapshot.spawned_from, None);
5532 assert_eq!(snapshot.spawned_file_identity, None);
5533 }
5534
5535 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5536 async fn failed_enable_spawn_clears_preexisting_current_process_facts() {
5537 let supervisor =
5538 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5539 let mut runtime = supervisor.runtime_config();
5540 runtime.test_seed_stale_facts_before_enable_spawn = true;
5541 let snapshot = stale_process_snapshot(ModuleState::Disabled, false);
5542 let mut child = None;
5543 let spec = ModuleSpec {
5544 module_id: "failed-enable-clears-facts".to_string(),
5545 program: PathBuf::from("/definitely/missing/failed-enable-module"),
5546 args: Vec::new(),
5547 env: Vec::new(),
5548 reserved: false,
5549 reserved_prefixes: Vec::new(),
5550 protocol: ModuleProtocol::Subc,
5551 overlap: Default::default(),
5552 };
5553
5554 let result = set_child_enabled(
5555 &spec,
5556 &runtime,
5557 &supervisor.registry,
5558 &supervisor.process_liveness,
5559 &snapshot,
5560 &mut child,
5561 true,
5562 )
5563 .await;
5564
5565 assert!(matches!(result, Err(SuperviseError::Spawn { .. })));
5566 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
5567 assert_snapshot_process_facts_cleared(&snapshot);
5568 }
5569
5570 #[tokio::test]
5571 async fn start_revives_stranded_restarting_but_not_pending_backoff() {
5572 let supervisor =
5573 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5574 let runtime = supervisor.runtime_config();
5575 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(
5576 ModuleState::Restarting,
5577 true,
5578 )));
5579 let spec = ModuleSpec {
5580 module_id: "start-stranded-restarting".to_string(),
5581 program: super::terminal_history_tests::fake_aft_stub_path(),
5582 args: Vec::new(),
5583 env: vec![("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string())],
5584 reserved: false,
5585 reserved_prefixes: Vec::new(),
5586 protocol: ModuleProtocol::None,
5587 overlap: Default::default(),
5588 };
5589 let mut child = None;
5590 lock_snapshot(&snapshot).unwrap().respawn_pending = true;
5591 assert!(!super::set_child_enabled(
5592 &spec,
5593 &runtime,
5594 &Registry::default(),
5595 &supervisor.process_liveness,
5596 &snapshot,
5597 &mut child,
5598 true
5599 )
5600 .await
5601 .unwrap());
5602 assert!(child.is_none());
5603 lock_snapshot(&snapshot).unwrap().respawn_pending = false;
5604 assert!(super::set_child_enabled(
5605 &spec,
5606 &runtime,
5607 &Registry::default(),
5608 &supervisor.process_liveness,
5609 &snapshot,
5610 &mut child,
5611 true
5612 )
5613 .await
5614 .unwrap());
5615 assert_eq!(
5616 lock_snapshot(&snapshot).unwrap().state,
5617 ModuleState::Running
5618 );
5619 let mut child = child.unwrap();
5620 child.start_kill().unwrap();
5621 child.wait().await.unwrap();
5622 }
5623
5624 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5625 async fn failed_reload_spawn_clears_current_process_facts() {
5626 let supervisor =
5627 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5628 let mut runtime = supervisor.runtime_config();
5629 runtime.restart_policy = RestartPolicy::new(0, Duration::ZERO);
5630 let snapshot = stale_process_snapshot(ModuleState::Running, true);
5631 let mut child = None;
5632 let spec = ModuleSpec {
5633 module_id: "failed-reload-clears-facts".to_string(),
5634 program: PathBuf::from("/unused/failed-reload-module"),
5635 args: Vec::new(),
5636 env: Vec::new(),
5637 reserved: false,
5638 reserved_prefixes: Vec::new(),
5639 protocol: ModuleProtocol::Subc,
5640 overlap: Default::default(),
5641 };
5642
5643 let result = handle_reload_spawn_failure(
5644 &spec,
5645 &runtime,
5646 &supervisor.process_liveness,
5647 &snapshot,
5648 &mut child,
5649 "forced reload spawn failure".to_string(),
5650 )
5651 .await;
5652
5653 assert!(matches!(result, Err(SuperviseError::ReloadFailed { .. })));
5654 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
5655 assert_snapshot_process_facts_cleared(&snapshot);
5656 }
5657
5658 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5659 async fn dropping_a_module_with_an_active_monitor_clears_current_process_facts() {
5660 let supervisor =
5661 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5662 let snapshot = stale_process_snapshot(ModuleState::Running, true);
5663 let module = supervisor.supervised_module(
5664 ModuleSpec {
5665 module_id: "drop-clears-facts".to_string(),
5666 program: PathBuf::from("/unused/drop-module"),
5667 args: Vec::new(),
5668 env: Vec::new(),
5669 reserved: false,
5670 reserved_prefixes: Vec::new(),
5671 protocol: ModuleProtocol::Subc,
5672 overlap: Default::default(),
5673 },
5674 supervisor.runtime_config(),
5675 Arc::clone(&snapshot),
5676 None,
5677 );
5678 assert!(!module
5679 .inner
5680 .monitor
5681 .lock()
5682 .unwrap()
5683 .as_ref()
5684 .unwrap()
5685 .is_finished());
5686
5687 drop(module);
5688
5689 assert_eq!(
5690 lock_snapshot(&snapshot).unwrap().state,
5691 ModuleState::Stopped
5692 );
5693 assert_snapshot_process_facts_cleared(&snapshot);
5694 }
5695
5696 #[cfg(unix)]
5697 #[tokio::test]
5698 async fn rescan_preserves_running_protocol_until_respawn() {
5699 let dir = cortexkit_test_support::ScratchDir::new("rescan-protocol");
5700 let initial = ModuleSpec {
5701 module_id: "rescan-protocol".into(),
5702 program: PathBuf::from("/bin/sleep"),
5703 args: vec!["60".into()],
5704 env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
5705 .into_iter()
5706 .map(|key| (key.into(), dir.path().to_string_lossy().into_owned()))
5707 .collect(),
5708 reserved: false,
5709 reserved_prefixes: vec![],
5710 protocol: ModuleProtocol::None,
5711 overlap: Default::default(),
5712 };
5713 let supervisor =
5714 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5715 let module = supervisor.spawn(initial.clone()).unwrap();
5716 assert!(module.status().unwrap().live);
5717 let mut next = initial;
5718 next.protocol = ModuleProtocol::Subc;
5719 module
5720 .update_configuration(next.clone(), HealthConfig::default(), None)
5721 .await
5722 .unwrap();
5723 assert!(
5724 module.status().unwrap().live,
5725 "rescan must not require HELLO from the old non-wire process"
5726 );
5727 let runtime = supervisor.runtime_config();
5728 let action = on_child_exit(
5729 &next,
5730 RestartPolicy::default(),
5731 &supervisor.registry,
5732 &module.inner.snapshot,
5733 &runtime.terminal_ring,
5734 &runtime.spawn_events,
5735 &runtime.child_roster,
5736 ExitReport {
5737 kind: ExitKind::Clean,
5738 code: Some(0),
5739 signal: None,
5740 at_ms: unix_ms_now(),
5741 },
5742 )
5743 .await;
5744 assert!(
5745 matches!(action, NextAction::Restart { .. }),
5746 "the old non-wire process's clean exit must restart"
5747 );
5748 module.drain().await.unwrap();
5749 }
5750
5751 #[cfg(unix)]
5752 #[tokio::test]
5753 async fn orphan_identity_matches_path_names_and_shebang_interpreters() {
5754 use std::os::unix::fs::PermissionsExt;
5755 let dir = cortexkit_test_support::ScratchDir::new("spawn-image-identity");
5756 let script = dir.join("module.sh");
5757 std::fs::write(&script, "#!/bin/sh\nwhile :; do sleep 0.1; done\n").unwrap();
5758 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o700)).unwrap();
5759 let record_path = dir.join("live-children.json");
5760 let supervisor =
5761 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5762 .with_live_children_record(&record_path);
5763 for (program, args) in [
5764 (PathBuf::from("sleep"), vec!["60".into()]),
5765 (script, vec![]),
5766 ] {
5767 let spec = ModuleSpec {
5768 module_id: "image-identity".into(),
5769 program: program.clone(),
5770 args,
5771 env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
5772 .into_iter()
5773 .map(|key| (key.into(), dir.to_string_lossy().into_owned()))
5774 .collect(),
5775 reserved: false,
5776 reserved_prefixes: vec![],
5777 protocol: ModuleProtocol::None,
5778 overlap: Default::default(),
5779 };
5780 let module = supervisor.spawn(spec).unwrap();
5781 #[cfg(target_os = "macos")]
5782 {
5783 let deadline = Instant::now() + Duration::from_secs(5);
5786 while crate::live_children::read_record(&record_path)
5787 .unwrap()
5788 .iter()
5789 .all(|entry| entry.executable.is_none())
5790 {
5791 assert!(Instant::now() < deadline, "module image was not confirmed");
5792 tokio::time::sleep(Duration::from_millis(5)).await;
5793 }
5794 }
5795 let entry = crate::live_children::read_record(&record_path)
5796 .unwrap()
5797 .pop()
5798 .unwrap();
5799 let observed = subc_os::Process::open(entry.pid)
5800 .unwrap()
5801 .unwrap()
5802 .observe()
5803 .unwrap();
5804 let verdict = crate::live_children::identity_verdict(&entry, entry.pid, &observed);
5805 module.drain().await.unwrap();
5806 assert_eq!(
5807 verdict,
5808 crate::live_children::IdentityVerdict::Matches,
5809 "program {program:?}: recorded {entry:?}, observed {observed:?}"
5810 );
5811 }
5812 }
5813
5814 #[cfg(unix)]
5815 fn http_fixture(
5816 dir: &std::path::Path,
5817 url: &str,
5818 threshold: u32,
5819 ) -> crate::daemon_config::ConfiguredModule {
5820 let path = dir.join("subc.jsonc");
5821 std::fs::write(&path, serde_json::json!({"version": 1, "modules": {"http-process": {
5822 "program": "/bin/sleep", "args": ["60"], "protocol": "none",
5823 "env": {"XDG_DATA_HOME":dir,"XDG_RUNTIME_DIR":dir,"XDG_CONFIG_HOME":dir},
5824 "health": {"http":url,"cadence_ms":20,"deadline_ms":200,"failure_threshold":threshold},
5825 "restart": {"backoff_ms":1,"max_backoff_ms":1}, "drain_timeout_ms":10
5826 }}}).to_string()).unwrap();
5827 crate::daemon_config::load(&path)
5828 .unwrap()
5829 .unwrap()
5830 .modules
5831 .pop()
5832 .unwrap()
5833 }
5834
5835 #[cfg(unix)]
5836 async fn wait_http_health(module: &SupervisedModule, status: SupervisorHealthStatus) {
5837 timeout(Duration::from_secs(5), async {
5838 loop {
5839 if module.status().unwrap().health.status == status {
5840 break;
5841 }
5842 sleep(Duration::from_millis(5)).await;
5843 }
5844 })
5845 .await
5846 .unwrap_or_else(|_| {
5847 panic!(
5848 "expected {status:?}, got {:?}",
5849 module.status().unwrap().health
5850 )
5851 });
5852 }
5853
5854 #[cfg(unix)]
5855 #[tokio::test]
5856 async fn http_health_status_flips_ok_failing_ok() {
5857 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5858 let dir = cortexkit_test_support::ScratchDir::new("http-status-flips");
5859 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5860 let status = Arc::new(std::sync::atomic::AtomicU16::new(200));
5861 let serving_status = status.clone();
5862 let url = format!("http://{}/healthz", listener.local_addr().unwrap());
5863 let server = tokio::spawn(async move {
5864 loop {
5865 let (mut stream, _) = listener.accept().await.unwrap();
5866 let mut request = [0u8; 2048];
5867 let count = stream.read(&mut request).await.unwrap();
5868 assert!(count > 0, "a probe must send an HTTP request");
5869 let code = serving_status.load(std::sync::atomic::Ordering::SeqCst);
5870 let body = if code == 200 {
5871 "ready"
5872 } else {
5873 "{\"status\":\"unavailable\",\"error\":\"scratch failure\"}"
5874 };
5875 let response = format!("HTTP/1.1 {code} scratch\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());
5876 let _ = stream.write_all(response.as_bytes()).await;
5877 }
5878 });
5879 let configured = http_fixture(&dir, &url, 1000);
5880 let module =
5881 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5882 .supervise_configured_with_health(
5883 configured.module_spec(),
5884 true,
5885 configured.health,
5886 configured.drain_timeout_ms,
5887 configured.restart,
5888 )
5889 .unwrap();
5890 wait_http_health(&module, SupervisorHealthStatus::Ok).await;
5891 status.store(503, std::sync::atomic::Ordering::SeqCst);
5892 wait_http_health(&module, SupervisorHealthStatus::Failing).await;
5893 assert!(module
5894 .status()
5895 .unwrap()
5896 .health
5897 .detail
5898 .unwrap()
5899 .contains("scratch failure"));
5900 status.store(200, std::sync::atomic::Ordering::SeqCst);
5901 wait_http_health(&module, SupervisorHealthStatus::Ok).await;
5902 assert_eq!(module.status().unwrap().health.consecutive_failures, 0);
5903 let before = module.status().unwrap();
5904 let (spec, mut health) = module.configuration().unwrap();
5905 health.http = None;
5906 module
5907 .update_configuration(spec.clone(), health.clone(), Some(10))
5908 .await
5909 .unwrap();
5910 wait_http_health(&module, SupervisorHealthStatus::Unknown).await;
5911 health.http = Some(url);
5912 module
5913 .update_configuration(spec, health, Some(10))
5914 .await
5915 .unwrap();
5916 wait_http_health(&module, SupervisorHealthStatus::Ok).await;
5917 assert_eq!(
5918 module.status().unwrap().pid,
5919 before.pid,
5920 "changing a probe must apply live, not restart its process"
5921 );
5922 let (spec, mut health) = module.configuration().unwrap();
5923 health.failure_threshold = 2;
5924 module
5925 .update_configuration(spec, health, Some(10))
5926 .await
5927 .unwrap();
5928 status.store(503, std::sync::atomic::Ordering::SeqCst);
5929 timeout(Duration::from_secs(5), async {
5930 while module.status().unwrap().spawn_generation == before.spawn_generation {
5931 sleep(Duration::from_millis(5)).await;
5932 }
5933 })
5934 .await
5935 .expect("sustained HTTP 503 responses must trigger the health restart policy");
5936 module.drain().await.unwrap();
5937 server.abort();
5938 }
5939
5940 #[cfg(unix)]
5941 #[tokio::test]
5942 async fn http_health_no_listener_restarts_after_consecutive_failures() {
5943 let dir = cortexkit_test_support::ScratchDir::new("http-refused");
5944 let unused = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5945 let url = format!("http://{}/healthz", unused.local_addr().unwrap());
5946 drop(unused);
5947 let configured = http_fixture(&dir, &url, 2);
5948 let module =
5949 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5950 .supervise_configured_with_health(
5951 configured.module_spec(),
5952 true,
5953 configured.health,
5954 configured.drain_timeout_ms,
5955 configured.restart,
5956 )
5957 .unwrap();
5958 let before = module.status().unwrap().spawn_generation;
5959 timeout(Duration::from_secs(5), async {
5960 loop {
5961 let status = module.status().unwrap();
5962 if status.spawn_generation > before {
5963 assert!(status.lifetime_restarts > 0);
5964 break;
5965 }
5966 sleep(Duration::from_millis(5)).await;
5967 }
5968 })
5969 .await
5970 .expect("sustained HTTP refusal must trigger the health restart policy");
5971 module.drain().await.unwrap();
5972 }
5973
5974 #[cfg(unix)]
5975 #[tokio::test]
5976 async fn http_health_timeout_honours_deadline() {
5977 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5978 let url = format!("http://{}/healthz", listener.local_addr().unwrap());
5979 let server = tokio::spawn(async move {
5980 let _held = listener.accept().await.unwrap();
5981 std::future::pending::<()>().await;
5982 });
5983 let error = timeout(
5984 Duration::from_secs(1),
5985 probe_http_health(&url, Duration::from_millis(10)),
5986 )
5987 .await
5988 .expect("the probe must enforce its own deadline")
5989 .unwrap_err();
5990 server.abort();
5991 assert!(error.to_string().contains("timed out"));
5992 }
5993
5994 #[cfg(unix)]
5995 #[tokio::test]
5996 async fn http_health_localhost_reaches_an_ipv6_only_listener() {
5997 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5998 let listener = tokio::net::TcpListener::bind("[::1]:0").await.unwrap();
5999 let url = format!(
6000 "http://localhost:{}/healthz",
6001 listener.local_addr().unwrap().port()
6002 );
6003 let server = tokio::spawn(async move {
6004 let (mut stream, _) = listener.accept().await.unwrap();
6005 let mut request = [0u8; 2048];
6006 assert!(stream.read(&mut request).await.unwrap() > 0);
6007 stream
6008 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
6009 .await
6010 .unwrap();
6011 });
6012 assert_eq!(
6017 probe_http_health(&url, Duration::from_secs(10))
6018 .await
6019 .unwrap()
6020 .status,
6021 HealthStatus::Ok
6022 );
6023 server.await.unwrap();
6024 }
6025
6026 #[cfg(unix)]
6027 #[tokio::test]
6028 async fn http_health_timeout_keeps_partial_status_and_body() {
6029 use tokio::io::{AsyncReadExt, AsyncWriteExt};
6030 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6031 let url = format!("http://{}/healthz", listener.local_addr().unwrap());
6032 let server = tokio::spawn(async move {
6033 let (mut stream, _) = listener.accept().await.unwrap();
6034 let mut request = [0u8; 2048];
6035 assert!(stream.read(&mut request).await.unwrap() > 0);
6036 stream
6037 .write_all(
6038 b"HTTP/1.1 503 Unavailable\r\nContent-Length: 100\r\n\r\npartial diagnostic",
6039 )
6040 .await
6041 .unwrap();
6042 std::future::pending::<()>().await;
6043 });
6044 let error = probe_http_health(&url, Duration::from_secs(1))
6045 .await
6046 .unwrap_err()
6047 .to_string();
6048 server.abort();
6049 assert!(
6050 error.contains("timed out")
6051 && error.contains("503 Unavailable")
6052 && error.contains("partial diagnostic"),
6053 "{error}"
6054 );
6055 }
6056
6057 #[cfg(unix)]
6058 #[tokio::test]
6059 async fn http_health_diagnostic_body_is_bounded_to_200_bytes() {
6060 use tokio::io::{AsyncReadExt, AsyncWriteExt};
6061 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6062 let url = format!("http://{}/healthz", listener.local_addr().unwrap());
6063 let server = tokio::spawn(async move {
6064 let (mut stream, _) = listener.accept().await.unwrap();
6065 let mut request = [0u8; 2048];
6066 assert!(stream.read(&mut request).await.unwrap() > 0);
6067 let body = format!("{}not-in-diagnostic", "x".repeat(400));
6068 let response = format!(
6069 "HTTP/1.1 503 Unavailable\r\nContent-Length: {}\r\n\r\n{body}",
6070 body.len()
6071 );
6072 stream.write_all(response.as_bytes()).await.unwrap();
6073 });
6074 let error = probe_http_health(&url, Duration::from_secs(1))
6075 .await
6076 .unwrap_err()
6077 .to_string();
6078 server.await.unwrap();
6079 assert!(error.ends_with(&"x".repeat(200)), "{error}");
6080 assert_eq!(error.split(": ").last().unwrap().len(), 200);
6081 assert!(!error.contains("not-in-diagnostic"));
6082 }
6083
6084 #[cfg(unix)]
6085 #[tokio::test]
6086 async fn http_health_real_nats_server_monitoring() {
6087 if std::process::Command::new("nats-server")
6088 .arg("--version")
6089 .env("XDG_DATA_HOME", std::env::temp_dir())
6090 .env("XDG_RUNTIME_DIR", std::env::temp_dir())
6091 .env("XDG_CONFIG_HOME", std::env::temp_dir())
6092 .output()
6093 .is_err()
6094 {
6095 eprintln!(
6096 "skipped: http_health_real_nats_server_monitoring (nats-server is not on PATH)"
6097 );
6098 return;
6099 }
6100 let dir = cortexkit_test_support::ScratchDir::new("nats-http-monitoring");
6101 let monitor = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6102 let port = monitor.local_addr().unwrap().port();
6103 let client = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6104 let client_port = client.local_addr().unwrap().port();
6105 let config = dir.join("server.conf");
6106 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();
6107 let mut configured = http_fixture(&dir, &format!("http://127.0.0.1:{port}/healthz"), 1000);
6108 configured.program = PathBuf::from("nats-server");
6109 configured.args = vec!["-c".into(), config.to_string_lossy().into_owned()];
6110 drop(monitor);
6111 drop(client);
6112 let module =
6113 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
6114 .supervise_configured_with_health(
6115 configured.module_spec(),
6116 true,
6117 configured.health,
6118 configured.drain_timeout_ms,
6119 configured.restart,
6120 )
6121 .unwrap();
6122 wait_http_health(&module, SupervisorHealthStatus::Ok).await;
6123 module.drain().await.unwrap();
6124 }
6125
6126 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
6127 async fn configuration_update_does_not_replace_captured_running_process_facts() {
6128 let supervisor =
6129 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
6130 let snapshot = stale_process_snapshot(ModuleState::Running, true);
6131 let initial = ModuleSpec {
6132 module_id: "rescan-preserves-spawn-facts".to_string(),
6133 program: PathBuf::from("/spawned/module"),
6134 args: Vec::new(),
6135 env: Vec::new(),
6136 reserved: false,
6137 reserved_prefixes: Vec::new(),
6138 protocol: ModuleProtocol::Subc,
6139 overlap: Default::default(),
6140 };
6141 let module = supervisor.supervised_module(
6142 initial.clone(),
6143 supervisor.runtime_config(),
6144 snapshot,
6145 None,
6146 );
6147 let before = module.status().unwrap();
6148 let mut replacement = initial;
6149 replacement.program = PathBuf::from("/rescanned/replacement-module");
6150
6151 module
6152 .update_configuration(replacement, HealthConfig::default(), None)
6153 .await
6154 .unwrap();
6155
6156 let after = module.status().unwrap();
6157 assert_eq!(after.pid, before.pid);
6158 assert_eq!(after.spawned_at_ms, before.spawned_at_ms);
6159 assert_eq!(after.spawned_from, before.spawned_from);
6160 drop(module);
6161 }
6162}
6163
6164fn unix_ms_now() -> u64 {
6165 SystemTime::now()
6166 .duration_since(UNIX_EPOCH)
6167 .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64)
6168 .unwrap_or(0)
6169}
6170
6171async fn supervise_loop(
6172 mut spec: ModuleSpec,
6173 mut runtime: SupervisorRuntimeConfig,
6174 registry: Arc<Registry>,
6175 process_liveness: Arc<SupervisorProcessLiveness>,
6176 snapshot: SharedSnapshot,
6177 mut child: Option<SupervisedChild>,
6178 mut commands: mpsc::Receiver<SupervisorCommand>,
6179) {
6180 let mut health_probe = HealthProbeRuntime::default();
6181 let mut registration_changes = match registry.subscribe_module_changes(&spec.module_id) {
6185 Ok(changes) => Some(changes),
6186 Err(err) => {
6187 warn!(module_id = %spec.module_id, error = %err, "health prober could not subscribe to registry");
6190 None
6191 }
6192 };
6193 let mut pending_respawn: Option<PendingRespawn> = None;
6197 let mut requeued: VecDeque<SupervisorCommand> = VecDeque::new();
6200 #[cfg(target_os = "linux")]
6201 let mut previous_instance_recheck = tokio::time::interval_at(
6202 Instant::now() + Duration::from_secs(5),
6203 Duration::from_secs(5),
6204 );
6205 #[cfg(target_os = "linux")]
6206 previous_instance_recheck.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
6207 loop {
6208 #[cfg(test)]
6209 {
6210 lock_snapshot(&snapshot).unwrap().actor_select = None;
6211 }
6212 #[cfg(target_os = "macos")]
6213 if let Some(active) = child.as_mut() {
6214 active.confirm_privacy_exec().await;
6215 }
6216 if let Some(scheduled) = runtime
6217 .scheduled_respawn
6218 .lock()
6219 .unwrap_or_else(|p| p.into_inner())
6220 .take()
6221 {
6222 pending_respawn = Some(scheduled);
6223 }
6224 if pending_respawn.is_some() && (child.is_some() || !respawn_still_pending(&snapshot)) {
6225 pending_respawn = None;
6226 cancel_deferred_reload(
6227 &runtime,
6228 &spec.module_id,
6229 "respawn cancelled by a supervisor command",
6230 );
6231 }
6232 if child.is_none()
6233 && pending_respawn.is_none()
6234 && lock_snapshot(&snapshot).is_ok_and(|state| state.waiting_for_previous_pid.is_none())
6235 {
6236 cancel_deferred_reload(
6237 &runtime,
6238 &spec.module_id,
6239 "respawn cancelled before a replacement was spawned",
6240 );
6241 let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| {
6242 state.respawn_pending = false;
6243 state.coalesced_restart_pending = false;
6244 if matches!(
6245 state.state,
6246 ModuleState::Restarting
6247 | ModuleState::Starting
6248 | ModuleState::Draining
6249 | ModuleState::Unresponsive
6250 ) {
6251 error!(module_id = %spec.module_id, state = ?state.state,
6252 "supervision operation ended without a child or pending respawn; marking failed so start can retry");
6253 state.state = ModuleState::Failed;
6254 clear_current_process_facts(state);
6255 }
6256 });
6257 }
6258 if let Some(command) = requeued.pop_front() {
6259 if !handle_supervisor_command(
6260 command,
6261 &mut spec,
6262 &mut runtime,
6263 ®istry,
6264 &process_liveness,
6265 &snapshot,
6266 &mut child,
6267 &mut commands,
6268 &mut requeued,
6269 )
6270 .await
6271 {
6272 return;
6273 }
6274 if child.is_some() || !respawn_still_pending(&snapshot) {
6275 pending_respawn = None;
6276 let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| {
6277 state.respawn_pending = false
6278 });
6279 }
6280 continue;
6281 }
6282 if child.is_some() {
6283 #[cfg(test)]
6284 {
6285 lock_snapshot(&snapshot).unwrap().actor_turns += 1;
6286 }
6287 health_probe.refresh_registration(&spec, &runtime, ®istry, &snapshot);
6288 let wake_after = health_probe.wake_after();
6289 let probe_sleep = async move {
6290 match wake_after {
6291 Some(delay) => sleep(delay).await,
6292 None => std::future::pending().await,
6293 }
6294 };
6295 tokio::pin!(probe_sleep);
6296 let wire_child = running_protocol(&spec, &snapshot) == ModuleProtocol::Subc;
6301 #[cfg(test)]
6302 {
6303 let registration_pending = wire_child
6304 && registration_changes
6305 .as_ref()
6306 .is_some_and(|changes| changes.has_changed().unwrap());
6307 let mut state = lock_snapshot(&snapshot).unwrap();
6308 state.actor_select = (!registration_pending).then_some(ActorSelectCheckpoint {
6309 generation: state.spawn_generation,
6310 turn: state.actor_turns,
6311 registered_connection: health_probe.registered_connection,
6312 next_probe_at: health_probe.next_probe_at,
6313 wake_after,
6314 });
6315 }
6316 let active_child = child.as_mut().expect("child checked above");
6317 tokio::select! {
6318 wait_result = active_child.wait() => {
6319 #[cfg(test)]
6320 { lock_snapshot(&snapshot).unwrap().actor_select = None; }
6321 let exit_report = match wait_result {
6330 Ok(status) => classify_reaped_child_exit(&snapshot, active_child, &status),
6331 Err(err) => {
6332 active_child.drain_stderr(&spec.module_id).await;
6333 fail_after_wait_error(
6334 &spec.module_id,
6335 &snapshot,
6336 &runtime.terminal_ring,
6337 &runtime.spawn_events,
6338 format!("failed to wait for supervised module: {err}"),
6339 );
6340 untrack_if_registration_released(
6341 &process_liveness,
6342 ®istry,
6343 &spec.module_id,
6344 &snapshot,
6345 );
6346 error!(module_id = %spec.module_id, error = %err, "failed to wait for supervised module");
6347 child = None;
6348 continue;
6349 }
6350 };
6351 active_child.drain_stderr(&spec.module_id).await;
6352
6353 let next = on_child_exit(
6354 &spec,
6355 runtime.restart_policy,
6356 ®istry,
6357 &snapshot,
6358 &runtime.terminal_ring,
6359 &runtime.spawn_events,
6360 &runtime.child_roster,
6361 exit_report,
6362 ).await;
6363 runtime.start_requester = None;
6364 runtime.pending_activity_restart = false;
6365 active_child.release_roster();
6368 match next {
6369 NextAction::Stop { registration_released } => {
6370 if registration_released {
6371 process_liveness.untrack_if_current(&spec.module_id, &snapshot);
6372 }
6373 child = None;
6374 }
6375 NextAction::Restart { schedule } => {
6376 let delay = schedule.map_or(
6377 runtime.restart_policy.delay_for_restart(0),
6378 |schedule| schedule.delay,
6379 );
6380 if let Some(schedule) = schedule {
6381 log_crash_respawn(&spec.module_id, schedule);
6382 }
6383 child = None;
6391 pending_respawn = Some(PendingRespawn { deadline: Instant::now() + delay, kind: RespawnKind::Spawn });
6392 let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| state.respawn_pending = true);
6393 }
6394 }
6395 }
6396 command = commands.recv() => {
6397 #[cfg(test)]
6398 { lock_snapshot(&snapshot).unwrap().actor_select = None; }
6399 let Some(command) = command else {
6400 return;
6401 };
6402 if !handle_supervisor_command(
6403 command,
6404 &mut spec,
6405 &mut runtime,
6406 ®istry,
6407 &process_liveness,
6408 &snapshot,
6409 &mut child,
6410 &mut commands,
6411 &mut requeued,
6412 ).await {
6413 return;
6414 }
6415 }
6416 _ = async {
6417 match registration_changes.as_mut() {
6418 Some(changes) => { let _ = changes.changed().await; }
6419 None => std::future::pending().await,
6420 }
6421 }, if wire_child => {
6422 #[cfg(test)]
6423 { lock_snapshot(&snapshot).unwrap().actor_select = None; }
6424 }
6425 _ = &mut probe_sleep => {
6426 #[cfg(test)]
6427 { lock_snapshot(&snapshot).unwrap().actor_select = None; }
6428 if health_probe.due() {
6429 run_health_probe_cycle(
6430 &spec,
6431 &runtime,
6432 ®istry,
6433 &process_liveness,
6434 &snapshot,
6435 &mut child,
6436 ).await;
6437 if child.is_none() {
6438 runtime.start_requester = None;
6439 runtime.pending_activity_restart = false;
6440 }
6441 if child.is_some() {
6442 health_probe.schedule_next(&spec, runtime.health.cadence);
6443 }
6444 }
6445 }
6446 }
6447 } else if let Some(pending) = pending_respawn {
6448 tokio::select! {
6449 _ = sleep_until(pending.deadline) => {
6450 pending_respawn = None;
6451 let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| state.respawn_pending = false);
6452 if !respawn_still_pending(&snapshot) {
6456 continue;
6457 }
6458 if runtime.child_roster.is_closed() {
6463 let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| {
6464 state.state = ModuleState::Stopped;
6465 });
6466 debug!(module_id = %spec.module_id, "crash respawn cancelled by daemon shutdown");
6467 continue;
6468 }
6469 if let Err(err) = release_dead_registration(
6470 ®istry,
6471 runtime.forwarding.as_deref(),
6472 &snapshot,
6473 &spec.module_id,
6474 ).await {
6475 fail_snapshot(&snapshot, Some(&spec.module_id), None);
6476 error!(module_id = %spec.module_id, error = %err, "registration did not release before restart");
6477 continue;
6478 }
6479
6480 if matches!(pending.kind, RespawnKind::Reload) {
6481 let reply = runtime.deferred_reload_reply.lock().unwrap_or_else(|p| p.into_inner()).take();
6482 let result = finish_reload_child(&spec, &runtime, ®istry, &process_liveness, &snapshot, &mut child).await;
6483 if result.is_ok() && runtime.pending_activity_restart {
6484 runtime.record_activity_transition("subc.module_restarted", &spec.module_id, runtime.start_requester.clone());
6485 }
6486 runtime.pending_activity_restart = false;
6487 if let Some(reply) = reply { let _ = reply.send(result); }
6488 continue;
6489 }
6490 process_liveness.track(spec.module_id.clone(), Arc::clone(&snapshot));
6491 match spawn_and_mark_running(&spec, &runtime, &snapshot) {
6492 Ok(next_child) => {
6493 child = Some(next_child);
6494 if runtime.pending_activity_restart {
6495 runtime.record_activity_transition("subc.module_restarted", &spec.module_id, runtime.start_requester.clone());
6496 runtime.pending_activity_restart = false;
6497 }
6498 debug!(module_id = %spec.module_id, "supervised module restarted after crash");
6499 }
6500 Err(err) => {
6501 fail_snapshot(&snapshot, Some(&spec.module_id), None);
6502 process_liveness.untrack_if_current(&spec.module_id, &snapshot);
6503 error!(module_id = %spec.module_id, error = %err, "failed to restart supervised module");
6504 }
6505 }
6506 }
6507 command = commands.recv() => {
6508 let Some(command) = command else {
6509 return;
6510 };
6511 if !handle_supervisor_command(
6512 command,
6513 &mut spec,
6514 &mut runtime,
6515 ®istry,
6516 &process_liveness,
6517 &snapshot,
6518 &mut child,
6519 &mut commands,
6520 &mut requeued,
6521 ).await {
6522 return;
6523 }
6524 if child.is_some() || !respawn_still_pending(&snapshot) {
6529 pending_respawn = None;
6530 let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| state.respawn_pending = false);
6531 }
6532 }
6533 }
6534 } else {
6535 #[cfg(target_os = "linux")]
6536 let command = if lock_snapshot(&snapshot)
6537 .is_ok_and(|state| state.waiting_for_previous_pid.is_some())
6538 {
6539 tokio::select! {
6540 command = commands.recv() => command,
6541 _ = previous_instance_recheck.tick() => {
6542 if runtime.child_roster.previous_instance_pid(&spec.module_id).is_none() {
6543 let enabled = lock_snapshot(&snapshot).is_ok_and(|state| state.enabled);
6544 if enabled && !runtime.child_roster.is_closed() {
6545 process_liveness.track(spec.module_id.clone(), Arc::clone(&snapshot));
6546 match spawn_and_mark_running(&spec, &runtime, &snapshot) {
6547 Ok(next_child) => child = Some(next_child),
6548 Err(error) => {
6549 fail_snapshot(&snapshot, Some(&spec.module_id), None);
6550 error!(module_id = %spec.module_id, %error, "failed to start module after its previous instance exited");
6551 }
6552 }
6553 } else {
6554 let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| {
6555 state.waiting_for_previous_pid = None;
6556 });
6557 }
6558 }
6559 continue;
6560 }
6561 }
6562 } else {
6563 commands.recv().await
6564 };
6565 #[cfg(not(target_os = "linux"))]
6566 let command = commands.recv().await;
6567 let Some(command) = command else {
6568 return;
6569 };
6570 if !handle_supervisor_command(
6571 command,
6572 &mut spec,
6573 &mut runtime,
6574 ®istry,
6575 &process_liveness,
6576 &snapshot,
6577 &mut child,
6578 &mut commands,
6579 &mut requeued,
6580 )
6581 .await
6582 {
6583 return;
6584 }
6585 }
6586 }
6587}
6588
6589fn log_crash_respawn(module_id: &str, schedule: CrashRestartSchedule) {
6590 info!(
6591 module_id,
6592 restart_in_window = schedule.restart_in_window,
6593 delay_ms = schedule.delay.as_millis() as u64,
6594 "respawning after crash"
6595 );
6596}
6597
6598fn respawn_still_pending(snapshot: &SharedSnapshot) -> bool {
6604 matches!(
6605 lock_snapshot(snapshot),
6606 Ok(state) if state.enabled && state.state == ModuleState::Restarting
6607 )
6608}
6609
6610enum NextAction {
6611 Stop {
6612 registration_released: bool,
6613 },
6614 Restart {
6615 schedule: Option<CrashRestartSchedule>,
6616 },
6617}
6618
6619#[allow(clippy::too_many_arguments)]
6620async fn handle_supervisor_command(
6621 command: SupervisorCommand,
6622 spec: &mut ModuleSpec,
6623 runtime: &mut SupervisorRuntimeConfig,
6624 registry: &Arc<Registry>,
6625 process_liveness: &SupervisorProcessLiveness,
6626 snapshot: &SharedSnapshot,
6627 child: &mut Option<SupervisedChild>,
6628 commands: &mut mpsc::Receiver<SupervisorCommand>,
6629 requeued: &mut VecDeque<SupervisorCommand>,
6630) -> bool {
6631 #[cfg(target_os = "linux")]
6632 let command = if let Some(pid) = runtime.child_roster.previous_instance_pid(&spec.module_id) {
6633 match command {
6634 SupervisorCommand::Restart { reply, .. }
6635 | SupervisorCommand::Reload { reply, .. }
6636 | SupervisorCommand::Swap { reply, .. } => {
6637 let _ = reply.send(Err(previous_instance_error(spec, pid)));
6638 return true;
6639 }
6640 SupervisorCommand::SetEnabled {
6641 enabled: true,
6642 reply,
6643 ..
6644 } => {
6645 let _ = reply.send(Err(previous_instance_error(spec, pid)));
6646 return true;
6647 }
6648 other => other,
6649 }
6650 } else {
6651 command
6652 };
6653 match command {
6654 SupervisorCommand::Drain { requester, reply } => {
6655 let result = drain_optional_child(
6658 &spec.module_id,
6659 spec.protocol,
6660 StopNotice::NotSent,
6661 registry,
6662 runtime.forwarding.as_deref(),
6663 snapshot,
6664 &runtime.terminal_ring,
6665 &runtime.spawn_events,
6666 child,
6667 runtime.drain_timeout,
6668 ModuleState::Stopped,
6669 None,
6670 )
6671 .await;
6672 let registration_released = result.is_ok();
6673 if registration_released {
6674 runtime.record_activity_transition(
6675 "subc.module_stopped",
6676 &spec.module_id,
6677 requester,
6678 );
6679 }
6680 let _ = reply.send(result);
6681 if registration_released {
6682 process_liveness.untrack_if_current(&spec.module_id, snapshot);
6683 }
6684 false
6685 }
6686 SupervisorCommand::Retire { requester, reply } => {
6687 let result = async {
6688 let stop_notice = begin_forwarding_drain_if_configured(
6689 spec,
6690 runtime,
6691 registry,
6692 snapshot,
6693 None,
6694 RouteCloseReason::Disable,
6695 )
6696 .await?;
6697 drain_optional_child(
6698 &spec.module_id,
6699 spec.protocol,
6700 stop_notice,
6701 registry,
6702 runtime.forwarding.as_deref(),
6703 snapshot,
6704 &runtime.terminal_ring,
6705 &runtime.spawn_events,
6706 child,
6707 runtime.drain_timeout,
6708 ModuleState::Stopped,
6709 None,
6710 )
6711 .await
6712 }
6713 .await;
6714 let registration_released = result.is_ok();
6715 if registration_released {
6716 runtime.record_activity_transition(
6717 "subc.module_stopped",
6718 &spec.module_id,
6719 requester,
6720 );
6721 }
6722 let _ = reply.send(result);
6723 if registration_released {
6724 process_liveness.untrack_if_current(&spec.module_id, snapshot);
6725 }
6726 false
6727 }
6728 SupervisorCommand::Restart {
6729 requester,
6730 drain_timeout_ms,
6731 received_at_generation,
6732 queued_at,
6733 reply,
6734 } => {
6735 info!(
6739 module_id = %spec.module_id,
6740 queued_ms = u64::try_from(queued_at.elapsed().as_millis()).unwrap_or(u64::MAX),
6741 "restart command dequeued"
6742 );
6743 let validation = match lock_snapshot(snapshot) {
6755 Ok(state) if !state.enabled => Err(SuperviseError::Disabled {
6756 module_id: spec.module_id.clone(),
6757 }),
6758 Ok(_) => Ok(()),
6759 Err(err) => Err(err),
6760 };
6761 let initiated = validation.is_ok();
6762 let _ = reply.send(validation);
6763 let satisfied_by_generation = if initiated && child.is_some() {
6774 lock_snapshot(snapshot).ok().and_then(|state| {
6775 (state.spawn_generation > received_at_generation
6776 && !state.configuration_updated_since_spawn)
6777 .then_some(state.spawn_generation)
6778 })
6779 } else {
6780 None
6781 };
6782 let satisfied_by_pending = initiated
6783 && child.is_none()
6784 && lock_snapshot(snapshot).ok().is_some_and(|mut state| {
6785 let pending = state.respawn_pending && !state.configuration_updated_since_spawn;
6786 if pending {
6787 state.coalesced_restart_pending = true;
6788 }
6789 pending
6790 });
6791 if satisfied_by_pending {
6792 debug!(module_id = %spec.module_id, "restart coalesced into the pending replacement");
6793 } else if let Some(generation) = satisfied_by_generation {
6794 info!(
6795 module_id = %spec.module_id,
6796 received_at_generation,
6797 "restart already satisfied by generation {generation}; not restarting again"
6798 );
6799 } else if initiated {
6800 let drain_timeout = drain_timeout_ms
6803 .map(Duration::from_millis)
6804 .unwrap_or(runtime.drain_timeout);
6805 if let Err(err) = restart_child(
6806 spec,
6807 runtime,
6808 registry,
6809 process_liveness,
6810 snapshot,
6811 child,
6812 drain_timeout,
6813 )
6814 .await
6815 {
6816 warn!(
6817 module_id = %spec.module_id,
6818 error = %err,
6819 "operator restart failed after initiation ack; module state carries the outcome"
6820 );
6821 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
6822 state.state = ModuleState::Failed;
6823 clear_current_process_facts(state);
6824 });
6825 } else {
6826 runtime.start_requester = requester;
6827 runtime.pending_activity_restart = true;
6828 }
6829 }
6830 true
6831 }
6832 SupervisorCommand::Reload { requester, reply } => {
6833 let result =
6834 reload_child(spec, runtime, registry, process_liveness, snapshot, child).await;
6835 if result.is_ok() {
6836 runtime.start_requester = requester;
6837 runtime.pending_activity_restart = true;
6838 }
6839 if result.is_ok()
6840 && runtime
6841 .scheduled_respawn
6842 .lock()
6843 .unwrap_or_else(|p| p.into_inner())
6844 .as_ref()
6845 .is_some_and(|pending| matches!(pending.kind, RespawnKind::Reload))
6846 {
6847 *runtime
6848 .deferred_reload_reply
6849 .lock()
6850 .unwrap_or_else(|p| p.into_inner()) = Some(reply);
6851 } else {
6852 let _ = reply.send(result);
6853 }
6854 true
6855 }
6856 SupervisorCommand::SetEnabled {
6857 enabled,
6858 requester,
6859 reply,
6860 } => {
6861 let previous_requester = runtime.start_requester.clone();
6862 if enabled {
6863 runtime.start_requester = requester.clone();
6864 }
6865 let result = set_child_enabled(
6866 spec,
6867 runtime,
6868 registry,
6869 process_liveness,
6870 snapshot,
6871 child,
6872 enabled,
6873 )
6874 .await;
6875 if matches!(result, Ok(true)) {
6876 runtime.pending_activity_restart = false;
6877 if enabled {
6878 runtime.start_requester = requester;
6879 } else {
6880 runtime.start_requester = None;
6881 runtime.record_activity_transition(
6882 "subc.module_disabled",
6883 &spec.module_id,
6884 requester,
6885 );
6886 }
6887 } else {
6888 runtime.start_requester = previous_requester;
6889 }
6890 let _ = reply.send(result);
6891 true
6892 }
6893 SupervisorCommand::UpdateConfiguration {
6894 spec: next_spec,
6895 health,
6896 drain_timeout_ms,
6897 reply,
6898 } => {
6899 if let Some(handle) = &runtime.supervisor_handle {
6900 handle.apply_identity_configuration(&next_spec);
6901 }
6902 *spec = next_spec;
6903 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
6904 state.configuration_updated_since_spawn = true;
6905 });
6906 let health_changed = runtime.health != health;
6907 runtime.health = health;
6908 if health_changed {
6911 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
6912 state.health = ModuleHealthStatus::default();
6913 });
6914 }
6915 runtime.drain_timeout = drain_timeout_ms
6916 .map(Duration::from_millis)
6917 .unwrap_or(runtime.default_drain_timeout);
6918 *runtime
6919 .effective_drain_timeout
6920 .lock()
6921 .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.drain_timeout;
6922 let _ = reply.send(());
6923 true
6924 }
6925 SupervisorCommand::Swap {
6926 requester,
6927 ready_timeout,
6928 reply,
6929 } => {
6930 let mut swap_runtime = runtime.clone();
6931 swap_runtime.start_requester = requester;
6932 let before = lock_snapshot(snapshot)
6933 .ok()
6934 .map(|state| state.spawn_generation);
6935 let end = swap::run_swap(
6936 spec,
6937 &swap_runtime,
6938 registry,
6939 process_liveness,
6940 snapshot,
6941 child,
6942 commands,
6943 ready_timeout.unwrap_or(DEFAULT_SWAP_READY_TIMEOUT),
6944 reply,
6945 )
6946 .await;
6947 if before
6948 != lock_snapshot(snapshot)
6949 .ok()
6950 .map(|state| state.spawn_generation)
6951 {
6952 runtime.start_requester = swap_runtime.start_requester;
6953 }
6954 requeued.extend(end.requeue);
6955 true
6956 }
6957 }
6958}
6959
6960async fn restart_child(
6961 spec: &ModuleSpec,
6962 runtime: &SupervisorRuntimeConfig,
6963 registry: &Registry,
6964 process_liveness: &SupervisorProcessLiveness,
6965 snapshot: &SharedSnapshot,
6966 child: &mut Option<SupervisedChild>,
6967 drain_timeout: Duration,
6968) -> Result<(), SuperviseError> {
6969 if !lock_snapshot(snapshot)?.enabled {
6971 return Err(SuperviseError::Disabled {
6972 module_id: spec.module_id.clone(),
6973 });
6974 }
6975 let stop_notice = begin_forwarding_drain_with_timeout(
6976 spec,
6977 runtime,
6978 registry,
6979 snapshot,
6980 None,
6981 RouteCloseReason::Restart,
6982 drain_timeout,
6983 )
6984 .await?;
6985
6986 if child.is_some() {
6987 drain_optional_child(
6988 &spec.module_id,
6989 spec.protocol,
6990 stop_notice,
6991 registry,
6992 runtime.forwarding.as_deref(),
6993 snapshot,
6994 &runtime.terminal_ring,
6995 &runtime.spawn_events,
6996 child,
6997 drain_timeout,
6998 ModuleState::Restarting,
6999 Some(true),
7000 )
7001 .await?;
7002 } else {
7003 update_snapshot(snapshot, Some(&spec.module_id), |state| {
7004 state.enabled = true;
7005 state.state = ModuleState::Restarting;
7006 clear_current_process_facts(state);
7007 })?;
7008 release_dead_registration(
7009 registry,
7010 runtime.forwarding.as_deref(),
7011 snapshot,
7012 &spec.module_id,
7013 )
7014 .await?;
7015 }
7016
7017 reset_restart_count(snapshot, &spec.module_id)?;
7018 process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
7019 schedule_respawn(
7020 runtime,
7021 snapshot,
7022 &spec.module_id,
7023 runtime.restart_policy.backoff,
7024 RespawnKind::Spawn,
7025 )
7026}
7027
7028async fn reload_child(
7029 spec: &ModuleSpec,
7030 runtime: &SupervisorRuntimeConfig,
7031 registry: &Registry,
7032 process_liveness: &SupervisorProcessLiveness,
7033 snapshot: &SharedSnapshot,
7034 child: &mut Option<SupervisedChild>,
7035) -> Result<(), SuperviseError> {
7036 if !lock_snapshot(snapshot)?.enabled {
7038 return Err(SuperviseError::Disabled {
7039 module_id: spec.module_id.clone(),
7040 });
7041 }
7042 let stop_notice = begin_forwarding_drain(
7043 spec,
7044 runtime,
7045 registry,
7046 snapshot,
7047 Some(true),
7048 RouteCloseReason::Reload,
7049 )
7050 .await?;
7051
7052 if child.is_some() {
7053 drain_optional_child(
7054 &spec.module_id,
7055 spec.protocol,
7056 stop_notice,
7057 registry,
7058 runtime.forwarding.as_deref(),
7059 snapshot,
7060 &runtime.terminal_ring,
7061 &runtime.spawn_events,
7062 child,
7063 runtime.drain_timeout,
7064 ModuleState::Restarting,
7065 Some(true),
7066 )
7067 .await?;
7068 } else {
7069 update_snapshot(snapshot, Some(&spec.module_id), |state| {
7070 state.enabled = true;
7071 state.state = ModuleState::Restarting;
7072 clear_current_process_facts(state);
7073 })?;
7074 release_dead_registration(
7075 registry,
7076 runtime.forwarding.as_deref(),
7077 snapshot,
7078 &spec.module_id,
7079 )
7080 .await?;
7081 }
7082
7083 reset_restart_count(snapshot, &spec.module_id)?;
7084 process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
7085 schedule_respawn(
7086 runtime,
7087 snapshot,
7088 &spec.module_id,
7089 runtime.restart_policy.backoff,
7090 RespawnKind::Reload,
7091 )
7092}
7093
7094async fn finish_reload_child(
7095 spec: &ModuleSpec,
7096 runtime: &SupervisorRuntimeConfig,
7097 registry: &Registry,
7098 process_liveness: &SupervisorProcessLiveness,
7099 snapshot: &SharedSnapshot,
7100 child: &mut Option<SupervisedChild>,
7101) -> Result<(), SuperviseError> {
7102 process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
7103 let next_child = match spawn_and_mark_running(spec, runtime, snapshot) {
7104 Ok(next_child) => next_child,
7105 Err(err) => {
7106 return handle_reload_spawn_failure(
7107 spec,
7108 runtime,
7109 process_liveness,
7110 snapshot,
7111 child,
7112 format!("new child failed to spawn: {err}"),
7113 )
7114 .await;
7115 }
7116 };
7117 *child = Some(next_child);
7118
7119 let wait_outcome = {
7120 let active_child = child.as_mut().expect("new reload child was just stored");
7121 wait_for_registration_after_reload(
7122 registry,
7123 &spec.module_id,
7124 snapshot,
7125 active_child,
7126 REGISTRY_RELEASE_TIMEOUT,
7127 )
7128 .await?
7129 };
7130
7131 match wait_outcome {
7132 RegistrationWaitOutcome::Registered => {
7133 debug!(module_id = %spec.module_id, "supervised module reloaded and registered");
7134 Ok(())
7135 }
7136 RegistrationWaitOutcome::Exited(exit_report) => {
7137 if let Some(active_child) = child.as_mut() {
7138 active_child.drain_stderr(&spec.module_id).await;
7139 }
7140 let mut exited_child = child.take().expect("exited reload child is still stored");
7143 #[cfg(test)]
7144 if let Some(gate) = &runtime.test_reload_exit_record_gate {
7145 gate.reached.notify_one();
7146 gate.resume.notified().await;
7147 }
7148 let result = handle_reload_child_registration_failure(
7149 spec,
7150 runtime,
7151 registry,
7152 process_liveness,
7153 snapshot,
7154 child,
7155 ReloadRegistrationFailure {
7156 exit_report: registration_failure_exit_report(exit_report),
7157 reason: exited_child
7158 .spawn_failure
7159 .clone()
7160 .unwrap_or_else(|| "new child exited before registering".to_string()),
7161 },
7162 )
7163 .await;
7164 exited_child.release_roster();
7165 result
7166 }
7167 RegistrationWaitOutcome::TimedOut => {
7168 let mut timed_out_child = child
7169 .take()
7170 .expect("timed-out reload child is still running");
7171 timed_out_child
7172 .start_kill()
7173 .map_err(|source| SuperviseError::Kill {
7174 module_id: spec.module_id.clone(),
7175 source,
7176 })?;
7177 let status = timed_out_child
7178 .wait()
7179 .await
7180 .map_err(|source| SuperviseError::Wait {
7181 module_id: spec.module_id.clone(),
7182 source,
7183 })?;
7184 timed_out_child.drain_stderr(&spec.module_id).await;
7185 handle_reload_child_registration_failure(
7186 spec,
7187 runtime,
7188 registry,
7189 process_liveness,
7190 snapshot,
7191 child,
7192 ReloadRegistrationFailure {
7193 exit_report: registration_failure_exit_report(classify_reaped_child_exit(
7194 snapshot,
7195 &timed_out_child,
7196 &status,
7197 )),
7198 reason: format!(
7199 "new child did not register within {:?}",
7200 REGISTRY_RELEASE_TIMEOUT
7201 ),
7202 },
7203 )
7204 .await
7205 }
7206 }
7207}
7208
7209async fn set_child_enabled(
7210 spec: &ModuleSpec,
7211 runtime: &SupervisorRuntimeConfig,
7212 registry: &Registry,
7213 process_liveness: &SupervisorProcessLiveness,
7214 snapshot: &SharedSnapshot,
7215 child: &mut Option<SupervisedChild>,
7216 enabled: bool,
7217) -> Result<bool, SuperviseError> {
7218 let (current_enabled, current_state, respawn_pending) = {
7219 let state = lock_snapshot(snapshot)?;
7220 (state.enabled, state.state, state.respawn_pending)
7221 };
7222 let revive_terminal = enabled
7230 && current_enabled
7231 && child.is_none()
7232 && (matches!(current_state, ModuleState::Failed | ModuleState::Stopped)
7233 || (current_state == ModuleState::Restarting && !respawn_pending));
7234 if current_enabled == enabled && !revive_terminal {
7235 return Ok(false);
7236 }
7237
7238 if enabled {
7239 update_snapshot(snapshot, Some(&spec.module_id), |state| {
7240 state.enabled = true;
7241 state.state = ModuleState::Starting;
7242 clear_current_process_facts(state);
7243 })?;
7244 #[cfg(test)]
7245 if runtime.test_seed_stale_facts_before_enable_spawn {
7246 update_snapshot(snapshot, Some(&spec.module_id), |state| {
7247 state.process_alive = true;
7248 state.pid = Some(41);
7249 state.spawned_at_ms = Some(42);
7250 state.spawned_from = Some(PathBuf::from("/spawned/module"));
7251 state.spawned_file_identity = Some(SpawnedFileIdentity {
7252 device: 43,
7253 inode: 44,
7254 #[cfg(windows)]
7255 windows_image: None,
7256 });
7257 })?;
7258 }
7259 release_dead_registration(
7260 registry,
7261 runtime.forwarding.as_deref(),
7262 snapshot,
7263 &spec.module_id,
7264 )
7265 .await?;
7266 reset_restart_count(snapshot, &spec.module_id)?;
7267 process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
7268 let next_child = match spawn_and_mark_running(spec, runtime, snapshot) {
7269 Ok(next_child) => next_child,
7270 Err(err) => {
7271 if let Err(state_err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
7272 state.state = ModuleState::Failed;
7273 clear_current_process_facts(state);
7274 }) {
7275 error!(module_id = %spec.module_id, error = %state_err, "failed to record enable spawn failure");
7276 }
7277 process_liveness.untrack_if_current(&spec.module_id, snapshot);
7278 return Err(err);
7279 }
7280 };
7281 *child = Some(next_child);
7282 debug!(module_id = %spec.module_id, "supervised module enabled");
7283 Ok(true)
7284 } else {
7285 let stop_notice = begin_forwarding_drain_if_configured(
7286 spec,
7287 runtime,
7288 registry,
7289 snapshot,
7290 Some(false),
7291 RouteCloseReason::Disable,
7292 )
7293 .await?;
7294 drain_optional_child(
7295 &spec.module_id,
7296 spec.protocol,
7297 stop_notice,
7298 registry,
7299 runtime.forwarding.as_deref(),
7300 snapshot,
7301 &runtime.terminal_ring,
7302 &runtime.spawn_events,
7303 child,
7304 runtime.drain_timeout,
7305 ModuleState::Disabled,
7306 Some(false),
7307 )
7308 .await?;
7309 debug!(module_id = %spec.module_id, "supervised module disabled");
7310 Ok(true)
7311 }
7312}
7313
7314#[allow(clippy::too_many_arguments)]
7315async fn on_child_exit(
7316 spec: &ModuleSpec,
7317 policy: RestartPolicy,
7318 registry: &Registry,
7319 snapshot: &SharedSnapshot,
7320 terminal_ring: &Arc<Mutex<TerminalRing>>,
7321 spawn_events: &SpawnEventFeed,
7322 roster: &ChildRoster,
7323 exit_report: ExitReport,
7324) -> NextAction {
7325 if roster.is_closed() {
7331 return on_child_exit_during_daemon_shutdown(
7332 spec,
7333 registry,
7334 snapshot,
7335 terminal_ring,
7336 spawn_events,
7337 exit_report,
7338 )
7339 .await;
7340 }
7341 let unrequested_clean_exit_of_protocol_none = exit_report.kind == ExitKind::Clean
7356 && running_protocol(spec, snapshot) == ModuleProtocol::None;
7357 match exit_report.kind {
7358 ExitKind::Clean if !unrequested_clean_exit_of_protocol_none => {
7359 info!(
7360 module_id = %spec.module_id,
7361 exit_code = ?exit_report.code,
7362 exit_signal = ?exit_report.signal,
7363 "supervised module exited cleanly"
7364 );
7365 if let Err(err) = publish_after_terminal_record(
7366 &spec.module_id,
7367 || {
7368 record_terminal(
7369 &spec.module_id,
7370 terminal_ring,
7371 spawn_events,
7372 &exit_report,
7373 TerminalDisposition::Stopped,
7374 )
7375 },
7376 || {
7377 update_snapshot(snapshot, Some(&spec.module_id), |state| {
7378 state.state = ModuleState::Stopped;
7379 clear_current_process_facts(state);
7380 state.last_exit = Some(exit_report.clone());
7381 })
7382 },
7383 ) {
7384 error!(module_id = %spec.module_id, error = %err, "failed to publish clean module exit");
7385 }
7386 let registration_released = match wait_for_registration_release(
7387 registry,
7388 &spec.module_id,
7389 REGISTRY_RELEASE_TIMEOUT,
7390 )
7391 .await
7392 {
7393 Ok(()) => true,
7394 Err(err) => {
7395 warn!(module_id = %spec.module_id, error = %err, "registration still active after clean exit");
7396 false
7397 }
7398 };
7399 NextAction::Stop {
7400 registration_released,
7401 }
7402 }
7403 ExitKind::Clean | ExitKind::Crash => {
7404 if unrequested_clean_exit_of_protocol_none {
7405 warn!(
7406 module_id = %spec.module_id,
7407 exit_code = ?exit_report.code,
7408 exit_signal = ?exit_report.signal,
7409 "protocol-none module exited cleanly without a stop request; handling it as a crash"
7410 );
7411 } else {
7412 warn!(
7413 module_id = %spec.module_id,
7414 exit_code = ?exit_report.code,
7415 exit_signal = ?exit_report.signal,
7416 "supervised module exited abnormally (crash)"
7417 );
7418 }
7419 let mut restart_schedule = None;
7420 let mut disposition = TerminalDisposition::Disabled;
7421 let mut final_state = ModuleState::Disabled;
7422 let mut disposition_detail = lock_snapshot(snapshot)
7426 .ok()
7427 .and_then(|mut state| state.spawn_failure.take());
7428 let now = Instant::now();
7429 if let Err(err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
7430 if state.enabled {
7431 if let Some(schedule) = state.next_crash_restart(&policy, now) {
7432 final_state = ModuleState::Restarting;
7433 restart_schedule = Some(schedule);
7434 disposition = TerminalDisposition::Restarting;
7435 } else {
7436 final_state = ModuleState::Failed;
7437 disposition = TerminalDisposition::Failed;
7438 let budget = policy.budget_exhausted_detail();
7439 disposition_detail =
7440 Some(disposition_detail.take().map_or_else(
7441 || budget.clone(),
7442 |cause| format!("{cause}; {budget}"),
7443 ));
7444 }
7445 } else {
7446 disposition = TerminalDisposition::Disabled;
7447 }
7448 }) {
7449 error!(module_id = %spec.module_id, error = %err, "failed to record crashed module exit");
7450 return NextAction::Stop {
7451 registration_released: false,
7452 };
7453 }
7454 if disposition == TerminalDisposition::Failed {
7455 error!(
7460 module_id = %spec.module_id,
7461 max_restarts = policy.max_restarts,
7462 window_secs = policy.window.as_secs(),
7463 "module stopped: {}",
7464 policy.budget_exhausted_detail()
7465 );
7466 }
7467 if let Err(err) = publish_after_terminal_record(
7468 &spec.module_id,
7469 || {
7470 record_terminal_with_detail(
7471 &spec.module_id,
7472 terminal_ring,
7473 spawn_events,
7474 &exit_report,
7475 disposition,
7476 disposition_detail,
7477 )
7478 },
7479 || {
7480 update_snapshot(snapshot, Some(&spec.module_id), |state| {
7481 state.state = final_state;
7482 clear_current_process_facts(state);
7483 state.last_exit = Some(exit_report.clone());
7484 })
7485 },
7486 ) {
7487 error!(module_id = %spec.module_id, error = %err, "failed to publish crashed module exit");
7488 }
7489
7490 if let Some(schedule) = restart_schedule {
7491 NextAction::Restart {
7492 schedule: Some(schedule),
7493 }
7494 } else {
7495 let registration_released = match wait_for_registration_release(
7496 registry,
7497 &spec.module_id,
7498 REGISTRY_RELEASE_TIMEOUT,
7499 )
7500 .await
7501 {
7502 Ok(()) => true,
7503 Err(err) => {
7504 warn!(module_id = %spec.module_id, error = %err, "registration still active after failed module");
7505 false
7506 }
7507 };
7508 NextAction::Stop {
7509 registration_released,
7510 }
7511 }
7512 }
7513 ExitKind::DeliberateSeverance => {
7514 warn!(
7515 module_id = %spec.module_id,
7516 exit_code = ?exit_report.code,
7517 exit_signal = ?exit_report.signal,
7518 "supervised module exited after deliberate connection severance"
7519 );
7520 let should_restart = match lock_snapshot(snapshot) {
7521 Ok(state) => state.enabled,
7522 Err(err) => {
7523 error!(module_id = %spec.module_id, error = %err, "failed to read deliberately severed module state");
7524 return NextAction::Stop {
7525 registration_released: false,
7526 };
7527 }
7528 };
7529 let final_state = if should_restart {
7530 ModuleState::Restarting
7531 } else {
7532 ModuleState::Disabled
7533 };
7534 if let Err(err) = publish_after_terminal_record(
7535 &spec.module_id,
7536 || {
7537 record_terminal(
7538 &spec.module_id,
7539 terminal_ring,
7540 spawn_events,
7541 &exit_report,
7542 terminal_disposition(final_state),
7543 )
7544 },
7545 || {
7546 update_snapshot(snapshot, Some(&spec.module_id), |state| {
7547 state.state = final_state;
7548 clear_current_process_facts(state);
7549 state.last_exit = Some(exit_report.clone());
7550 state.lifetime_restarts += 1;
7551 })
7552 },
7553 ) {
7554 error!(module_id = %spec.module_id, error = %err, "failed to publish deliberately severed module exit");
7555 return NextAction::Stop {
7556 registration_released: false,
7557 };
7558 }
7559
7560 if should_restart {
7561 NextAction::Restart { schedule: None }
7562 } else {
7563 let registration_released = match wait_for_registration_release(
7564 registry,
7565 &spec.module_id,
7566 REGISTRY_RELEASE_TIMEOUT,
7567 )
7568 .await
7569 {
7570 Ok(()) => true,
7571 Err(err) => {
7572 warn!(module_id = %spec.module_id, error = %err, "registration still active after deliberately severed module exit");
7573 false
7574 }
7575 };
7576 NextAction::Stop {
7577 registration_released,
7578 }
7579 }
7580 }
7581 }
7582}
7583
7584async fn on_child_exit_during_daemon_shutdown(
7585 spec: &ModuleSpec,
7586 registry: &Registry,
7587 snapshot: &SharedSnapshot,
7588 terminal_ring: &Arc<Mutex<TerminalRing>>,
7589 spawn_events: &SpawnEventFeed,
7590 exit_report: ExitReport,
7591) -> NextAction {
7592 info!(
7593 module_id = %spec.module_id,
7594 exit_code = ?exit_report.code,
7595 exit_signal = ?exit_report.signal,
7596 exit_kind = ?exit_report.kind,
7597 "supervised module exited during daemon shutdown; not restarting it"
7598 );
7599 if let Err(err) = publish_after_terminal_record(
7600 &spec.module_id,
7601 || {
7602 record_terminal(
7603 &spec.module_id,
7604 terminal_ring,
7605 spawn_events,
7606 &exit_report,
7607 TerminalDisposition::DaemonShutdown,
7608 )
7609 },
7610 || {
7611 update_snapshot(snapshot, Some(&spec.module_id), |state| {
7612 state.state = ModuleState::Stopped;
7613 clear_current_process_facts(state);
7614 state.last_exit = Some(exit_report.clone());
7615 })
7616 },
7617 ) {
7618 error!(module_id = %spec.module_id, error = %err, "failed to publish module exit during daemon shutdown");
7619 }
7620 let registration_released =
7621 wait_for_registration_release(registry, &spec.module_id, REGISTRY_RELEASE_TIMEOUT)
7622 .await
7623 .is_ok();
7624 NextAction::Stop {
7625 registration_released,
7626 }
7627}
7628
7629fn publish_after_terminal_record<T>(
7634 module_id: &str,
7635 record: impl FnOnce(),
7636 publish: impl FnOnce() -> T,
7637) -> T {
7638 let recorded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(record));
7639 let published = publish();
7640 if let Err(panic) = recorded {
7641 error!(
7642 module_id,
7643 "terminal recording panicked; published final state before resuming unwind"
7644 );
7645 std::panic::resume_unwind(panic);
7646 }
7647 published
7648}
7649
7650fn fail_after_wait_error(
7651 module_id: &str,
7652 snapshot: &SharedSnapshot,
7653 terminal_ring: &Arc<Mutex<TerminalRing>>,
7654 spawn_events: &SpawnEventFeed,
7655 detail: String,
7656) {
7657 let report = wait_error_exit_report();
7658 publish_after_terminal_record(
7659 module_id,
7660 || {
7661 record_terminal_with_detail(
7662 module_id,
7663 terminal_ring,
7664 spawn_events,
7665 &report,
7666 TerminalDisposition::Failed,
7667 Some(detail),
7668 )
7669 },
7670 || fail_snapshot(snapshot, Some(module_id), Some(report.clone())),
7671 );
7672}
7673
7674#[cfg(test)]
7675fn record_wait_error_terminal(
7676 module_id: &str,
7677 terminal_ring: &Arc<Mutex<TerminalRing>>,
7678 spawn_events: &SpawnEventFeed,
7679) {
7680 record_terminal(
7681 module_id,
7682 terminal_ring,
7683 spawn_events,
7684 &wait_error_exit_report(),
7685 TerminalDisposition::Failed,
7686 );
7687}
7688
7689fn record_terminal(
7690 module_id: &str,
7691 terminal_ring: &Arc<Mutex<TerminalRing>>,
7692 spawn_events: &SpawnEventFeed,
7693 exit_report: &ExitReport,
7694 disposition: TerminalDisposition,
7695) {
7696 record_terminal_with_detail(
7697 module_id,
7698 terminal_ring,
7699 spawn_events,
7700 exit_report,
7701 disposition,
7702 None,
7703 );
7704}
7705
7706fn durable_terminal_history_of(
7710 terminal_ring: &Mutex<TerminalRing>,
7711 module_id: &str,
7712) -> subc_control::TerminalHistory {
7713 let read = terminal_ring
7714 .lock()
7715 .unwrap_or_else(|p| p.into_inner())
7716 .capture_durable_history();
7717 read.read(module_id)
7718}
7719
7720fn record_terminal_with_detail(
7721 module_id: &str,
7722 terminal_ring: &Arc<Mutex<TerminalRing>>,
7723 spawn_events: &SpawnEventFeed,
7724 exit_report: &ExitReport,
7725 disposition: TerminalDisposition,
7726 disposition_detail: Option<String>,
7727) {
7728 #[cfg(test)]
7729 terminal_history_tests::before_terminal_record(spawn_events);
7730 spawn_events.emit_exited(module_id, exit_report.code, exit_report.signal);
7731 let record = TerminalRecord {
7732 exit_code: exit_report.code,
7733 exit_signal: exit_report.signal,
7734 at_ms: exit_report.at_ms,
7735 disposition,
7736 exit_kind: exit_report.kind.into(),
7737 disposition_detail,
7738 };
7739 terminal_ring
7740 .lock()
7741 .unwrap_or_else(|poisoned| poisoned.into_inner())
7742 .record_exit(module_id, record);
7743}
7744
7745fn untrack_if_registration_released(
7746 process_liveness: &SupervisorProcessLiveness,
7747 registry: &Registry,
7748 module_id: &str,
7749 snapshot: &SharedSnapshot,
7750) {
7751 match registry.get_module(module_id) {
7752 Ok(None) => process_liveness.untrack_if_current(module_id, snapshot),
7753 Ok(Some(_)) => {}
7754 Err(err) => {
7755 warn!(module_id, error = %err, "could not determine whether supervisor liveness can be untracked");
7756 }
7757 }
7758}
7759
7760#[cfg(test)]
7774fn apply_wire_spawn_args(
7775 command: &mut Command,
7776 spec: &ModuleSpec,
7777 connection_file_path: Option<&std::path::Path>,
7778 handle: Option<&SupervisorHandle>,
7779) -> Result<Option<NonceHandoff>, SuperviseError> {
7780 apply_wire_spawn_args_for_role(
7781 command,
7782 spec,
7783 connection_file_path,
7784 handle,
7785 SpawnRole::Plain,
7786 )
7787}
7788
7789#[cfg(unix)]
7794type NonceHandoff = subc_os::LaunchNonceHandoff;
7795#[cfg(windows)]
7796type NonceHandoff = subc_os::LaunchNoncePipeHandoff;
7797#[cfg(not(any(unix, windows)))]
7798type NonceHandoff = std::convert::Infallible;
7799
7800fn apply_wire_spawn_args_for_role(
7821 command: &mut Command,
7822 spec: &ModuleSpec,
7823 connection_file_path: Option<&std::path::Path>,
7824 handle: Option<&SupervisorHandle>,
7825 role: SpawnRole,
7826) -> Result<Option<NonceHandoff>, SuperviseError> {
7827 command.env(SUBC_MODULE_ID_ENV, &spec.module_id);
7828 command.env_remove(subc_os::LAUNCH_NONCE_FD_ENV);
7833 #[cfg(windows)]
7834 {
7835 command.env_remove(subc_os::LAUNCH_NONCE_PIPE_ENV);
7836 command.env_remove(subc_os::LAUNCH_NONCE_PIPE_FALLBACK_ENV);
7837 }
7838 command.env_remove(SUBC_LAUNCH_NONCE_ENV);
7840 if spec.protocol == ModuleProtocol::None {
7841 return Ok(None);
7842 }
7843 if let Some(connection_file_path) = connection_file_path {
7844 command.arg(SUBC_ARG).arg(connection_file_path);
7845 }
7846
7847 let nonce = generate_launch_nonce()?;
7851 if let Some(handle) = handle {
7852 match role {
7853 SpawnRole::Plain => {
7854 handle.set_spawn_nonce(&spec.module_id, nonce.clone());
7855 if spec.reserved {
7856 handle.set_reserved_nonce(&spec.module_id, nonce.clone());
7857 }
7858 }
7859 SpawnRole::SwapCandidate => handle.open_swap(&spec.module_id, nonce.clone()),
7860 }
7861 }
7862 #[cfg(unix)]
7863 let handoff = {
7864 let handoff =
7865 subc_os::LaunchNonceHandoff::new(&nonce).map_err(|source| SuperviseError::Spawn {
7866 program: spec.program.clone(),
7867 source,
7868 cgroup_path: None,
7869 })?;
7870 command.env(subc_os::LAUNCH_NONCE_FD_ENV, handoff.fd_env_value());
7871 Some(handoff)
7872 };
7873 #[cfg(windows)]
7874 let handoff = prepare_windows_nonce_pipe(command, spec, &nonce);
7875 #[cfg(not(any(unix, windows)))]
7876 let handoff = None;
7877 #[cfg(not(unix))]
7881 command.env(SUBC_LAUNCH_NONCE_ENV, nonce);
7882 Ok(handoff)
7883}
7884
7885fn apply_child_env(command: &mut Command, spec: &ModuleSpec) {
7886 command.env_remove(CK_LOG_ENV);
7887 command.env_remove(SUBC_SPAWN_ROLE_ENV);
7894 for (key, value) in &spec.env {
7895 if matches!(
7899 key.as_str(),
7900 CAPTURE_MAX_FILE_MB_ENV | CAPTURE_KEEP_ENV | CAPTURE_MAX_AGE_DAYS_ENV
7901 ) || key == SUBC_SPAWN_ROLE_ENV
7902 {
7903 continue;
7904 }
7905 command.env(key, value);
7906 }
7907}
7908
7909#[cfg(windows)]
7910fn prepare_windows_nonce_pipe(
7911 command: &mut Command,
7912 spec: &ModuleSpec,
7913 nonce: &str,
7914) -> Option<NonceHandoff> {
7915 match subc_os::LaunchNoncePipeHandoff::new(nonce) {
7916 Ok(handoff) => {
7917 command.env(subc_os::LAUNCH_NONCE_PIPE_ENV, handoff.name());
7918 command.env(subc_os::LAUNCH_NONCE_PIPE_FALLBACK_ENV, "env");
7923 Some(handoff)
7924 }
7925 Err(error) => {
7926 warn!(module_id = %spec.module_id, %error, "could not prepare launch nonce pipe; retaining the environment handoff");
7927 None
7928 }
7929 }
7930}
7931
7932#[cfg(windows)]
7933fn start_windows_nonce_pipe(
7934 handoff: Option<NonceHandoff>,
7935 pid: u32,
7936 spec: &ModuleSpec,
7937) -> Option<subc_os::LaunchNoncePipeDelivery> {
7938 handoff.and_then(|handoff| match handoff.serve(pid, REGISTRY_RELEASE_TIMEOUT) {
7942 Ok(delivery) => Some(delivery),
7943 Err(error) => {
7944 warn!(module_id = %spec.module_id, %error, "could not serve launch nonce pipe; retaining the environment handoff");
7945 None
7946 }
7947 })
7948}
7949
7950fn observed_launch_nonce_source(snapshot: &SupervisorSnapshot) -> Option<String> {
7951 #[cfg(windows)]
7952 if snapshot.process_alive && snapshot.spawned_protocol == Some(ModuleProtocol::Subc) {
7953 return Some(
7954 if snapshot
7955 .pipe_consumed
7956 .as_ref()
7957 .is_some_and(|consumed| consumed.get().is_some())
7958 {
7959 "pipe"
7960 } else {
7961 "env"
7962 }
7963 .to_owned(),
7964 );
7965 }
7966 let _ = snapshot;
7967 None
7968}
7969
7970#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7973enum SpawnRole {
7974 Plain,
7975 SwapCandidate,
7976}
7977
7978fn apply_spawn_role(command: &mut Command, role: SpawnRole) {
7981 if role == SpawnRole::SwapCandidate {
7982 command.env(SUBC_SPAWN_ROLE_ENV, SPAWN_ROLE_SWAP_CANDIDATE);
7983 }
7984}
7985
7986#[cfg(target_os = "linux")]
7987fn previous_instance_error(spec: &ModuleSpec, pid: u32) -> SuperviseError {
7988 SuperviseError::Spawn {
7989 program: spec.program.clone(),
7990 source: io::Error::new(io::ErrorKind::WouldBlock, format!(
7991 "waiting: previous instance (pid {pid}) is still running but hidden; it cannot be identified"
7992 )),
7993 cgroup_path: None,
7994 }
7995}
7996
7997fn spawn_child(
7998 spec: &ModuleSpec,
7999 connection_file_path: Option<&std::path::Path>,
8000 handle: Option<&SupervisorHandle>,
8001 ring: &Arc<Mutex<StderrRing>>,
8002 capture_logs_dir: Option<&std::path::Path>,
8003 roster: &ChildRoster,
8004 #[cfg(target_os = "linux")] cgroup_placement: Option<&subc_cgroup::Placement>,
8005) -> Result<SupervisedChild, SuperviseError> {
8006 spawn_child_in_slot(
8007 spec,
8008 connection_file_path,
8009 handle,
8010 ring,
8011 capture_logs_dir,
8012 roster,
8013 #[cfg(target_os = "linux")]
8014 cgroup_placement,
8015 SpawnRole::Plain,
8016 false,
8017 )
8018}
8019
8020#[allow(clippy::too_many_arguments)]
8033fn spawn_child_in_slot(
8034 spec: &ModuleSpec,
8035 connection_file_path: Option<&std::path::Path>,
8036 handle: Option<&SupervisorHandle>,
8037 ring: &Arc<Mutex<StderrRing>>,
8038 capture_logs_dir: Option<&std::path::Path>,
8039 roster: &ChildRoster,
8040 #[cfg(target_os = "linux")] cgroup_placement: Option<&subc_cgroup::Placement>,
8041 role: SpawnRole,
8042 alternate_slot: bool,
8043) -> Result<SupervisedChild, SuperviseError> {
8044 #[cfg(target_os = "linux")]
8045 if let Some(pid) = roster.previous_instance_pid(&spec.module_id) {
8046 return Err(previous_instance_error(spec, pid));
8047 }
8048 if roster.is_closed() {
8049 return Err(SuperviseError::Spawn {
8050 program: spec.program.clone(),
8051 source: io::Error::other("the daemon is shutting down; not starting a new process"),
8052 cgroup_path: None,
8053 });
8054 }
8055 #[cfg(target_os = "linux")]
8056 let cgroup_name = {
8057 if cgroup_placement.is_none() {
8062 swap::cgroup_name(&spec.module_id, alternate_slot)
8063 } else {
8064 let nonce = generate_launch_nonce()?;
8065 let suffix = u128::from_str_radix(&nonce[..32], 16).expect("hex launch nonce");
8066 let mut end = spec.module_id.len().min(64);
8069 while !spec.module_id.is_char_boundary(end) {
8070 end -= 1;
8071 }
8072 format!(
8073 "{}_{suffix}",
8074 swap::cgroup_name(&spec.module_id[..end], alternate_slot)
8075 )
8076 }
8077 };
8078 #[cfg(not(target_os = "linux"))]
8079 let _ = alternate_slot;
8080 #[cfg(target_os = "macos")]
8081 let (mut command, privacy_exec, exec_ack) = privacy_command(spec, roster)?;
8082 #[cfg(windows)]
8083 let image_capture = windows_spawn_capture(spec).map_err(|source| SuperviseError::Spawn {
8084 program: spec.program.clone(),
8085 source,
8086 cgroup_path: None,
8087 })?;
8088 #[cfg(windows)]
8089 let mut command = Command::new(image_capture.path());
8090 #[cfg(not(any(target_os = "macos", windows)))]
8091 let mut command = Command::new(&spec.program);
8092 command.args(&spec.args);
8093 apply_child_env(&mut command, spec);
8123 apply_spawn_role(&mut command, role);
8124 let nonce_handoff =
8125 apply_wire_spawn_args_for_role(&mut command, spec, connection_file_path, handle, role)?;
8126
8127 #[cfg(target_os = "linux")]
8128 let cgroup_path = cgroup_placement
8129 .map(|placement| placement.module_path(&cgroup_name))
8130 .transpose()
8131 .map_err(|source| SuperviseError::Cgroup {
8132 module_id: spec.module_id.clone(),
8133 source,
8134 })?;
8135 #[cfg(not(target_os = "linux"))]
8136 let cgroup_path: Option<PathBuf> = None;
8137 #[cfg(target_os = "linux")]
8138 if let Some(path) = &cgroup_path {
8139 if let Err(error) = apply_cgroup_placement(&mut command, spec, path) {
8140 if let Some(placement) = cgroup_placement {
8141 remove_module_cgroup(placement, &cgroup_name);
8142 }
8143 return Err(error);
8144 }
8145 }
8146
8147 let output_sink = if let Some(logs_dir) = capture_logs_dir {
8148 let path = logs_dir.join(format!("{}.stderr.log", spec.module_id));
8149 match ChildOutputSink::open(&path, capture_retention(spec)) {
8150 Ok(sink) => sink,
8151 Err(error) => {
8152 warn!(
8153 module_id = %spec.module_id,
8154 path = %path.display(),
8155 error = %error,
8156 "could not open child output capture file; forwarding to stderr"
8157 );
8158 ChildOutputSink::Stderr
8159 }
8160 }
8161 } else {
8162 ChildOutputSink::Stderr
8163 };
8164
8165 command.stdout(Stdio::piped());
8166 command.stderr(Stdio::piped());
8167 command.kill_on_drop(true);
8168 #[cfg(unix)]
8185 command.process_group(0);
8186 command.stdin(Stdio::null());
8187 #[cfg(unix)]
8191 if let Some(handoff) = nonce_handoff {
8192 handoff.install_last(command.as_std_mut());
8193 }
8194 #[cfg(not(any(unix, windows)))]
8195 let _ = nonce_handoff;
8196
8197 #[cfg(windows)]
8202 subc_jobobject::suspend_on_create_async(&mut command);
8203 let mut child = match command.spawn() {
8204 Ok(child) => child,
8205 Err(source) => {
8206 #[cfg(target_os = "linux")]
8207 if let Some(placement) = cgroup_placement {
8208 remove_module_cgroup(placement, &cgroup_name);
8209 }
8210 return Err(SuperviseError::Spawn {
8211 program: spec.program.clone(),
8212 source,
8213 cgroup_path,
8214 });
8215 }
8216 };
8217 #[cfg(target_os = "macos")]
8222 drop(exec_ack);
8223
8224 #[cfg(windows)]
8229 let windows_image =
8230 Arc::new(
8231 image_capture
8232 .bind_tokio(&child)
8233 .map_err(|source| SuperviseError::Spawn {
8234 program: spec.program.clone(),
8235 source,
8236 cgroup_path: None,
8237 })?,
8238 );
8239 #[cfg(windows)]
8241 let job = contain_spawned_child(&child, spec)?;
8242 let spawned_at_ms = unix_ms_now();
8243 #[cfg(not(windows))]
8244 let spawned_from = spec.program.clone();
8245 #[cfg(windows)]
8246 let spawned_from = windows_image.path().to_path_buf();
8247 #[cfg(not(windows))]
8248 let spawned_file_identity = spawned_file_identity(&spawned_from);
8249 #[cfg(windows)]
8250 let spawned_file_identity = Some(SpawnedFileIdentity {
8251 device: 0,
8252 inode: 0,
8253 windows_image: Some(Arc::clone(&windows_image)),
8254 });
8255 let pid = child.id().ok_or_else(|| SuperviseError::Spawn {
8256 program: spec.program.clone(),
8257 source: io::Error::other("spawned child exposed no live pid"),
8258 cgroup_path: cgroup_path.clone(),
8259 })?;
8260 #[cfg(windows)]
8261 let nonce_delivery = start_windows_nonce_pipe(nonce_handoff, pid, spec);
8262 #[cfg(not(windows))]
8263 let process_start_time = crate::provenance::process_start_time(pid);
8264 #[cfg(windows)]
8265 let process_start_time = Some(windows_image.start_time());
8266 let process_identity = process_start_time.map(|start_time| ProcessIdentity { pid, start_time });
8267 #[cfg(all(test, target_os = "macos"))]
8268 privacy_exec_boundary_tests::before_image_sample(spec, pid);
8269 #[cfg(not(windows))]
8275 let recorded_image = observe_spawned_image(pid);
8276 #[cfg(target_os = "macos")]
8280 let recorded_image = if privacy_exec.is_some() {
8281 None
8282 } else {
8283 recorded_image
8284 };
8285 #[cfg(target_os = "linux")]
8286 let recorded_cgroup_name = cgroup_path.as_ref().map(|_| cgroup_name.clone());
8287 #[cfg(not(target_os = "linux"))]
8288 let recorded_cgroup_name = None;
8289 let roster_guard = roster.admit(
8290 spec.module_id.clone(),
8291 pid,
8292 spec.protocol,
8293 process_start_time,
8294 crate::child_roster::RecordedIdentity {
8295 #[cfg(not(windows))]
8296 start_time: recorded_image.map(|image| image.start_time),
8297 #[cfg(windows)]
8298 start_time: process_start_time,
8299 #[cfg(not(windows))]
8300 executable: recorded_image
8301 .and_then(|image| image.executable)
8302 .map(crate::live_children::ExecutableIdentity::from),
8303 #[cfg(windows)]
8304 executable: Some(crate::live_children::ExecutableIdentity::from(
8305 windows_image.identity(),
8306 )),
8307 cgroup_name: recorded_cgroup_name,
8308 #[cfg(target_os = "linux")]
8309 cgroup_placement: cgroup_placement.cloned(),
8310 },
8311 );
8312 if roster.is_closed() {
8321 #[cfg(target_os = "linux")]
8323 kill_module_cgroup(cgroup_placement, &cgroup_name);
8324 if let Err(error) = child.start_kill() {
8325 debug!(module_id = %spec.module_id, pid, %error, "kill of a process spawned during daemon shutdown failed; it may already have exited");
8326 }
8327 #[cfg(target_os = "linux")]
8328 if let Some(placement) = cgroup_placement {
8329 while matches!(child.try_wait(), Ok(None)) {
8333 std::thread::yield_now();
8334 }
8335 if matches!(
8336 subc_cgroup::kill_module(Some(placement), &cgroup_name),
8337 subc_cgroup::KillOutcome::Killed
8338 ) {
8339 if let Ok(path) = placement.module_path(&cgroup_name) {
8340 while std::fs::read_to_string(path.join("cgroup.events"))
8341 .ok()
8342 .is_some_and(|events| events.lines().any(|line| line == "populated 1"))
8343 {
8344 std::thread::yield_now();
8345 }
8346 }
8347 }
8348 remove_module_cgroup(placement, &cgroup_name);
8349 }
8350 drop(roster_guard);
8351 return Err(SuperviseError::Spawn {
8352 program: spec.program.clone(),
8353 source: io::Error::other(
8354 "the daemon began shutting down while this process was starting; ended it",
8355 ),
8356 cgroup_path,
8357 });
8358 }
8359
8360 let stdout_pump = match child.stdout.take() {
8361 Some(stdout) => Some(tokio::spawn(pump_stdout_to(stdout, output_sink.clone()))),
8362 None => {
8363 warn!(
8364 module_id = %spec.module_id,
8365 "spawned child exposed no stdout pipe; file capture will be incomplete"
8366 );
8367 None
8368 }
8369 };
8370 let stderr_pump = match child.stderr.take() {
8371 Some(stderr) => {
8372 let generation = ring
8373 .lock()
8374 .unwrap_or_else(|poisoned| poisoned.into_inner())
8375 .begin_process();
8376 Some(StderrPump {
8377 task: tokio::spawn(pump_stderr_to(
8378 stderr,
8379 Arc::clone(ring),
8380 generation,
8381 output_sink,
8382 )),
8383 generation,
8384 })
8385 }
8386 None => {
8387 ring.lock()
8391 .unwrap_or_else(|poisoned| poisoned.into_inner())
8392 .mark_not_captured("stderr pipe was not available on spawn");
8393 warn!(
8394 module_id = %spec.module_id,
8395 "spawned child exposed no stderr pipe; tail will be unavailable"
8396 );
8397 None
8398 }
8399 };
8400
8401 Ok(SupervisedChild {
8402 child,
8403 #[cfg(windows)]
8404 nonce_delivery,
8405 protocol: spec.protocol,
8406 #[cfg(target_os = "linux")]
8407 module_id: cgroup_name,
8408 #[cfg(target_os = "linux")]
8409 cgroup_placement: cgroup_placement.cloned(),
8410 #[cfg(windows)]
8411 job,
8412 stdout_pump,
8413 stderr_pump,
8414 stderr_ring: Arc::clone(ring),
8415 spawned_at_ms,
8416 spawned_from,
8417 spawned_file_identity,
8418 process_start_time,
8419 process_identity,
8420 pid,
8421 roster_guard: Some(roster_guard),
8422 #[cfg(target_os = "macos")]
8423 privacy_exec,
8424 #[cfg(target_os = "macos")]
8425 report_ready: Arc::new(OnceLock::new()),
8426 spawn_failure: None,
8427 })
8428}
8429
8430#[cfg(target_os = "linux")]
8431pub(crate) fn kill_module_cgroup(placement: Option<&subc_cgroup::Placement>, module_id: &str) {
8432 use subc_cgroup::KillOutcome;
8433 match subc_cgroup::kill_module(placement, module_id) {
8434 KillOutcome::Killed => {}
8435 KillOutcome::NotPlaced | KillOutcome::Unsupported => {
8436 debug!(
8437 module_id,
8438 "cgroup tree kill unavailable; using direct-child kill"
8439 );
8440 }
8441 KillOutcome::IoError { path, error } => {
8442 warn!(module_id, path = %path.display(), %error, "cgroup tree kill failed; using direct-child kill");
8443 }
8444 }
8445}
8446
8447#[cfg(windows)]
8461fn contain_spawned_child(
8462 child: &Child,
8463 spec: &ModuleSpec,
8464) -> Result<Option<subc_jobobject::JobObject>, SuperviseError> {
8465 let module_id = spec.module_id.as_str();
8466 let Some(pid) = child.id() else {
8467 warn!(
8470 module_id,
8471 "spawned child had already exited before containment; no job object attached"
8472 );
8473 return Ok(None);
8474 };
8475
8476 let job = match subc_jobobject::JobObject::new() {
8477 Ok(job) => job,
8478 Err(source) => {
8479 warn!(
8480 module_id,
8481 error = %source,
8482 "could not create a job object; this module's helper processes will not be \
8483 reaped on teardown"
8484 );
8485 resume_suspended_child(pid, spec)?;
8488 return Ok(None);
8489 }
8490 };
8491
8492 if let Err(source) = job.assign(child) {
8493 warn!(
8494 module_id,
8495 error = %source,
8496 "could not assign the child to its job object; this module's helper processes \
8497 will not be reaped on teardown"
8498 );
8499 resume_suspended_child(pid, spec)?;
8500 return Ok(None);
8501 }
8502
8503 resume_suspended_child(pid, spec)?;
8504 Ok(Some(job))
8505}
8506
8507#[cfg(windows)]
8512fn resume_suspended_child(pid: u32, spec: &ModuleSpec) -> Result<(), SuperviseError> {
8513 if let Err(source) = subc_jobobject::resume_main_thread(pid) {
8514 let _ = std::process::Command::new("taskkill.exe")
8518 .args(["/PID", &pid.to_string(), "/T", "/F"])
8519 .stdin(Stdio::null())
8520 .stdout(Stdio::null())
8521 .stderr(Stdio::null())
8522 .status();
8523 return Err(SuperviseError::Spawn {
8524 program: spec.program.clone(),
8525 source,
8526 cgroup_path: None,
8527 });
8528 }
8529 Ok(())
8530}
8531
8532#[cfg(target_os = "linux")]
8533fn remove_module_cgroup(placement: &subc_cgroup::Placement, module_id: &str) {
8534 match placement.remove_module(module_id) {
8535 Ok(()) => debug!(module_id, "removed module cgroup after process exit"),
8536 Err(error) => warn!(
8537 module_id,
8538 error = %error,
8539 "could not remove module cgroup after process exit; continuing teardown"
8540 ),
8541 }
8542}
8543
8544#[cfg(target_os = "linux")]
8545async fn cleanup_reaped_cgroup(placement: &subc_cgroup::Placement, module_id: &str) {
8546 if matches!(
8550 subc_cgroup::kill_module(Some(placement), module_id),
8551 subc_cgroup::KillOutcome::Killed
8552 ) {
8553 if let Ok(path) = placement.module_path(module_id) {
8554 while std::fs::read_to_string(path.join("cgroup.events"))
8555 .ok()
8556 .is_some_and(|events| events.lines().any(|line| line == "populated 1"))
8557 {
8558 sleep(Duration::from_millis(1)).await;
8559 }
8560 }
8561 }
8562 remove_module_cgroup(placement, module_id);
8563}
8564
8565#[cfg(target_os = "linux")]
8566fn apply_cgroup_placement(
8567 command: &mut Command,
8568 spec: &ModuleSpec,
8569 path: &std::path::Path,
8570) -> Result<(), SuperviseError> {
8571 subc_cgroup::apply(command, path).map_err(|source| SuperviseError::Cgroup {
8572 module_id: spec.module_id.clone(),
8573 source,
8574 })
8575}
8576
8577fn capture_retention(spec: &ModuleSpec) -> Retention {
8578 let defaults = Retention::default();
8579 let value = |name: &str| {
8580 spec.env
8581 .iter()
8582 .rev()
8583 .find_map(|(key, value)| (key == name).then_some(value.as_str()))
8584 };
8585 Retention {
8586 max_file_mb: value(CAPTURE_MAX_FILE_MB_ENV)
8587 .and_then(|value| value.parse().ok())
8588 .unwrap_or(defaults.max_file_mb),
8589 keep: value(CAPTURE_KEEP_ENV)
8590 .and_then(|value| value.parse().ok())
8591 .unwrap_or(defaults.keep),
8592 max_age_days: value(CAPTURE_MAX_AGE_DAYS_ENV)
8593 .and_then(|value| value.parse().ok())
8594 .unwrap_or(defaults.max_age_days),
8595 }
8596}
8597
8598fn generate_launch_nonce() -> Result<String, SuperviseError> {
8601 let mut bytes = [0u8; 32];
8602 getrandom::getrandom(&mut bytes).map_err(|source| SuperviseError::LaunchNonce {
8603 reason: source.to_string(),
8604 })?;
8605 let mut hex = String::with_capacity(64);
8606 for b in bytes {
8607 use std::fmt::Write;
8608 let _ = write!(hex, "{b:02x}");
8609 }
8610 Ok(hex)
8611}
8612
8613fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
8616 if a.len() != b.len() {
8617 return false;
8618 }
8619 let mut diff = 0u8;
8620 for (x, y) in a.iter().zip(b.iter()) {
8621 diff |= x ^ y;
8622 }
8623 diff == 0
8624}
8625
8626#[cfg(not(windows))]
8630fn observe_spawned_image(pid: u32) -> Option<subc_os::Observation> {
8631 subc_os::Process::open(pid)
8632 .ok()
8633 .flatten()
8634 .and_then(|process| process.observe())
8635}
8636
8637#[cfg(windows)]
8640fn windows_spawn_capture(spec: &ModuleSpec) -> io::Result<subc_os::ExecutableCapture> {
8641 let mut program = spec.program.clone();
8642 if program.is_absolute() || program.components().count() > 1 {
8643 match subc_os::ExecutableCapture::open(&program) {
8646 Ok(capture) => return Ok(capture),
8647 Err(error)
8648 if error.kind() == io::ErrorKind::NotFound && program.extension().is_none() => {}
8649 Err(error) => return Err(error),
8650 }
8651 program.set_extension("exe");
8652 return subc_os::ExecutableCapture::open(&program);
8653 }
8654 if program.extension().is_none() {
8655 program.set_extension("exe");
8656 }
8657 let custom_path = spec
8658 .env
8659 .iter()
8660 .rev()
8661 .find(|(key, _)| key.eq_ignore_ascii_case("PATH"));
8662 let mut directories = Vec::new();
8663 if let Some(parent) = std::env::current_exe()?.parent() {
8666 directories.push(parent.to_path_buf());
8667 }
8668 directories.push(std::env::current_dir()?);
8669 if let Some(windows) = std::env::var_os("SystemRoot") {
8670 let windows = PathBuf::from(windows);
8671 directories.push(windows.join("System32"));
8672 directories.push(windows.clone());
8673 }
8674 let path = custom_path
8675 .map(|(_, value)| std::ffi::OsString::from(value))
8676 .or_else(|| std::env::var_os("PATH"));
8677 if let Some(path) = path {
8678 directories.extend(std::env::split_paths(&path));
8679 }
8680 for directory in directories {
8681 match subc_os::ExecutableCapture::open(&directory.join(&program)) {
8682 Ok(capture) => return Ok(capture),
8683 Err(error) if error.kind() == io::ErrorKind::NotFound => continue,
8684 Err(error) => return Err(error),
8685 }
8686 }
8687 Err(io::Error::new(
8688 io::ErrorKind::NotFound,
8689 "resolved executable not found",
8690 ))
8691}
8692
8693fn spawn_and_mark_running(
8694 spec: &ModuleSpec,
8695 runtime: &SupervisorRuntimeConfig,
8696 snapshot: &SharedSnapshot,
8697) -> Result<SupervisedChild, SuperviseError> {
8698 let activity_spawn = runtime.begin_activity_spawn(&spec.module_id);
8699 let child = spawn_child(
8700 spec,
8701 runtime.connection_file_path.as_deref(),
8702 runtime.supervisor_handle.as_ref(),
8703 &runtime.stderr_ring,
8704 runtime.capture_logs_dir.as_deref(),
8705 &runtime.child_roster,
8706 #[cfg(target_os = "linux")]
8707 runtime.cgroup_placement.as_ref(),
8708 )?;
8709 set_running(snapshot, &child, &spec.module_id, &runtime.spawn_events)?;
8710 if let Some(spawn) = activity_spawn {
8711 spawn.complete(snapshot);
8712 }
8713 Ok(child)
8714}
8715
8716enum RegistrationWaitOutcome {
8717 Registered,
8718 Exited(ExitReport),
8719 TimedOut,
8720}
8721
8722struct ReloadRegistrationFailure {
8723 exit_report: ExitReport,
8724 reason: String,
8725}
8726
8727#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8728enum BusyGaugeObservation {
8729 Quiescent,
8730 Busy,
8731 Omitted,
8732}
8733
8734fn busy_gauge_observation(metrics: Option<&Value>, gauges: &[String]) -> BusyGaugeObservation {
8735 let Some(metrics) = metrics.and_then(Value::as_object) else {
8736 return BusyGaugeObservation::Omitted;
8737 };
8738 let mut sum = 0u128;
8739 for gauge in gauges {
8740 let Some(value) = metrics.get(gauge) else {
8741 return BusyGaugeObservation::Omitted;
8742 };
8743 let Some(value) = value.as_u64() else {
8744 return BusyGaugeObservation::Busy;
8745 };
8746 sum = sum.saturating_add(u128::from(value));
8747 }
8748 if sum == 0 {
8749 BusyGaugeObservation::Quiescent
8750 } else {
8751 BusyGaugeObservation::Busy
8752 }
8753}
8754
8755fn declared_busy_gauges(
8756 registry: &Registry,
8757 module_id: &str,
8758) -> Result<Vec<String>, SuperviseError> {
8759 busy_gauges_of(
8760 registry
8761 .get_module(module_id)
8762 .map_err(SuperviseError::Registry)?,
8763 )
8764}
8765
8766fn declared_busy_gauges_for_connection(
8770 registry: &Registry,
8771 connection_id: ConnectionId,
8772) -> Result<Vec<String>, SuperviseError> {
8773 busy_gauges_of(
8774 registry
8775 .get_module_by_connection(connection_id)
8776 .map_err(SuperviseError::Registry)?,
8777 )
8778}
8779
8780fn busy_gauges_of(
8781 registration: Option<crate::registry::ModuleRegistration>,
8782) -> Result<Vec<String>, SuperviseError> {
8783 let Some(registration) = registration else {
8784 return Ok(Vec::new());
8785 };
8786 let Some(self_signals) = registration.manifest.self_signals else {
8787 return Ok(Vec::new());
8788 };
8789
8790 let mut gauges = Vec::new();
8791 for declaration in self_signals {
8792 if declaration.kind != SelfSignalKind::Busy {
8793 continue;
8794 }
8795 match declaration.anchored_to {
8796 SignalAnchor::HealthGauges { gauges: declared } if !declared.is_empty() => {
8797 gauges.extend(declared)
8798 }
8799 _ => {
8800 gauges.push(String::new());
8803 }
8804 }
8805 }
8806 Ok(gauges)
8807}
8808
8809async fn wait_for_forwarding_quiescence(
8814 forwarding: &ForwardingTable,
8815 module_id: &str,
8816 runtime: &SupervisorRuntimeConfig,
8817 endpoint: crate::ModuleEndpointId,
8818 deadline: Instant,
8819 busy_gauges: &[String],
8820 scope: DrainScope,
8821) -> Result<bool, SuperviseError> {
8822 let mut gauges_quiescent = busy_gauges.is_empty();
8823 let mut next_probe_at = Instant::now();
8824 let mut omission_counted = false;
8825
8826 loop {
8827 let now = Instant::now();
8828 if !busy_gauges.is_empty() && now >= next_probe_at && now < deadline {
8829 let report = match scope {
8830 DrainScope::Active => probe_module_health(module_id, runtime, Some(deadline)).await,
8831 DrainScope::Endpoint(endpoint) => {
8832 probe_endpoint_health(endpoint, runtime, Some(deadline)).await
8833 }
8834 };
8835 gauges_quiescent = match report {
8836 Ok(report) => match busy_gauge_observation(report.metrics.as_ref(), busy_gauges) {
8837 BusyGaugeObservation::Quiescent => true,
8838 BusyGaugeObservation::Busy => false,
8839 BusyGaugeObservation::Omitted => {
8840 if !omission_counted {
8841 forwarding
8842 .counters()
8843 .increment_drains_with_undeclared_gauge();
8844 omission_counted = true;
8845 }
8846 false
8847 }
8848 },
8849 Err(err) => {
8850 warn!(
8851 module_id,
8852 error = %err,
8853 "drain health.check did not produce declared busy gauges; treating module as busy"
8854 );
8855 false
8856 }
8857 };
8858 next_probe_at = Instant::now() + runtime.health.cadence.max(REGISTRY_RELEASE_POLL);
8859 }
8860
8861 let in_flight = forwarding
8862 .endpoint_in_flight_count(endpoint)
8863 .map_err(SuperviseError::Forwarding)?;
8864 if in_flight == 0 && gauges_quiescent {
8865 return Ok(true);
8866 }
8867
8868 let now = Instant::now();
8869 if now >= deadline {
8870 return Ok(false);
8871 }
8872 let mut wait = deadline
8873 .saturating_duration_since(now)
8874 .min(REGISTRY_RELEASE_POLL);
8875 if !busy_gauges.is_empty() {
8876 wait = wait.min(next_probe_at.saturating_duration_since(now));
8877 }
8878 sleep(wait).await;
8879 }
8880}
8881
8882fn drained_after_quiescence_wait(wait_result: &Result<bool, SuperviseError>) -> bool {
8890 match wait_result {
8891 Ok(drained) => *drained,
8892 Err(_) => false,
8893 }
8894}
8895
8896fn send_route_goodbyes(forwarding: &ForwardingTable, released_routes: Vec<GoodbyeTarget>) {
8897 for released in released_routes {
8898 let frame = match Frame::build_with_version(
8899 released.negotiated_ver,
8900 FrameType::Goodbye,
8901 control_flags(),
8902 released.channel,
8903 released.epoch,
8904 0,
8905 Vec::new(),
8906 ) {
8907 Ok(frame) => frame,
8908 Err(err) => {
8909 warn!(
8910 route_channel = released.channel,
8911 error = %err,
8912 "failed to build supervisor drain route GOODBYE frame"
8913 );
8914 continue;
8915 }
8916 };
8917 if !released.close_on_delivery_failure() {
8918 crate::forwarding::send_module_route_goodbye(
8919 &forwarding.counters(),
8920 &released.sink,
8921 frame,
8922 released.module_id.as_deref(),
8923 "supervisor drain",
8924 );
8925 continue;
8926 }
8927 if let Err(err) = released.sink.try_send(frame) {
8928 warn!(
8929 target_connection_id = released.connection_id.get(),
8930 route_channel = released.channel,
8931 error = %err,
8932 "supervisor drain route GOODBYE was not delivered to client; closing target connection"
8933 );
8934 let _ = forwarding.escalate_client_delivery_failure(
8935 released.connection_id,
8936 released.channel,
8937 released.epoch,
8938 CloseReason::new(
8939 "route_goodbye_delivery_failed",
8940 format!(
8941 "failed to enqueue supervisor drain route GOODBYE for channel {}: {err}",
8942 released.channel
8943 ),
8944 ),
8945 crate::forwarding::UndeliveredFrame {
8946 module_id: released.module_id.as_deref(),
8947 sink: &released.sink,
8948 },
8949 );
8950 }
8951 }
8952}
8953
8954fn send_module_draining(
8955 module_id: &str,
8956 reason: RouteCloseReason,
8957 deadline_ms: u64,
8958 target: &ModuleDrainTarget,
8959) {
8960 let body = match serde_json::to_vec(&ModuleControlCommand::Draining {
8961 reason,
8962 deadline_ms,
8963 }) {
8964 Ok(body) => body,
8965 Err(err) => {
8966 warn!(
8967 module_id,
8968 error = %err,
8969 "failed to encode module draining command"
8970 );
8971 return;
8972 }
8973 };
8974 let frame = match Frame::build_with_version(
8975 target.negotiated_ver,
8976 FrameType::Push,
8977 control_flags(),
8978 0,
8979 0,
8980 0,
8981 body,
8982 ) {
8983 Ok(frame) => frame,
8984 Err(err) => {
8985 warn!(
8986 module_id,
8987 error = %err,
8988 "failed to build module draining command frame"
8989 );
8990 return;
8991 }
8992 };
8993 if let Err(err) = target.sink.try_send(frame) {
8994 warn!(
8995 module_id,
8996 target_connection_id = target.endpoint.connection_id.get(),
8997 error = %err,
8998 "module draining command was not delivered to peer"
8999 );
9000 }
9001}
9002
9003fn module_goodbye_frame(module_id: &str, negotiated_ver: u8) -> Option<Frame> {
9005 match Frame::build_with_version(
9006 negotiated_ver,
9007 FrameType::Goodbye,
9008 control_flags(),
9009 0,
9010 0,
9011 0,
9012 Vec::new(),
9013 ) {
9014 Ok(frame) => Some(frame),
9015 Err(err) => {
9016 warn!(
9017 module_id,
9018 error = %err,
9019 "failed to build module GOODBYE frame"
9020 );
9021 None
9022 }
9023 }
9024}
9025
9026#[cfg(unix)]
9040async fn send_module_goodbyes_for_daemon_shutdown(
9041 forwarding: &Arc<ForwardingTable>,
9042 reason: &CloseReason,
9043 wait_for_flush: bool,
9044) {
9045 const GOODBYE_BUDGET: Duration = Duration::from_millis(500);
9046 let targets = match forwarding.module_connections() {
9047 Ok(targets) => targets,
9048 Err(err) => {
9049 warn!(error = %err, "could not list module connections for shutdown GOODBYE");
9050 return;
9051 }
9052 };
9053 let deadline = Instant::now() + GOODBYE_BUDGET;
9054 let mut sends = tokio::task::JoinSet::new();
9055 for target in targets {
9056 let Some(frame) = module_goodbye_frame(&target.module_id, target.negotiated_ver) else {
9057 continue;
9058 };
9059 if !wait_for_flush {
9060 if let Err(err) = target.sink.try_send(frame) {
9061 debug!(
9062 module_id = %target.module_id,
9063 error = %err,
9064 "shutdown module GOODBYE was not queued"
9065 );
9066 }
9067 continue;
9068 }
9069 let forwarding = Arc::clone(forwarding);
9070 let reason = reason.clone();
9071 sends.spawn(async move {
9072 match timeout_at(deadline, target.sink.send_flushed(frame)).await {
9073 Ok(Ok(())) => {}
9074 Ok(Err(err)) => debug!(
9075 module_id = %target.module_id,
9076 error = %err,
9077 "module connection closed before its shutdown GOODBYE was written"
9078 ),
9079 Err(_) => warn!(
9080 module_id = %target.module_id,
9081 budget = ?GOODBYE_BUDGET,
9082 "shutdown module GOODBYE was not written within its budget; closing anyway"
9083 ),
9084 }
9085 forwarding.request_connection_close(target.endpoint.connection_id, reason);
9086 });
9087 }
9088 while sends.join_next().await.is_some() {}
9090}
9091
9092fn send_module_goodbye(module_id: &str, forwarding: &ForwardingTable, target: &ModuleDrainTarget) {
9093 let Some(frame) = module_goodbye_frame(module_id, target.negotiated_ver) else {
9094 return;
9095 };
9096 if let Err(err) = target.sink.try_send(frame) {
9097 warn!(
9098 module_id,
9099 target_connection_id = target.endpoint.connection_id.get(),
9100 error = %err,
9101 "supervisor drain module GOODBYE was not delivered to peer; closing module connection"
9102 );
9103 forwarding.request_connection_close(
9104 target.endpoint.connection_id,
9105 CloseReason::new(
9106 "module_goodbye_delivery_failed",
9107 format!("failed to enqueue supervisor drain module GOODBYE for module '{module_id}': {err}"),
9108 ),
9109 );
9110 }
9111}
9112
9113#[derive(Clone, Copy)]
9114struct ForwardingDrainContext<'a> {
9115 spec: &'a ModuleSpec,
9116 runtime: &'a SupervisorRuntimeConfig,
9117 registry: &'a Registry,
9118 scope: DrainScope,
9119}
9120
9121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9123enum DrainScope {
9124 Active,
9127 Endpoint(crate::ModuleEndpointId),
9132}
9133
9134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9142enum StopNotice {
9143 SentOverConnection,
9146 NoConnection,
9150 NotSent,
9154}
9155
9156async fn begin_forwarding_drain(
9157 spec: &ModuleSpec,
9158 runtime: &SupervisorRuntimeConfig,
9159 registry: &Registry,
9160 snapshot: &SharedSnapshot,
9161 enabled: Option<bool>,
9162 reason: RouteCloseReason,
9163) -> Result<StopNotice, SuperviseError> {
9164 let Some(forwarding) = runtime.forwarding.as_ref() else {
9165 return Err(SuperviseError::ReloadUnavailable {
9166 module_id: spec.module_id.clone(),
9167 reason: "supervisor was not configured with a forwarding table".to_string(),
9168 });
9169 };
9170
9171 begin_forwarding_drain_with(
9172 forwarding,
9173 ForwardingDrainContext {
9174 spec,
9175 runtime,
9176 registry,
9177 scope: DrainScope::Active,
9178 },
9179 snapshot,
9180 enabled,
9181 reason,
9182 runtime.drain_timeout,
9183 )
9184 .await
9185}
9186
9187async fn begin_forwarding_drain_if_configured(
9188 spec: &ModuleSpec,
9189 runtime: &SupervisorRuntimeConfig,
9190 registry: &Registry,
9191 snapshot: &SharedSnapshot,
9192 enabled: Option<bool>,
9193 reason: RouteCloseReason,
9194) -> Result<StopNotice, SuperviseError> {
9195 begin_forwarding_drain_with_timeout(
9196 spec,
9197 runtime,
9198 registry,
9199 snapshot,
9200 enabled,
9201 reason,
9202 runtime.drain_timeout,
9203 )
9204 .await
9205}
9206
9207async fn begin_forwarding_drain_with_timeout(
9211 spec: &ModuleSpec,
9212 runtime: &SupervisorRuntimeConfig,
9213 registry: &Registry,
9214 snapshot: &SharedSnapshot,
9215 enabled: Option<bool>,
9216 reason: RouteCloseReason,
9217 drain_timeout: Duration,
9218) -> Result<StopNotice, SuperviseError> {
9219 let Some(forwarding) = runtime.forwarding.as_ref() else {
9220 return Ok(StopNotice::NotSent);
9221 };
9222
9223 begin_forwarding_drain_with(
9224 forwarding,
9225 ForwardingDrainContext {
9226 spec,
9227 runtime,
9228 registry,
9229 scope: DrainScope::Active,
9230 },
9231 snapshot,
9232 enabled,
9233 reason,
9234 drain_timeout,
9235 )
9236 .await
9237}
9238
9239async fn begin_forwarding_drain_with(
9240 forwarding: &ForwardingTable,
9241 context: ForwardingDrainContext<'_>,
9242 snapshot: &SharedSnapshot,
9243 enabled: Option<bool>,
9244 reason: RouteCloseReason,
9245 drain_timeout: Duration,
9246) -> Result<StopNotice, SuperviseError> {
9247 let ForwardingDrainContext {
9248 spec,
9249 runtime,
9250 registry,
9251 scope,
9252 } = context;
9253 debug_assert_ne!(reason, RouteCloseReason::Crash);
9254 let terminal = matches!(reason, RouteCloseReason::Disable);
9255 let drain_started_at = Instant::now();
9256 let drain_deadline = drain_started_at + drain_timeout;
9257 let deadline_ms =
9258 unix_ms_now().saturating_add(u64::try_from(drain_timeout.as_millis()).unwrap_or(u64::MAX));
9259 let busy_gauges = match scope {
9260 DrainScope::Active => declared_busy_gauges(registry, &spec.module_id)?,
9261 DrainScope::Endpoint(endpoint) => {
9262 declared_busy_gauges_for_connection(registry, endpoint.connection_id)?
9263 }
9264 };
9265
9266 let gate_started = Instant::now();
9269 let drain_target = match scope {
9270 DrainScope::Active => forwarding.begin_module_drain(&spec.module_id, reason),
9271 DrainScope::Endpoint(endpoint) => forwarding.begin_endpoint_drain(endpoint, reason),
9272 }
9273 .map_err(SuperviseError::Forwarding)?;
9274 info!(
9279 module_id = %spec.module_id,
9280 ?reason,
9281 gate_ms = u64::try_from(gate_started.elapsed().as_millis()).unwrap_or(u64::MAX),
9282 connected = drain_target.is_some(),
9283 "module drain began; route admission closed"
9284 );
9285 if scope == DrainScope::Active {
9286 update_snapshot(snapshot, Some(&spec.module_id), |state| {
9287 state.state = ModuleState::Draining;
9288 state.draining_to_replace =
9289 matches!(reason, RouteCloseReason::Restart | RouteCloseReason::Reload);
9290 if let Some(enabled) = enabled {
9291 state.enabled = enabled;
9292 }
9293 })?;
9294 }
9295
9296 let Some(target) = drain_target.as_ref() else {
9297 return Ok(StopNotice::NoConnection);
9301 };
9302 {
9303 send_module_draining(&spec.module_id, reason, deadline_ms, target);
9304 let routes = forwarding
9305 .endpoint_routes(target.endpoint)
9306 .map_err(SuperviseError::Forwarding)?;
9307 let routes_notified = routes.len();
9308 crate::control::send_route_control_pushes(
9309 forwarding,
9310 routes.clone(),
9311 ClientControlPush::RouteClosing {
9312 module_id: spec.module_id.clone(),
9313 channels: Vec::new(),
9314 reason,
9315 },
9316 );
9317 send_route_goodbyes(forwarding, target.abandoned_bindings.clone());
9318
9319 let wait_result = wait_for_forwarding_quiescence(
9325 forwarding,
9326 &spec.module_id,
9327 runtime,
9328 target.endpoint,
9329 drain_deadline,
9330 &busy_gauges,
9331 scope,
9332 )
9333 .await;
9334 let drained = drained_after_quiescence_wait(&wait_result);
9335 if let Err(err) = &wait_result {
9336 error!(
9337 module_id = %spec.module_id,
9338 ?reason,
9339 error = %err,
9340 "forwarding quiescence wait failed after route.closing; forcing route.closed(drained: false) so the client is not left waiting on an unfulfilled promise"
9341 );
9342 } else if !drained {
9343 let holdouts = forwarding
9349 .endpoint_drain_holdouts(target.endpoint)
9350 .unwrap_or_default();
9351 warn!(
9352 module_id = %spec.module_id,
9353 waited = ?drain_timeout,
9354 ?reason,
9355 held_requests = holdouts.requests,
9356 held_routes = holdouts.routes,
9357 total_routes = holdouts.total_routes,
9358 top_connections = ?holdouts.top_connections,
9359 held = %holdouts
9362 .held
9363 .iter()
9364 .map(|(channel, corr)| format!("{channel}:{corr}"))
9365 .collect::<Vec<_>>()
9366 .join(","),
9367 "route drain timed out before request quiescence; forcing teardown"
9368 );
9369 }
9370 crate::control::send_route_control_pushes(
9371 forwarding,
9372 routes,
9373 ClientControlPush::RouteClosed {
9374 module_id: spec.module_id.clone(),
9375 channels: Vec::new(),
9376 reason,
9377 drained,
9378 abandoned: target.abandoned_bindings.len() as u32,
9379 excluded_subscriptions: target.excluded_subscriptions,
9380 terminal: Some(terminal),
9381 },
9382 );
9383 wait_result?;
9384
9385 let released_routes = match forwarding.release_module_endpoint_routes(target.endpoint) {
9391 Ok(routes) => routes,
9392 Err(err) => {
9393 warn!(
9394 module_id = %spec.module_id,
9395 ?reason,
9396 error = %err,
9397 "failed to release module endpoint routes after route.closed; module GOODBYE will still be sent"
9398 );
9399 send_module_goodbye(&spec.module_id, forwarding, target);
9400 return Err(SuperviseError::Forwarding(err));
9401 }
9402 };
9403 let route_goodbye_count = released_routes.len();
9404 send_route_goodbyes(forwarding, released_routes);
9405 send_module_goodbye(&spec.module_id, forwarding, target);
9406
9407 info!(
9413 module_id = %spec.module_id,
9414 ?reason,
9415 routes_notified,
9416 route_goodbyes = route_goodbye_count,
9417 abandoned_reservations = target.abandoned_bindings.len(),
9418 excluded_subscriptions = target.excluded_subscriptions,
9419 drained,
9420 "module drain complete; consumers notified via route.closing/route.closed pushes and per-route GOODBYE frames"
9421 );
9422 }
9423
9424 Ok(StopNotice::SentOverConnection)
9425}
9426
9427async fn wait_for_registration_after_reload(
9430 registry: &Registry,
9431 module_id: &str,
9432 snapshot: &SharedSnapshot,
9433 child: &mut SupervisedChild,
9434 wait: Duration,
9435) -> Result<RegistrationWaitOutcome, SuperviseError> {
9436 wait_for_slot_registration(
9437 registry,
9438 crate::registry::RegistrationSlot::Active(module_id),
9439 module_id,
9440 snapshot,
9441 child,
9442 wait,
9443 )
9444 .await
9445}
9446
9447async fn wait_for_slot_registration(
9455 registry: &Registry,
9456 slot: crate::registry::RegistrationSlot<'_>,
9457 module_id: &str,
9458 snapshot: &SharedSnapshot,
9459 child: &mut SupervisedChild,
9460 wait: Duration,
9461) -> Result<RegistrationWaitOutcome, SuperviseError> {
9462 let deadline = Instant::now() + wait;
9463 loop {
9464 if registry
9465 .registration(slot)
9466 .map_err(SuperviseError::Registry)?
9467 .is_some()
9468 {
9469 return Ok(RegistrationWaitOutcome::Registered);
9470 }
9471
9472 let now = Instant::now();
9473 if now >= deadline {
9474 return Ok(RegistrationWaitOutcome::TimedOut);
9475 }
9476 let remaining = deadline.saturating_duration_since(now);
9477 let poll = remaining.min(REGISTRY_RELEASE_POLL);
9478
9479 tokio::select! {
9480 wait_result = child.wait() => {
9481 let status = wait_result.map_err(|source| SuperviseError::Wait {
9482 module_id: module_id.to_string(),
9483 source,
9484 })?;
9485 return Ok(RegistrationWaitOutcome::Exited(classify_reaped_child_exit(
9486 snapshot,
9487 child,
9488 &status,
9489 )));
9490 }
9491 _ = sleep(poll) => {}
9492 }
9493 }
9494}
9495
9496fn registration_failure_exit_report(mut exit_report: ExitReport) -> ExitReport {
9497 if exit_report.kind != ExitKind::DeliberateSeverance {
9500 exit_report.kind = ExitKind::Crash;
9501 }
9502 exit_report
9503}
9504
9505async fn handle_reload_child_registration_failure(
9506 spec: &ModuleSpec,
9507 runtime: &SupervisorRuntimeConfig,
9508 registry: &Registry,
9509 process_liveness: &SupervisorProcessLiveness,
9510 snapshot: &SharedSnapshot,
9511 _child: &mut Option<SupervisedChild>,
9512 failure: ReloadRegistrationFailure,
9513) -> Result<(), SuperviseError> {
9514 let ReloadRegistrationFailure {
9515 exit_report,
9516 reason,
9517 } = failure;
9518 match on_child_exit(
9519 spec,
9520 runtime.restart_policy,
9521 registry,
9522 snapshot,
9523 &runtime.terminal_ring,
9524 &runtime.spawn_events,
9525 &runtime.child_roster,
9526 exit_report,
9527 )
9528 .await
9529 {
9530 NextAction::Stop {
9531 registration_released,
9532 } => {
9533 if registration_released {
9534 process_liveness.untrack_if_current(&spec.module_id, snapshot);
9535 }
9536 }
9537 NextAction::Restart { schedule } => {
9538 let delay = schedule.map_or(runtime.restart_policy.delay_for_restart(0), |schedule| {
9539 schedule.delay
9540 });
9541 if let Some(schedule) = schedule {
9542 log_crash_respawn(&spec.module_id, schedule);
9543 }
9544 schedule_respawn(
9545 runtime,
9546 snapshot,
9547 &spec.module_id,
9548 delay,
9549 RespawnKind::Spawn,
9550 )?;
9551 }
9552 }
9553 Err(SuperviseError::ReloadFailed {
9554 module_id: spec.module_id.clone(),
9555 reason,
9556 })
9557}
9558
9559async fn handle_reload_spawn_failure(
9560 spec: &ModuleSpec,
9561 runtime: &SupervisorRuntimeConfig,
9562 process_liveness: &SupervisorProcessLiveness,
9563 snapshot: &SharedSnapshot,
9564 _child: &mut Option<SupervisedChild>,
9565 reason: String,
9566) -> Result<(), SuperviseError> {
9567 let now = Instant::now();
9568 let mut schedule = None;
9569 update_snapshot(snapshot, Some(&spec.module_id), |state| {
9570 clear_current_process_facts(state);
9571 if state.enabled {
9572 schedule = state.next_crash_restart(&runtime.restart_policy, now);
9573 state.state = if schedule.is_some() {
9574 ModuleState::Restarting
9575 } else {
9576 ModuleState::Failed
9577 };
9578 } else {
9579 state.state = ModuleState::Disabled;
9580 }
9581 })?;
9582 if let Some(schedule) = schedule {
9583 schedule_respawn(
9584 runtime,
9585 snapshot,
9586 &spec.module_id,
9587 schedule.delay,
9588 RespawnKind::Spawn,
9589 )?;
9590 } else {
9591 process_liveness.untrack_if_current(&spec.module_id, snapshot);
9592 }
9593 Err(SuperviseError::ReloadFailed {
9594 module_id: spec.module_id.clone(),
9595 reason,
9596 })
9597}
9598
9599fn control_flags() -> Flags {
9600 Flags::new(false, Priority::Passive, false)
9601}
9602
9603#[allow(clippy::too_many_arguments)]
9604async fn drain_optional_child(
9605 module_id: &str,
9606 protocol: ModuleProtocol,
9607 stop_notice: StopNotice,
9608 registry: &Registry,
9609 forwarding: Option<&ForwardingTable>,
9610 snapshot: &SharedSnapshot,
9611 terminal_ring: &Arc<Mutex<TerminalRing>>,
9612 spawn_events: &SpawnEventFeed,
9613 child: &mut Option<SupervisedChild>,
9614 drain_timeout: Duration,
9615 final_state: ModuleState,
9616 enabled: Option<bool>,
9617) -> Result<(), SuperviseError> {
9618 if let Some(child) = child.take() {
9619 drain_child_to_state(
9620 module_id,
9621 protocol,
9622 stop_notice,
9623 registry,
9624 forwarding,
9625 snapshot,
9626 terminal_ring,
9627 spawn_events,
9628 child,
9629 drain_timeout,
9630 final_state,
9631 enabled,
9632 )
9633 .await
9634 } else {
9635 update_snapshot(snapshot, Some(module_id), |state| {
9636 state.state = final_state;
9637 if let Some(enabled) = enabled {
9638 state.enabled = enabled;
9639 }
9640 clear_current_process_facts(state);
9641 })?;
9642 release_dead_registration(registry, forwarding, snapshot, module_id).await
9643 }
9644}
9645
9646#[allow(clippy::too_many_arguments)]
9647async fn drain_child_to_state(
9648 module_id: &str,
9649 _protocol: ModuleProtocol,
9650 stop_notice: StopNotice,
9651 registry: &Registry,
9652 forwarding: Option<&ForwardingTable>,
9653 snapshot: &SharedSnapshot,
9654 terminal_ring: &Arc<Mutex<TerminalRing>>,
9655 spawn_events: &SpawnEventFeed,
9656 mut child: SupervisedChild,
9657 drain_timeout: Duration,
9658 final_state: ModuleState,
9659 enabled: Option<bool>,
9660) -> Result<(), SuperviseError> {
9661 let protocol = child.protocol;
9662 update_snapshot(snapshot, Some(module_id), |state| {
9663 state.state = ModuleState::Draining;
9664 state.draining_to_replace = final_state == ModuleState::Restarting;
9665 if let Some(enabled) = enabled {
9666 state.enabled = enabled;
9667 }
9668 })?;
9669
9670 if stop_notice != StopNotice::SentOverConnection {
9681 if protocol == ModuleProtocol::Subc && stop_notice == StopNotice::NoConnection {
9682 info!(
9683 module_id,
9684 pid = child.pid,
9685 budget_ms = u64::try_from(drain_timeout.as_millis()).unwrap_or(u64::MAX),
9686 "module has no connection yet; requesting stop by signal"
9687 );
9688 }
9689 request_graceful_stop(module_id, &child);
9690 }
9691
9692 let exit_report = match timeout(drain_timeout, child.wait()).await {
9693 Ok(Ok(status)) => classify_reaped_child_exit(snapshot, &child, &status),
9694 Ok(Err(source)) => {
9695 fail_after_wait_error(
9696 module_id,
9697 snapshot,
9698 terminal_ring,
9699 spawn_events,
9700 format!("failed to wait during drain: {source}"),
9701 );
9702 return Err(SuperviseError::Wait {
9703 module_id: module_id.to_string(),
9704 source,
9705 });
9706 }
9707 Err(_) => {
9708 warn!(
9721 module_id,
9722 pid = child.pid,
9723 budget_ms = u64::try_from(drain_timeout.as_millis()).unwrap_or(u64::MAX),
9724 reason = ?final_state,
9725 ?stop_notice,
9726 "drain budget expired before the module exited; killing it"
9727 );
9728 child.start_kill().map_err(|source| {
9729 fail_after_wait_error(
9730 module_id,
9731 snapshot,
9732 terminal_ring,
9733 spawn_events,
9734 format!("failed to kill during drain: {source}"),
9735 );
9736 SuperviseError::Kill {
9737 module_id: module_id.to_string(),
9738 source,
9739 }
9740 })?;
9741 let status = child.wait().await.map_err(|source| {
9742 fail_after_wait_error(
9743 module_id,
9744 snapshot,
9745 terminal_ring,
9746 spawn_events,
9747 format!("failed to wait after drain kill: {source}"),
9748 );
9749 SuperviseError::Wait {
9750 module_id: module_id.to_string(),
9751 source,
9752 }
9753 })?;
9754 classify_reaped_child_exit(snapshot, &child, &status)
9755 }
9756 };
9757
9758 let detail = lock_snapshot(snapshot)?.drain_disposition_detail.take();
9759 publish_after_terminal_record(
9760 module_id,
9761 || {
9762 record_terminal_with_detail(
9763 module_id,
9764 terminal_ring,
9765 spawn_events,
9766 &exit_report,
9767 terminal_disposition(final_state),
9768 detail,
9769 )
9770 },
9771 || {
9772 update_snapshot(snapshot, Some(module_id), |state| {
9773 state.state = final_state;
9774 if let Some(enabled) = enabled {
9775 state.enabled = enabled;
9776 }
9777 clear_current_process_facts(state);
9778 state.last_exit = Some(exit_report.clone());
9779 if exit_report.kind == ExitKind::DeliberateSeverance {
9780 state.lifetime_restarts += 1;
9781 }
9782 })
9783 },
9784 )?;
9785 child.drain_stderr(module_id).await;
9786
9787 release_dead_registration(registry, forwarding, snapshot, module_id).await
9788}
9789
9790#[cfg(unix)]
9810fn request_graceful_stop(module_id: &str, child: &SupervisedChild) {
9811 let Some(pid) = child
9812 .id()
9813 .and_then(|pid| i32::try_from(pid).ok())
9814 .and_then(rustix::process::Pid::from_raw)
9815 else {
9816 debug!(
9817 module_id,
9818 "no pid to signal for teardown; falling through to the drain wait"
9819 );
9820 return;
9821 };
9822 match rustix::process::kill_process(pid, rustix::process::Signal::TERM) {
9823 Ok(()) => debug!(
9824 module_id,
9825 "sent SIGTERM to a module nothing else asked to stop"
9826 ),
9827 Err(err) => debug!(
9828 module_id,
9829 error = %err,
9830 "SIGTERM to module failed; the drain wait and kill still apply"
9831 ),
9832 }
9833}
9834
9835#[cfg(not(unix))]
9843fn request_graceful_stop(module_id: &str, _child: &SupervisedChild) {
9844 debug!(
9845 module_id,
9846 "no graceful stop signal exists on this platform; teardown of a module nothing asked to stop waits, then kills"
9847 );
9848}
9849
9850fn terminal_disposition(final_state: ModuleState) -> TerminalDisposition {
9851 match final_state {
9852 ModuleState::Stopped => TerminalDisposition::Stopped,
9853 ModuleState::Disabled => TerminalDisposition::Disabled,
9854 ModuleState::Restarting => TerminalDisposition::Restarting,
9855 ModuleState::Failed => TerminalDisposition::Failed,
9856 ModuleState::Starting
9857 | ModuleState::Running
9858 | ModuleState::Unresponsive
9859 | ModuleState::Draining => {
9860 unreachable!("terminal exits only finish in terminal or restarting states")
9861 }
9862 }
9863}
9864
9865async fn release_dead_registration(
9873 registry: &Registry,
9874 forwarding: Option<&ForwardingTable>,
9875 snapshot: &SharedSnapshot,
9876 module_id: &str,
9877) -> Result<(), SuperviseError> {
9878 let result = async {
9879 let registration = registry
9880 .get_module(module_id)
9881 .map_err(SuperviseError::Registry)?;
9882 match wait_for_registration_release(registry, module_id, REGISTRY_RELEASE_TIMEOUT).await {
9883 Ok(()) => return Ok(()),
9884 Err(SuperviseError::RegistrationStillActive { .. }) => {}
9885 Err(err) => return Err(err),
9886 }
9887 let pid = lock_snapshot(snapshot)?.reaped_pid;
9888 if let (Some(registration), Some(forwarding), Some(pid)) = (registration, forwarding, pid) {
9889 warn!(
9890 module_id,
9891 pid,
9892 connection_id = registration.connection_id.get(),
9893 "reaped module registration outlived release grace; closing dead connection"
9894 );
9895 forwarding.request_connection_close(
9896 registration.connection_id,
9897 CloseReason::new(
9898 "supervised_process_reaped",
9899 format!("module '{module_id}' pid {pid} exited"),
9900 ),
9901 );
9902 wait_for_slot_registration_release(
9903 registry,
9904 crate::registry::RegistrationSlot::Connection(registration.connection_id),
9905 REGISTRY_RELEASE_TIMEOUT,
9906 )
9907 .await?;
9908 }
9909 wait_for_registration_release(registry, module_id, Duration::ZERO).await
9910 }
9911 .await;
9912 if let Err(err) = &result {
9913 fail_snapshot(snapshot, Some(module_id), None);
9914 error!(module_id, error = %err, "registration release failed after child exit; module is failed and start can retry");
9915 }
9916 result
9917}
9918
9919async fn wait_for_registration_release(
9922 registry: &Registry,
9923 module_id: &str,
9924 wait: Duration,
9925) -> Result<(), SuperviseError> {
9926 wait_for_slot_registration_release(
9927 registry,
9928 crate::registry::RegistrationSlot::Active(module_id),
9929 wait,
9930 )
9931 .await
9932}
9933
9934async fn wait_for_slot_registration_release(
9942 registry: &Registry,
9943 slot: crate::registry::RegistrationSlot<'_>,
9944 wait: Duration,
9945) -> Result<(), SuperviseError> {
9946 let deadline = Instant::now() + wait;
9947 let mut release_events = registration_release_events().subscribe();
9948 let still_active = |registration: &crate::registry::ModuleRegistration| {
9949 SuperviseError::RegistrationStillActive {
9950 module_id: registration.manifest.module_id.clone(),
9951 waited: wait,
9952 }
9953 };
9954 loop {
9955 let _observed_generation = *release_events.borrow_and_update();
9956 let Some(registration) = registry
9957 .registration(slot)
9958 .map_err(SuperviseError::Registry)?
9959 else {
9960 return Ok(());
9961 };
9962
9963 let now = Instant::now();
9964 if now >= deadline {
9965 return Err(still_active(®istration));
9966 }
9967
9968 let remaining = deadline.saturating_duration_since(now);
9969 match timeout(remaining, release_events.changed()).await {
9970 Ok(Ok(())) | Ok(Err(_)) => {}
9971 Err(_) => return Err(still_active(®istration)),
9972 }
9973 }
9974}
9975
9976#[cfg(test)]
9977mod slot_registration_wait_tests {
9978 use super::*;
9979 use crate::registry::{ConnectionId, RegistrationSlot};
9980 use subc_protocol::manifest::ModuleManifest;
9981
9982 #[tokio::test]
9983 async fn enable_release_failure_is_failed_and_a_second_enable_retries() {
9984 let registry = Arc::new(Registry::default());
9985 let supervisor = Supervisor::new_for_test(Arc::clone(®istry), RestartPolicy::default());
9986 let runtime = supervisor.runtime_config();
9987 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::disabled()));
9988 let spec = ModuleSpec {
9989 module_id: "enable-stale-registration".to_string(),
9990 program: PathBuf::from("/missing/enable-retry-test"),
9991 args: Vec::new(),
9992 env: Vec::new(),
9993 reserved: false,
9994 reserved_prefixes: Vec::new(),
9995 protocol: ModuleProtocol::Subc,
9996 overlap: Default::default(),
9997 };
9998 let connection = ConnectionId::new(90);
9999 registry
10000 .register_with_control_ops(
10001 ModuleManifest::builder(&spec.module_id, "0.1.0").build(),
10002 1,
10003 connection,
10004 Vec::new(),
10005 )
10006 .unwrap();
10007 let mut child = None;
10008 let err = set_child_enabled(
10009 &spec,
10010 &runtime,
10011 ®istry,
10012 &supervisor.process_liveness,
10013 &snapshot,
10014 &mut child,
10015 true,
10016 )
10017 .await
10018 .unwrap_err();
10019 assert!(matches!(
10020 err,
10021 SuperviseError::RegistrationStillActive { .. }
10022 ));
10023 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
10024 assert!(child.is_none());
10025 registry.deregister_connection(connection).unwrap();
10026 let err = set_child_enabled(
10027 &spec,
10028 &runtime,
10029 ®istry,
10030 &supervisor.process_liveness,
10031 &snapshot,
10032 &mut child,
10033 true,
10034 )
10035 .await
10036 .unwrap_err();
10037 assert!(
10038 matches!(err, SuperviseError::Spawn { .. }),
10039 "second enable must attempt a spawn: {err}"
10040 );
10041 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
10042 }
10043
10044 const INCUMBENT: u64 = 1;
10045 const CANDIDATE: u64 = 2;
10046
10047 fn swapped_registry() -> Arc<Registry> {
10048 let registry = Arc::new(Registry::default());
10049 let manifest = ModuleManifest::builder("m", "0.1.0").build();
10050 registry
10051 .register_with_control_ops(
10052 manifest.clone(),
10053 1,
10054 ConnectionId::new(INCUMBENT),
10055 Vec::new(),
10056 )
10057 .unwrap();
10058 registry
10059 .register_candidate_with_control_ops(
10060 manifest,
10061 1,
10062 ConnectionId::new(CANDIDATE),
10063 Vec::new(),
10064 )
10065 .unwrap();
10066 registry
10067 }
10068
10069 #[tokio::test]
10073 async fn incumbent_release_is_awaited_by_connection_not_by_module_id() {
10074 let registry = swapped_registry();
10075 registry.promote_candidate("m").unwrap().unwrap();
10076
10077 assert!(matches!(
10078 wait_for_registration_release(®istry, "m", Duration::from_millis(50)).await,
10079 Err(SuperviseError::RegistrationStillActive { .. })
10080 ));
10081
10082 assert!(matches!(
10084 wait_for_slot_registration_release(
10085 ®istry,
10086 RegistrationSlot::Connection(ConnectionId::new(INCUMBENT)),
10087 Duration::from_millis(50),
10088 )
10089 .await,
10090 Err(SuperviseError::RegistrationStillActive { .. })
10091 ));
10092
10093 let releaser = Arc::clone(®istry);
10094 let release = tokio::spawn(async move {
10095 sleep(Duration::from_millis(20)).await;
10096 releaser
10097 .deregister_connection(ConnectionId::new(INCUMBENT))
10098 .unwrap();
10099 notify_registration_release();
10100 });
10101 wait_for_slot_registration_release(
10102 ®istry,
10103 RegistrationSlot::Connection(ConnectionId::new(INCUMBENT)),
10104 Duration::from_secs(5),
10105 )
10106 .await
10107 .expect("the incumbent's own registration is released");
10108 release.await.unwrap();
10109 assert!(registry.get_module("m").unwrap().is_some());
10110 }
10111
10112 #[tokio::test]
10115 async fn candidate_slot_wait_ignores_the_incumbents_registration() {
10116 let registry = swapped_registry();
10117 assert!(matches!(
10118 wait_for_slot_registration_release(
10119 ®istry,
10120 RegistrationSlot::Candidate("m"),
10121 Duration::from_millis(50),
10122 )
10123 .await,
10124 Err(SuperviseError::RegistrationStillActive { .. })
10125 ));
10126 registry
10127 .deregister_connection(ConnectionId::new(CANDIDATE))
10128 .unwrap();
10129 wait_for_slot_registration_release(
10130 ®istry,
10131 RegistrationSlot::Candidate("m"),
10132 Duration::from_millis(50),
10133 )
10134 .await
10135 .expect("a candidate slot with no candidate is released");
10136 assert!(registry
10137 .registration(RegistrationSlot::Active("m"))
10138 .unwrap()
10139 .is_some());
10140 }
10141}
10142
10143fn classify_exit(status: &ExitStatus) -> ExitReport {
10144 ExitReport {
10145 kind: if status.success() {
10146 ExitKind::Clean
10147 } else {
10148 ExitKind::Crash
10149 },
10150 code: status.code(),
10151 signal: exit_signal(status),
10152 at_ms: unix_ms_now(),
10153 }
10154}
10155
10156fn wait_error_exit_report() -> ExitReport {
10162 ExitReport {
10163 kind: ExitKind::Crash,
10164 code: None,
10165 signal: None,
10166 at_ms: unix_ms_now(),
10167 }
10168}
10169
10170#[cfg(unix)]
10171fn exit_signal(status: &ExitStatus) -> Option<i32> {
10172 use std::os::unix::process::ExitStatusExt;
10173
10174 status.signal()
10175}
10176
10177#[cfg(not(unix))]
10178fn exit_signal(_status: &ExitStatus) -> Option<i32> {
10179 None
10180}
10181
10182fn reset_restart_count(snapshot: &SharedSnapshot, module_id: &str) -> Result<(), SuperviseError> {
10188 update_snapshot(snapshot, Some(module_id), |state| {
10189 state.clear_crash_restarts();
10190 })
10191}
10192
10193fn set_running(
10194 snapshot: &SharedSnapshot,
10195 child: &SupervisedChild,
10196 module_id: &str,
10197 spawn_events: &SpawnEventFeed,
10198) -> Result<(), SuperviseError> {
10199 let mut state = snapshot.lock().map_err(|_| SuperviseError::StatePoisoned {
10200 module_id: Some(module_id.to_string()),
10201 })?;
10202 state.spawn_generation = spawn_events.emit_spawned(module_id, child.pid, child.spawned_at_ms);
10203 if std::mem::take(&mut state.coalesced_restart_pending) {
10204 let generation = state.spawn_generation;
10205 info!(module_id, "restart already satisfied by generation {generation}; coalesced pending request completed");
10206 }
10207 state.drain_disposition_detail = None;
10208 state.spawn_failure = None;
10209 state.waiting_for_previous_pid = None;
10210 state.in_alternate_slot = false;
10213 state.configuration_updated_since_spawn = false;
10214 state.spawned_protocol = Some(child.protocol);
10215 state.state = ModuleState::Running;
10216 state.enabled = true;
10217 state.process_alive = true;
10218 state.pid = child.id();
10219 #[cfg(windows)]
10220 {
10221 state.pipe_consumed = child
10222 .nonce_delivery
10223 .as_ref()
10224 .map(|delivery| delivery.consumed());
10225 }
10226 #[cfg(target_os = "macos")]
10227 {
10228 state.report_ready = Some(Arc::clone(&child.report_ready));
10229 }
10230 state.spawned_at_ms = Some(child.spawned_at_ms);
10231 state.spawned_from = Some(child.spawned_from.clone());
10232 state.spawned_file_identity = child.spawned_file_identity.clone();
10233 state.process_start_time = child.process_start_time;
10234 Ok(())
10235}
10236
10237fn clear_current_process_facts(state: &mut SupervisorSnapshot) {
10238 state.waiting_for_previous_pid = None;
10239 state.process_alive = false;
10240 state.spawned_protocol = None;
10241 state.pid = None;
10242 #[cfg(windows)]
10243 {
10244 state.pipe_consumed = None;
10245 }
10246 #[cfg(target_os = "macos")]
10247 {
10248 state.report_ready = None;
10249 }
10250 state.spawned_at_ms = None;
10251 state.spawned_from = None;
10252 state.spawned_file_identity = None;
10253 state.process_start_time = None;
10254 state.deliberate_severance = None;
10255}
10256
10257#[cfg(test)]
10258fn record_deliberate_severance(
10259 snapshot: &SharedSnapshot,
10260 identity: ProcessIdentity,
10261) -> Result<(), SuperviseError> {
10262 update_snapshot(snapshot, None, |state| {
10263 state.deliberate_severance = Some(identity);
10264 })
10265}
10266
10267fn apply_deliberate_severance_marker(
10268 snapshot: &SharedSnapshot,
10269 exited_identity: Option<ProcessIdentity>,
10270 mut exit_report: ExitReport,
10271) -> ExitReport {
10272 let marker = lock_snapshot(snapshot)
10273 .ok()
10274 .and_then(|mut state| state.deliberate_severance.take());
10275 if marker.is_some() && marker == exited_identity {
10276 exit_report.kind = ExitKind::DeliberateSeverance;
10277 }
10278 exit_report
10279}
10280
10281fn classify_reaped_child_exit(
10282 snapshot: &SharedSnapshot,
10283 child: &SupervisedChild,
10284 status: &ExitStatus,
10285) -> ExitReport {
10286 let _ = update_snapshot(snapshot, None, |state| {
10287 state.reaped_pid = Some(child.pid);
10288 state.spawn_failure = child.spawn_failure.clone();
10289 });
10290 apply_deliberate_severance_marker(snapshot, child.process_identity(), classify_exit(status))
10291}
10292
10293fn fail_snapshot(
10294 snapshot: &SharedSnapshot,
10295 module_id: Option<&str>,
10296 last_exit: Option<ExitReport>,
10297) {
10298 if let Err(err) = update_snapshot(snapshot, module_id, |state| {
10299 state.state = ModuleState::Failed;
10300 clear_current_process_facts(state);
10301 if let Some(last_exit) = last_exit {
10302 state.last_exit = Some(last_exit);
10303 }
10304 }) {
10305 error!(error = %err, "failed to mark supervisor state failed");
10306 }
10307}
10308
10309fn update_snapshot(
10310 snapshot: &SharedSnapshot,
10311 module_id: Option<&str>,
10312 update: impl FnOnce(&mut SupervisorSnapshot),
10313) -> Result<(), SuperviseError> {
10314 let mut state = snapshot.lock().map_err(|_| SuperviseError::StatePoisoned {
10315 module_id: module_id.map(ToOwned::to_owned),
10316 })?;
10317 update(&mut state);
10318 Ok(())
10319}
10320
10321const SLOW_SNAPSHOT_LOCK_THRESHOLD: Duration = Duration::from_millis(250);
10322
10323fn lock_snapshot_for_control<'a>(
10324 snapshot: &'a SharedSnapshot,
10325 module_id: &str,
10326 caller: &'static str,
10327) -> Result<std::sync::MutexGuard<'a, SupervisorSnapshot>, SuperviseError> {
10328 let started_at = Instant::now();
10329 let guard = lock_snapshot(snapshot)?;
10330 let waited = started_at.elapsed();
10331 if waited >= SLOW_SNAPSHOT_LOCK_THRESHOLD {
10332 warn!(
10333 module_id = %module_id,
10334 waited_ms = waited.as_millis() as u64,
10335 caller = %caller,
10336 "slow snapshot lock"
10337 );
10338 }
10339 Ok(guard)
10340}
10341
10342fn lock_snapshot(
10343 snapshot: &SharedSnapshot,
10344) -> Result<std::sync::MutexGuard<'_, SupervisorSnapshot>, SuperviseError> {
10345 snapshot
10346 .lock()
10347 .map_err(|_| SuperviseError::StatePoisoned { module_id: None })
10348}
10349
10350#[cfg(test)]
10351mod terminal_history_tests {
10352 use std::{
10353 path::PathBuf,
10354 sync::Arc,
10355 time::{Duration, Instant},
10356 };
10357
10358 use tokio::{sync::mpsc, time::sleep};
10359
10360 use super::{
10361 apply_deliberate_severance_marker, daemon_will_restart, drain_child_to_state,
10362 drained_after_quiescence_wait, handle_reload_spawn_failure, health_restart_child,
10363 lock_snapshot, on_child_exit, record_deliberate_severance, record_wait_error_terminal,
10364 reset_restart_count, spawn_and_mark_running, update_snapshot, wait_error_exit_report,
10365 ExitKind, ExitReport, ModuleProtocol, ModuleSpec, ModuleState, NextAction, ProcessIdentity,
10366 RestartPolicy, SpawnEventKind, StopNotice, SuperviseError, SupervisedConfiguration,
10367 SupervisedModule, SupervisedModuleInner, Supervisor, SupervisorHandle,
10368 SupervisorHealthStatus, SupervisorSnapshot,
10369 };
10370 use super::Instant as ClockInstant;
10375 use crate::{
10376 registry::Registry,
10377 terminal_ring::{TerminalRing, TerminalRingConfig},
10378 };
10379 use std::sync::Mutex;
10380 use subc_control::TerminalDisposition;
10381
10382 pub(super) fn fake_aft_stub_path() -> PathBuf {
10387 let mut path = std::env::current_exe().expect("current_exe available in tests");
10388 path.pop();
10389 path.pop();
10390 path.push(if cfg!(windows) {
10391 "fake-aft-stub.exe"
10392 } else {
10393 "fake-aft-stub"
10394 });
10395 assert!(
10396 path.exists(),
10397 "fake-aft-stub not built at {}: run `cargo test -p subc-core` (which builds \
10398 [[bin]] targets) rather than `cargo test -p subc-core --lib` (which does not)",
10399 path.display()
10400 );
10401 path
10402 }
10403
10404 #[test]
10405 fn reserved_never_spawned_refuses_every_hello() {
10406 let supervisor = SupervisorHandle::default();
10411 supervisor.apply_identity_configuration(&ModuleSpec {
10412 module_id: "never-spawned".to_string(),
10413 program: PathBuf::from("/usr/bin/false"),
10414 args: Vec::new(),
10415 env: Vec::new(),
10416 reserved: true,
10417 reserved_prefixes: Vec::new(),
10418 protocol: ModuleProtocol::Subc,
10419 overlap: Default::default(),
10420 });
10421 assert!(
10422 supervisor
10423 .reserved_hello_rejection("never-spawned", Some("any-forged-nonce"))
10424 .is_some(),
10425 "forged nonce must refuse on a reserved never-spawned id"
10426 );
10427 assert!(
10428 supervisor
10429 .reserved_hello_rejection("never-spawned", None)
10430 .is_some(),
10431 "absent nonce must refuse on a reserved never-spawned id"
10432 );
10433 supervisor.set_spawn_nonce("never-spawned", "minted".to_string());
10435 supervisor.apply_identity_configuration(&ModuleSpec {
10436 module_id: "never-spawned".to_string(),
10437 program: PathBuf::from("/usr/bin/false"),
10438 args: Vec::new(),
10439 env: Vec::new(),
10440 reserved: true,
10441 reserved_prefixes: Vec::new(),
10442 protocol: ModuleProtocol::Subc,
10443 overlap: Default::default(),
10444 });
10445 assert!(supervisor
10446 .reserved_hello_rejection("never-spawned", Some("minted"))
10447 .is_none());
10448 assert!(supervisor
10449 .reserved_hello_rejection("never-spawned", Some("forged"))
10450 .is_some());
10451 }
10452
10453 fn seed_crash_restarts(state: &mut SupervisorSnapshot, count: u32) {
10456 let now = ClockInstant::now();
10457 for _ in 0..count {
10458 state.crash_restarts.push_back(now);
10459 }
10460 }
10461
10462 fn age_oldest_crash_restart_out_of_window(state: &mut SupervisorSnapshot, window: Duration) {
10466 let aged = state
10467 .crash_restarts
10468 .front()
10469 .expect("a crash restart must be recorded before it can be aged")
10470 .checked_sub(window + Duration::from_secs(1))
10471 .expect("the test clock is far enough from its origin to age an instant");
10472 state.crash_restarts[0] = aged;
10473 }
10474
10475 fn snapshot_with_restarts(enabled: bool, count: u32) -> SupervisorSnapshot {
10476 let mut state = SupervisorSnapshot::new(ModuleState::Running, enabled);
10477 seed_crash_restarts(&mut state, count);
10478 state
10479 }
10480
10481 #[test]
10482 fn daemon_owned_recovery_predicate_uses_the_pre_increment_budget() {
10483 let policy = RestartPolicy::new(3, Duration::ZERO);
10484 let now = ClockInstant::now();
10485 assert!(daemon_will_restart(
10486 &mut snapshot_with_restarts(true, 2),
10487 &policy,
10488 now
10489 ));
10490 assert!(!daemon_will_restart(
10491 &mut snapshot_with_restarts(true, 3),
10492 &policy,
10493 now
10494 ));
10495 assert!(!daemon_will_restart(
10496 &mut snapshot_with_restarts(false, 0),
10497 &policy,
10498 now
10499 ));
10500 }
10501
10502 #[test]
10503 fn crash_restart_backoff_escalates_with_in_window_count() {
10504 let policy = RestartPolicy::new(4, Duration::from_millis(100))
10505 .with_max_backoff(Duration::from_secs(30));
10506 let now = ClockInstant::now();
10507 let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
10508 let schedules = (0..4)
10509 .map(|_| {
10510 state
10511 .next_crash_restart(&policy, now)
10512 .expect("the test policy allows four crash restarts")
10513 })
10514 .collect::<Vec<_>>();
10515
10516 assert_eq!(
10517 schedules
10518 .iter()
10519 .map(|schedule| schedule.restart_in_window)
10520 .collect::<Vec<_>>(),
10521 vec![0, 1, 2, 3]
10522 );
10523 assert_eq!(
10524 schedules
10525 .iter()
10526 .map(|schedule| schedule.delay)
10527 .collect::<Vec<_>>(),
10528 vec![
10529 Duration::from_millis(100),
10530 Duration::from_secs(1),
10531 Duration::from_secs(10),
10532 Duration::from_secs(30),
10533 ]
10534 );
10535 }
10536
10537 #[test]
10538 fn crash_restart_backoff_resets_after_ring_clear() {
10539 let policy = RestartPolicy::new(3, Duration::from_millis(100));
10540 let now = ClockInstant::now();
10541 let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
10542 assert_eq!(
10543 state.next_crash_restart(&policy, now).unwrap().delay,
10544 Duration::from_millis(100)
10545 );
10546 assert_eq!(
10547 state.next_crash_restart(&policy, now).unwrap().delay,
10548 Duration::from_secs(1)
10549 );
10550
10551 state.clear_crash_restarts();
10552 let schedule = state
10553 .next_crash_restart(&policy, now)
10554 .expect("a cleared ring must allow another restart");
10555 assert_eq!(schedule.restart_in_window, 0);
10556 assert_eq!(schedule.delay, Duration::from_millis(100));
10557 }
10558
10559 #[test]
10560 fn crash_restart_backoff_ignores_aged_restarts() {
10561 let policy = RestartPolicy::new(3, Duration::from_millis(100));
10562 let now = ClockInstant::now();
10563 let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
10564 state
10565 .next_crash_restart(&policy, now)
10566 .expect("the first restart is allowed");
10567 state
10568 .next_crash_restart(&policy, now)
10569 .expect("the second restart is allowed");
10570 state.crash_restarts[0] = now
10571 .checked_sub(policy.window + Duration::from_secs(1))
10572 .expect("the fake clock can age a restart past the window");
10573
10574 let schedule = state
10575 .next_crash_restart(&policy, now)
10576 .expect("an aged restart must release its slot");
10577 assert_eq!(schedule.restart_in_window, 1);
10578 assert_eq!(schedule.delay, Duration::from_secs(1));
10579 assert_eq!(state.crash_restarts.len(), 2);
10580 }
10581
10582 #[test]
10586 fn a_budget_spent_before_the_window_no_longer_refuses() {
10587 let policy = RestartPolicy::new(3, Duration::ZERO);
10588 let mut state = snapshot_with_restarts(true, 3);
10589 let now = ClockInstant::now();
10590 assert!(!daemon_will_restart(&mut state, &policy, now));
10591
10592 assert!(daemon_will_restart(
10593 &mut state,
10594 &policy,
10595 now + policy.window + Duration::from_secs(1)
10596 ));
10597 assert!(
10598 state.crash_restarts.is_empty(),
10599 "reading the budget must drop the instants that left the window"
10600 );
10601 }
10602
10603 fn module_with_recovery_snapshot(
10604 state: ModuleState,
10605 enabled: bool,
10606 restart_count: u32,
10607 ) -> SupervisedModule {
10608 let registry = Arc::new(Registry::default());
10609 let supervisor =
10610 Supervisor::new_for_test(Arc::clone(®istry), RestartPolicy::new(3, Duration::ZERO));
10611 let runtime = supervisor.runtime_config();
10612 let spec = ModuleSpec {
10613 module_id: "recovery-snapshot".to_string(),
10614 program: fake_aft_stub_path(),
10615 args: Vec::new(),
10616 env: Vec::new(),
10617 reserved: false,
10618 reserved_prefixes: Vec::new(),
10619 protocol: ModuleProtocol::Subc,
10620 overlap: Default::default(),
10621 };
10622 let mut snapshot = SupervisorSnapshot::new(state, enabled);
10623 seed_crash_restarts(&mut snapshot, restart_count);
10624 let (commands, _rx) = mpsc::channel(4);
10627 SupervisedModule {
10628 inner: Arc::new(SupervisedModuleInner {
10629 module_id: spec.module_id.clone(),
10630 registry,
10631 snapshot: Arc::new(Mutex::new(snapshot)),
10632 configuration: Arc::new(Mutex::new(SupervisedConfiguration {
10633 spec,
10634 health: runtime.health,
10635 })),
10636 stderr_ring: runtime.stderr_ring,
10637 terminal_ring: runtime.terminal_ring,
10638 commands,
10639 monitor: Mutex::new(None),
10640 restart_policy: runtime.restart_policy,
10641 effective_drain_timeout: runtime.effective_drain_timeout,
10642 provenance_probe: supervisor.provenance_probe.clone(),
10643 }),
10644 }
10645 }
10646
10647 #[cfg(target_os = "linux")]
10648 #[tokio::test]
10649 async fn no_cgroup_placement_does_not_block_fake_aft_stub_spawn() {
10650 let supervisor =
10651 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
10652 .with_cgroup_placement(None);
10653 let result = supervisor.spawn(ModuleSpec {
10654 module_id: "no-cgroup-placement".to_string(),
10655 program: fake_aft_stub_path(),
10656 args: Vec::new(),
10657 env: Vec::new(),
10658 reserved: false,
10659 reserved_prefixes: Vec::new(),
10660 protocol: ModuleProtocol::Subc,
10661 overlap: Default::default(),
10662 });
10663
10664 assert!(
10665 result.is_ok(),
10666 "no delegation must not turn an otherwise valid spawn into a failure: {result:?}"
10667 );
10668 }
10669
10670 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10671 async fn undecided_snapshot_uses_shared_restart_predicate() {
10672 assert!(module_with_recovery_snapshot(ModuleState::Running, true, 2)
10673 .will_recover_after_connection_loss()
10674 .unwrap());
10675 assert!(
10676 !module_with_recovery_snapshot(ModuleState::Running, true, 3)
10677 .will_recover_after_connection_loss()
10678 .unwrap()
10679 );
10680 }
10681
10682 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10683 async fn restarting_snapshot_at_exhausted_budget_is_non_terminal() {
10684 assert!(
10685 module_with_recovery_snapshot(ModuleState::Restarting, true, 3)
10686 .will_recover_after_connection_loss()
10687 .unwrap()
10688 );
10689 }
10690
10691 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10692 async fn terminal_phase_snapshots_are_terminal_before_budget_exhaustion() {
10693 assert!(!module_with_recovery_snapshot(ModuleState::Failed, true, 0)
10694 .will_recover_after_connection_loss()
10695 .unwrap());
10696 assert!(
10697 !module_with_recovery_snapshot(ModuleState::Disabled, true, 0)
10698 .will_recover_after_connection_loss()
10699 .unwrap()
10700 );
10701 }
10702
10703 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10704 async fn warming_snapshot_is_limited_to_startup_phases() {
10705 for state in [
10706 ModuleState::Starting,
10707 ModuleState::Running,
10708 ModuleState::Restarting,
10709 ] {
10710 assert!(
10711 module_with_recovery_snapshot(state, true, 0)
10712 .is_warming()
10713 .unwrap(),
10714 "{state:?} should be warming"
10715 );
10716 }
10717 for state in [
10718 ModuleState::Unresponsive,
10719 ModuleState::Draining,
10720 ModuleState::Stopped,
10721 ModuleState::Failed,
10722 ModuleState::Disabled,
10723 ] {
10724 assert!(
10725 !module_with_recovery_snapshot(state, true, 0)
10726 .is_warming()
10727 .unwrap(),
10728 "{state:?} should not be warming"
10729 );
10730 }
10731 }
10732
10733 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10734 async fn terminal_history_survives_respawn_and_keeps_both_crashes_in_order() {
10735 let registry = Arc::new(Registry::default());
10736 let supervisor =
10737 Supervisor::new_for_test(Arc::clone(®istry), RestartPolicy::new(1, Duration::ZERO));
10738 let module = supervisor
10739 .spawn(ModuleSpec {
10740 module_id: "terminal-history".to_string(),
10741 program: fake_aft_stub_path(),
10742 args: Vec::new(),
10743 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
10744 reserved: false,
10745 reserved_prefixes: Vec::new(),
10746 protocol: ModuleProtocol::Subc,
10747 overlap: Default::default(),
10748 })
10749 .unwrap();
10750
10751 let deadline = Instant::now() + Duration::from_secs(5);
10752 loop {
10753 let history = module.terminal_history();
10754 if history.entries.len() == 2 {
10755 assert_eq!(module.status().unwrap().state, ModuleState::Failed);
10756 assert_eq!(history.dropped, 0);
10757 assert_eq!(
10758 history
10759 .entries
10760 .iter()
10761 .map(|entry| entry.exit_code)
10762 .collect::<Vec<_>>(),
10763 vec![Some(23), Some(23)]
10764 );
10765 assert!(history.entries[0].at_ms <= history.entries[1].at_ms);
10766 return;
10767 }
10768 assert!(
10769 Instant::now() < deadline,
10770 "module did not retain two terminal exits: {history:?}"
10771 );
10772 sleep(Duration::from_millis(10)).await;
10773 }
10774 }
10775
10776 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10780 async fn disable_during_crash_backoff_cancels_pending_respawn() {
10781 let backoff = Duration::from_secs(2);
10782 let supervisor = Supervisor::new_for_test(
10783 Arc::new(Registry::default()),
10784 RestartPolicy::new(10, backoff),
10785 );
10786 let module = supervisor
10787 .spawn(ModuleSpec {
10788 module_id: "disable-during-backoff".to_string(),
10789 program: fake_aft_stub_path(),
10790 args: Vec::new(),
10791 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
10792 reserved: false,
10793 reserved_prefixes: Vec::new(),
10794 protocol: ModuleProtocol::Subc,
10795 overlap: Default::default(),
10796 })
10797 .unwrap();
10798
10799 let deadline = Instant::now() + Duration::from_secs(5);
10801 loop {
10802 if module.status().unwrap().state == ModuleState::Restarting {
10803 break;
10804 }
10805 assert!(
10806 Instant::now() < deadline,
10807 "module never entered the crash backoff"
10808 );
10809 sleep(Duration::from_millis(10)).await;
10810 }
10811
10812 let started = Instant::now();
10813 module.set_enabled(false).await.unwrap();
10814 let waited = started.elapsed();
10815
10816 assert!(
10817 waited < backoff / 2,
10818 "disable waited {waited:?} behind the {backoff:?} crash backoff; the operator command must preempt the pending respawn"
10819 );
10820 assert_eq!(module.status().unwrap().state, ModuleState::Disabled);
10821
10822 sleep(backoff + Duration::from_millis(500)).await;
10824 let status = module.status().unwrap();
10825 assert_eq!(status.state, ModuleState::Disabled);
10826 assert_eq!(
10827 status.spawn_generation, 1,
10828 "module respawned after the operator disabled it"
10829 );
10830 }
10831
10832 #[cfg(unix)]
10835 fn protocol_none_sigterm_exits_clean_spec(
10836 module_id: &str,
10837 dir: &std::path::Path,
10838 ) -> (ModuleSpec, PathBuf, PathBuf) {
10839 let ready = dir.join("ready");
10840 let marker = dir.join("sigterm");
10841 let spec = ModuleSpec {
10842 module_id: module_id.to_string(),
10843 program: fake_aft_stub_path(),
10844 args: Vec::new(),
10845 env: vec![
10846 ("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string()),
10847 (
10848 "FAKE_AFT_SIGTERM_MARKER_PATH".to_string(),
10849 marker.display().to_string(),
10850 ),
10851 (
10852 "FAKE_AFT_NEVER_CONNECT_READY_PATH".to_string(),
10853 ready.display().to_string(),
10854 ),
10855 ],
10856 reserved: false,
10857 reserved_prefixes: Vec::new(),
10858 protocol: ModuleProtocol::None,
10859 overlap: Default::default(),
10860 };
10861 (spec, ready, marker)
10862 }
10863
10864 #[cfg(unix)]
10868 async fn wait_for_file(path: &std::path::Path) {
10869 let deadline = Instant::now() + Duration::from_secs(10);
10870 while !path.exists() {
10871 assert!(
10872 Instant::now() < deadline,
10873 "{} never appeared",
10874 path.display()
10875 );
10876 sleep(Duration::from_millis(10)).await;
10877 }
10878 }
10879
10880 #[cfg(unix)]
10884 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10885 async fn protocol_none_unrequested_clean_exit_restarts_as_a_crash() {
10886 let dir = cortexkit_test_support::ScratchDir::new("none-unrequested-clean-exit");
10887 let (spec, ready, marker) =
10888 protocol_none_sigterm_exits_clean_spec("none-unrequested-clean-exit", dir.path());
10889 let supervisor = Supervisor::new_for_test(
10890 Arc::new(Registry::default()),
10891 RestartPolicy::new(3, Duration::ZERO),
10892 );
10893 let module = supervisor.spawn(spec).unwrap();
10894 wait_for_file(&ready).await;
10895 let deadline = Instant::now() + Duration::from_secs(10);
10899 let first_pid = loop {
10900 let status = module.status().unwrap();
10901 if status.state == ModuleState::Running {
10902 if let Some(pid) = status.pid {
10903 break pid;
10904 }
10905 }
10906 assert!(
10907 Instant::now() < deadline,
10908 "a running module must report its pid after exec confirmation: {status:?}"
10909 );
10910 sleep(Duration::from_millis(10)).await;
10911 };
10912
10913 rustix::process::kill_process(
10914 rustix::process::Pid::from_raw(i32::try_from(first_pid).unwrap()).unwrap(),
10915 rustix::process::Signal::TERM,
10916 )
10917 .unwrap();
10918
10919 let deadline = Instant::now() + Duration::from_secs(10);
10920 let respawned = loop {
10921 let status = module.status().unwrap();
10922 if status.state == ModuleState::Running
10923 && status.pid.is_some_and(|pid| pid != first_pid)
10924 {
10925 break status;
10926 }
10927 assert!(
10928 Instant::now() < deadline,
10929 "protocol-none module was not respawned after an unrequested clean exit: {status:?}"
10930 );
10931 sleep(Duration::from_millis(10)).await;
10932 };
10933 assert_eq!(respawned.spawn_generation, 2);
10934 assert!(
10935 marker.exists(),
10936 "the child must have exited through its SIGTERM handler (exit 0), or this proves nothing about clean exits"
10937 );
10938
10939 let history = module.terminal_history();
10940 assert_eq!(history.entries.len(), 1, "{history:?}");
10941 let entry = &history.entries[0];
10942 assert_eq!(entry.exit_code, Some(0));
10943 assert_eq!(entry.exit_kind, subc_control::TerminalExitKind::Clean);
10944 assert_eq!(entry.disposition, TerminalDisposition::Restarting);
10945
10946 module.stop().await.unwrap();
10947 }
10948
10949 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10953 async fn protocol_none_repeated_clean_exits_exhaust_the_restart_budget() {
10954 let supervisor = Supervisor::new_for_test(
10955 Arc::new(Registry::default()),
10956 RestartPolicy::new(1, Duration::ZERO),
10957 );
10958 let module = supervisor
10959 .spawn(ModuleSpec {
10960 module_id: "none-clean-exit-budget".to_string(),
10961 program: fake_aft_stub_path(),
10962 args: Vec::new(),
10963 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "0".to_string())],
10964 reserved: false,
10965 reserved_prefixes: Vec::new(),
10966 protocol: ModuleProtocol::None,
10967 overlap: Default::default(),
10968 })
10969 .unwrap();
10970
10971 let deadline = Instant::now() + Duration::from_secs(10);
10974 loop {
10975 let status = module.status().unwrap();
10976 if status.state == ModuleState::Failed {
10977 break;
10978 }
10979 assert!(
10980 Instant::now() < deadline,
10981 "module never exhausted its budget: {status:?} {:?}",
10982 module.terminal_history()
10983 );
10984 sleep(Duration::from_millis(10)).await;
10985 }
10986 let history = module.terminal_history();
10987 assert_eq!(
10988 history
10989 .entries
10990 .iter()
10991 .map(|entry| (entry.exit_code, entry.disposition.clone()))
10992 .collect::<Vec<_>>(),
10993 vec![
10994 (Some(0), TerminalDisposition::Restarting),
10995 (Some(0), TerminalDisposition::Failed),
10996 ]
10997 );
10998 let detail = history.entries[1]
10999 .disposition_detail
11000 .as_deref()
11001 .expect("a budget failure names the budget");
11002 assert!(detail.contains("max_restarts=1"), "{detail}");
11003 assert_eq!(module.status().unwrap().spawn_generation, 2);
11004 }
11005
11006 #[test]
11007 fn restart_budget_failure_is_published_after_its_terminal_record() {
11008 use std::io::{Read, Write};
11009 let supervisor = Supervisor::new_for_test(
11010 Arc::new(Registry::default()),
11011 RestartPolicy::new(0, Duration::ZERO),
11012 );
11013 let runtime = supervisor.runtime_config();
11014 let spec = ModuleSpec {
11015 protocol: ModuleProtocol::None,
11016 ..windowed_crash_spec("budget-publication-order")
11017 };
11018 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(
11019 ModuleState::Running,
11020 true,
11021 )));
11022 lock_snapshot(&snapshot).unwrap().process_alive = true;
11023 let reaped_snapshot = snapshot.clone();
11024 let mut barrier = terminal_record_barrier(&runtime.spawn_events);
11025 let history = runtime.terminal_ring.clone();
11026 let reap = std::thread::spawn(move || {
11027 tokio::runtime::Builder::new_current_thread()
11028 .enable_all()
11029 .build()
11030 .unwrap()
11031 .block_on(on_child_exit(
11032 &spec,
11033 runtime.restart_policy,
11034 &supervisor.registry,
11035 &reaped_snapshot,
11036 &runtime.terminal_ring,
11037 &runtime.spawn_events,
11038 &runtime.child_roster,
11039 ExitReport {
11040 kind: ExitKind::Clean,
11041 code: Some(0),
11042 signal: None,
11043 at_ms: 1,
11044 },
11045 ))
11046 });
11047 let mut reached = [0];
11048 barrier.read_exact(&mut reached).unwrap();
11049 assert_eq!(&reached, b"R");
11050 let (before_state, before_alive) = {
11051 let state = lock_snapshot(&snapshot).unwrap();
11052 (state.state, state.process_alive)
11053 };
11054 let before_history = history.lock().unwrap().snapshot();
11055 barrier.write_all(b"X").unwrap();
11056 assert!(matches!(reap.join().unwrap(), NextAction::Stop { .. }));
11057 assert!(before_history.entries.is_empty());
11058 assert_ne!(
11059 before_state,
11060 ModuleState::Failed,
11061 "Failed was visible before its terminal record could be written"
11062 );
11063 assert!(before_alive);
11064 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
11065 let history = history.lock().unwrap().snapshot();
11066 assert_eq!(history.entries.len(), 1);
11067 assert_eq!(history.entries[0].exit_code, Some(0));
11068 assert_eq!(history.entries[0].disposition, TerminalDisposition::Failed);
11069 }
11070
11071 fn terminal_record_barrier(events: &super::SpawnEventFeed) -> std::net::TcpStream {
11072 use std::net::{TcpListener, TcpStream};
11073 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
11074 let writer = TcpStream::connect(listener.local_addr().unwrap()).unwrap();
11075 let (barrier, _) = listener.accept().unwrap();
11076 for stream in [&writer, &barrier] {
11077 stream
11078 .set_read_timeout(Some(Duration::from_secs(10)))
11079 .unwrap();
11080 stream
11081 .set_write_timeout(Some(Duration::from_secs(10)))
11082 .unwrap();
11083 }
11084 *events.1.lock().unwrap() = Some(writer);
11085 barrier
11086 }
11087
11088 pub(super) fn before_terminal_record(events: &super::SpawnEventFeed) {
11089 use std::io::{Read, Write};
11090 let barrier = events.1.lock().unwrap().take();
11091 if let Some(mut barrier) = barrier {
11092 barrier.write_all(b"R").unwrap();
11093 let mut release = [0];
11094 barrier.read_exact(&mut release).unwrap();
11095 assert_eq!(&release, b"X");
11096 }
11097 }
11098
11099 #[test]
11100 fn health_restart_budget_failure_is_published_after_its_terminal_record() {
11101 use std::io::{Read, Write};
11102
11103 let supervisor = Supervisor::new_for_test(
11104 Arc::new(Registry::default()),
11105 RestartPolicy::new(0, Duration::ZERO),
11106 );
11107 let mut runtime = supervisor.runtime_config();
11108 let module = module_with_recovery_snapshot(ModuleState::Running, true, 0);
11109 let snapshot = module.inner.snapshot.clone();
11110 runtime.terminal_ring = module.inner.terminal_ring.clone();
11111 let spec = ModuleSpec {
11112 module_id: module.module_id().into(),
11113 program: fake_aft_stub_path(),
11114 protocol: ModuleProtocol::None,
11115 env: vec![("FAKE_AFT_EXIT_CODE".into(), "17".into())],
11116 ..windowed_crash_spec(module.module_id())
11117 };
11118 let mut barrier = terminal_record_barrier(&runtime.spawn_events);
11119 let reap = std::thread::spawn(move || {
11120 tokio::runtime::Builder::new_current_thread()
11121 .enable_all()
11122 .build()
11123 .unwrap()
11124 .block_on(async {
11125 let mut child =
11126 Some(spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap());
11127 health_restart_child(
11128 &spec,
11129 &runtime,
11130 &supervisor.registry,
11131 &supervisor.process_liveness,
11132 &snapshot,
11133 &mut child,
11134 SupervisorHealthStatus::Failing,
11135 Some("health probe did not answer"),
11136 1,
11137 )
11138 .await
11139 .unwrap();
11140 assert!(child.is_none());
11141 });
11142 });
11143 let mut reached = [0];
11148 barrier.read_exact(&mut reached).unwrap();
11149 assert_eq!(&reached, b"R");
11150 let before_status = module.status().unwrap();
11151 let before_history = module.terminal_history();
11152 barrier.write_all(b"X").unwrap();
11153 reap.join().unwrap();
11154 assert!(before_history.entries.is_empty());
11155 assert_ne!(
11156 before_status.state,
11157 ModuleState::Failed,
11158 "Failed was visible before its terminal record could be written"
11159 );
11160 assert!(
11161 before_status.process_alive,
11162 "process death was visible before its terminal record could be written"
11163 );
11164 let failed = module.status().unwrap();
11165 assert_eq!(failed.state, ModuleState::Failed);
11166 assert!(!failed.process_alive);
11167 let history = module.terminal_history();
11168 assert_eq!(history.entries.len(), 1);
11169 assert_eq!(history.entries[0].disposition, TerminalDisposition::Failed);
11170 assert_eq!(
11171 history.entries[0].disposition_detail.as_deref(),
11172 Some("crash budget exhausted: max_restarts=0 within window_secs=600")
11173 );
11174 }
11175
11176 #[test]
11177 fn observed_exits_publish_process_death_after_terminal_history() {
11178 use std::io::{Read, Write};
11179 for (kind, enabled, shutdown, wait_error, expected_state) in [
11180 (ExitKind::Clean, true, false, false, ModuleState::Stopped),
11181 (ExitKind::Crash, true, false, false, ModuleState::Restarting),
11182 (ExitKind::Crash, false, false, false, ModuleState::Disabled),
11183 (
11184 ExitKind::DeliberateSeverance,
11185 true,
11186 false,
11187 false,
11188 ModuleState::Restarting,
11189 ),
11190 #[cfg(unix)]
11191 (ExitKind::Crash, true, true, false, ModuleState::Stopped),
11192 (ExitKind::Crash, true, false, true, ModuleState::Failed),
11193 ] {
11194 let supervisor = Supervisor::new_for_test(
11195 Arc::new(Registry::default()),
11196 RestartPolicy::new(1, Duration::ZERO),
11197 );
11198 let runtime = supervisor.runtime_config();
11199 #[cfg(unix)]
11200 if shutdown {
11201 runtime.child_roster.close();
11202 }
11203 #[cfg(not(unix))]
11204 let _ = shutdown;
11205 let spec = windowed_crash_spec("exit-publication-order");
11206 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(
11207 ModuleState::Running,
11208 enabled,
11209 )));
11210 lock_snapshot(&snapshot).unwrap().process_alive = true;
11211 let reaped_snapshot = snapshot.clone();
11212 let history = runtime.terminal_ring.clone();
11213 let mut barrier = terminal_record_barrier(&runtime.spawn_events);
11214 let reap = std::thread::spawn(move || {
11215 if wait_error {
11216 super::fail_after_wait_error(
11217 &spec.module_id,
11218 &reaped_snapshot,
11219 &runtime.terminal_ring,
11220 &runtime.spawn_events,
11221 "wait failed".into(),
11222 );
11223 } else {
11224 tokio::runtime::Builder::new_current_thread()
11225 .enable_all()
11226 .build()
11227 .unwrap()
11228 .block_on(on_child_exit(
11229 &spec,
11230 runtime.restart_policy,
11231 &supervisor.registry,
11232 &reaped_snapshot,
11233 &runtime.terminal_ring,
11234 &runtime.spawn_events,
11235 &runtime.child_roster,
11236 ExitReport {
11237 kind,
11238 code: Some(0),
11239 signal: None,
11240 at_ms: 1,
11241 },
11242 ));
11243 }
11244 });
11245 let mut reached = [0];
11246 barrier.read_exact(&mut reached).unwrap();
11247 assert_eq!(&reached, b"R");
11248 let before_alive = lock_snapshot(&snapshot).unwrap().process_alive;
11249 let before_history = history.lock().unwrap().snapshot();
11250 barrier.write_all(b"X").unwrap();
11251 reap.join().unwrap();
11252 assert!(
11253 before_alive,
11254 "{kind:?}: death was published before recording"
11255 );
11256 assert!(before_history.entries.is_empty());
11257 assert_eq!(lock_snapshot(&snapshot).unwrap().state, expected_state);
11258 assert!(!lock_snapshot(&snapshot).unwrap().process_alive);
11259 assert_eq!(history.lock().unwrap().snapshot().entries.len(), 1);
11260 }
11261 }
11262
11263 #[tokio::test]
11264 async fn health_restart_budget_failure_remains_failed_when_journal_append_fails() {
11265 let dir = cortexkit_test_support::ScratchDir::new("health-budget-journal-failure");
11266 let path = dir.join("terminals.jsonl");
11267 std::fs::create_dir(&path).unwrap();
11268 let supervisor = Supervisor::new_for_test(
11269 Arc::new(Registry::default()),
11270 RestartPolicy::new(0, Duration::ZERO),
11271 )
11272 .with_terminal_journal(path, "health-budget-journal-failure".into());
11273 let runtime = supervisor.runtime_config();
11274 let spec = ModuleSpec {
11275 program: fake_aft_stub_path(),
11276 protocol: ModuleProtocol::None,
11277 env: vec![("FAKE_AFT_EXIT_CODE".into(), "17".into())],
11278 ..windowed_crash_spec("health-budget-journal-failure")
11279 };
11280 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11281 let mut child = Some(spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap());
11282 health_restart_child(
11283 &spec,
11284 &runtime,
11285 &supervisor.registry,
11286 &supervisor.process_liveness,
11287 &snapshot,
11288 &mut child,
11289 SupervisorHealthStatus::Failing,
11290 None,
11291 1,
11292 )
11293 .await
11294 .unwrap();
11295 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
11296 assert!(!lock_snapshot(&snapshot).unwrap().process_alive);
11297 let history = runtime
11298 .terminal_ring
11299 .lock()
11300 .unwrap()
11301 .durable_history(&spec.module_id);
11302 assert!(history.journal_write_failures > 0);
11303 assert_eq!(history.entries.len(), 1);
11304 assert_eq!(history.entries[0].disposition, TerminalDisposition::Failed);
11305 }
11306
11307 #[test]
11308 fn health_restart_budget_failure_remains_failed_when_exit_recording_panics() {
11309 let supervisor = Supervisor::new_for_test(
11310 Arc::new(Registry::default()),
11311 RestartPolicy::new(0, Duration::ZERO),
11312 );
11313 let runtime = supervisor.runtime_config();
11314 let spec = ModuleSpec {
11315 program: fake_aft_stub_path(),
11316 protocol: ModuleProtocol::None,
11317 env: vec![("FAKE_AFT_EXIT_CODE".into(), "17".into())],
11318 ..windowed_crash_spec("health-budget-recording-panic")
11319 };
11320 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11321 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
11322 tokio::runtime::Builder::new_current_thread()
11323 .enable_all()
11324 .build()
11325 .unwrap()
11326 .block_on(async {
11327 let mut child =
11328 Some(spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap());
11329 runtime.spawn_events.0.lock().unwrap().seq = u64::MAX;
11330 health_restart_child(
11331 &spec,
11332 &runtime,
11333 &supervisor.registry,
11334 &supervisor.process_liveness,
11335 &snapshot,
11336 &mut child,
11337 SupervisorHealthStatus::Failing,
11338 None,
11339 1,
11340 )
11341 .await
11342 .unwrap();
11343 });
11344 }));
11345 let panic = result.expect_err("recording must still unwind");
11346 assert_eq!(
11347 panic.downcast_ref::<String>().map(String::as_str),
11348 Some("spawn event sequence exhausted")
11349 );
11350 let state = lock_snapshot(&snapshot).unwrap();
11351 assert_eq!(state.state, ModuleState::Failed);
11352 assert!(!state.process_alive);
11353 assert!(state.last_exit.is_some());
11354 }
11355
11356 #[tokio::test]
11357 async fn restart_budget_failure_remains_failed_when_journal_append_fails() {
11358 let dir = cortexkit_test_support::ScratchDir::new("budget-journal-failure");
11359 let path = dir.join("terminals.jsonl");
11360 std::fs::create_dir(&path).unwrap();
11361 let supervisor = Supervisor::new_for_test(
11362 Arc::new(Registry::default()),
11363 RestartPolicy::new(0, Duration::ZERO),
11364 )
11365 .with_terminal_journal(path, "budget-journal-failure".into());
11366 let runtime = supervisor.runtime_config();
11367 let spec = windowed_crash_spec("budget-journal-failure");
11368 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11369 assert!(matches!(
11370 on_child_exit(
11371 &spec,
11372 runtime.restart_policy,
11373 &supervisor.registry,
11374 &snapshot,
11375 &runtime.terminal_ring,
11376 &runtime.spawn_events,
11377 &runtime.child_roster,
11378 crash_exit_report(1),
11379 )
11380 .await,
11381 NextAction::Stop { .. }
11382 ));
11383 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
11384 let history = runtime
11385 .terminal_ring
11386 .lock()
11387 .unwrap()
11388 .durable_history(&spec.module_id);
11389 assert!(history.journal_write_failures > 0);
11390 assert_eq!(history.entries.len(), 1);
11391 assert_eq!(history.entries[0].disposition, TerminalDisposition::Failed);
11392 }
11393
11394 #[test]
11395 fn restart_budget_failure_remains_failed_when_exit_recording_panics() {
11396 let supervisor = Supervisor::new_for_test(
11397 Arc::new(Registry::default()),
11398 RestartPolicy::new(0, Duration::ZERO),
11399 );
11400 let runtime = supervisor.runtime_config();
11401 let spec = windowed_crash_spec("budget-recording-panic");
11402 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11403 runtime.spawn_events.emit_spawned(&spec.module_id, 1, 1);
11404 runtime.spawn_events.0.lock().unwrap().seq = u64::MAX;
11407 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
11408 tokio::runtime::Builder::new_current_thread()
11409 .enable_all()
11410 .build()
11411 .unwrap()
11412 .block_on(on_child_exit(
11413 &spec,
11414 runtime.restart_policy,
11415 &supervisor.registry,
11416 &snapshot,
11417 &runtime.terminal_ring,
11418 &runtime.spawn_events,
11419 &runtime.child_roster,
11420 crash_exit_report(1),
11421 ))
11422 }));
11423 let panic = result.err().expect("recording must still unwind");
11424 assert_eq!(
11425 panic.downcast_ref::<String>().map(String::as_str),
11426 Some("spawn event sequence exhausted")
11427 );
11428 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
11429 }
11430
11431 #[cfg(unix)]
11434 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11435 async fn protocol_none_requested_stop_and_disable_do_not_respawn() {
11436 for disable in [false, true] {
11437 let label = if disable {
11438 "none-requested-disable"
11439 } else {
11440 "none-requested-stop"
11441 };
11442 let dir = cortexkit_test_support::ScratchDir::new(label);
11443 let (spec, ready, marker) = protocol_none_sigterm_exits_clean_spec(label, dir.path());
11444 let supervisor = Supervisor::new_for_test(
11445 Arc::new(Registry::default()),
11446 RestartPolicy::new(3, Duration::ZERO),
11447 );
11448 let module = supervisor.spawn(spec).unwrap();
11449 wait_for_file(&ready).await;
11450
11451 if disable {
11452 module.set_enabled(false).await.unwrap();
11453 } else {
11454 module.stop().await.unwrap();
11455 }
11456 assert!(
11457 marker.exists(),
11458 "{label}: the child must have left through its SIGTERM handler with exit 0"
11459 );
11460
11461 sleep(Duration::from_millis(500)).await;
11464 let status = module.status().unwrap();
11465 let expected = if disable {
11466 ModuleState::Disabled
11467 } else {
11468 ModuleState::Stopped
11469 };
11470 assert_eq!(status.state, expected, "{label}");
11471 assert_eq!(
11472 status.spawn_generation, 1,
11473 "{label}: respawned after a requested stop"
11474 );
11475 let history = module.terminal_history();
11476 assert_eq!(history.entries.len(), 1, "{label}: {history:?}");
11477 assert_eq!(history.entries[0].exit_code, Some(0), "{label}");
11478 assert_ne!(
11479 history.entries[0].disposition,
11480 TerminalDisposition::Restarting,
11481 "{label}"
11482 );
11483 }
11484 }
11485
11486 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11489 async fn subc_wire_clean_exit_is_still_a_stop() {
11490 let supervisor = Supervisor::new_for_test(
11491 Arc::new(Registry::default()),
11492 RestartPolicy::new(3, Duration::ZERO),
11493 );
11494 let module = supervisor
11495 .spawn(ModuleSpec {
11496 module_id: "wire-clean-exit".to_string(),
11497 program: fake_aft_stub_path(),
11498 args: Vec::new(),
11499 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "0".to_string())],
11500 reserved: false,
11501 reserved_prefixes: Vec::new(),
11502 protocol: ModuleProtocol::Subc,
11503 overlap: Default::default(),
11504 })
11505 .unwrap();
11506
11507 let deadline = Instant::now() + Duration::from_secs(10);
11508 while module.terminal_history().entries.is_empty() {
11509 assert!(Instant::now() < deadline, "module never exited");
11510 sleep(Duration::from_millis(10)).await;
11511 }
11512 sleep(Duration::from_millis(500)).await;
11514 let status = module.status().unwrap();
11515 assert_eq!(status.state, ModuleState::Stopped);
11516 assert_eq!(status.spawn_generation, 1);
11517 let history = module.terminal_history();
11518 assert_eq!(history.entries.len(), 1, "{history:?}");
11519 assert_eq!(history.entries[0].exit_code, Some(0));
11520 assert_eq!(history.entries[0].disposition, TerminalDisposition::Stopped);
11521 }
11522
11523 #[cfg(unix)]
11524 #[tokio::test]
11525 async fn reload_exit_keeps_roster_until_terminal_is_recorded_during_shutdown() {
11526 let dir = cortexkit_test_support::ScratchDir::new("reload-roster-terminal-order");
11527 let record = dir.join("live-children.json");
11528 let supervisor = Supervisor::new_for_test(
11529 Arc::new(Registry::default()),
11530 RestartPolicy::new(0, Duration::ZERO),
11531 );
11532 let mut runtime = supervisor.runtime_config();
11533 runtime.child_roster.record_to(record.clone());
11534 let gate = Arc::new(super::ReloadExitRecordGate::default());
11535 runtime.test_reload_exit_record_gate = Some(Arc::clone(&gate));
11536 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11537 let spec = ModuleSpec {
11538 module_id: "reload-exit-roster".into(),
11539 program: fake_aft_stub_path(),
11540 args: Vec::new(),
11541 env: vec![("FAKE_AFT_EXIT_CODE".into(), "23".into())],
11542 reserved: false,
11543 reserved_prefixes: Vec::new(),
11544 protocol: ModuleProtocol::Subc,
11545 overlap: Default::default(),
11546 };
11547 let mut child = None;
11548 let reload = super::finish_reload_child(
11549 &spec,
11550 &runtime,
11551 &supervisor.registry,
11552 &supervisor.process_liveness,
11553 &snapshot,
11554 &mut child,
11555 );
11556 tokio::pin!(reload);
11557 tokio::select! {
11558 result = &mut reload => panic!("reload missed the before-record gate: {result:?}"),
11559 _ = gate.reached.notified() => {}
11560 }
11561 assert!(runtime
11562 .terminal_ring
11563 .lock()
11564 .unwrap()
11565 .snapshot()
11566 .entries
11567 .is_empty());
11568 assert_eq!(
11569 crate::live_children::read_record(&record).unwrap().len(),
11570 1,
11571 "shutdown must still wait for the reaped child until its terminal record exists"
11572 );
11573 runtime.child_roster.close();
11574 gate.resume.notify_one();
11575 assert!(reload.await.is_err());
11576 assert!(crate::live_children::read_record(&record)
11577 .unwrap()
11578 .is_empty());
11579 let history = runtime.terminal_ring.lock().unwrap().snapshot();
11580 assert_eq!(history.entries.len(), 1);
11581 assert_eq!(
11582 history.entries[0].disposition,
11583 TerminalDisposition::DaemonShutdown
11584 );
11585 }
11586
11587 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11591 async fn every_restart_increment_path_advances_lifetime_count() {
11592 let supervisor = Supervisor::new_for_test(
11593 Arc::new(Registry::default()),
11594 RestartPolicy::new(1, Duration::ZERO),
11595 );
11596 let runtime = supervisor.runtime_config();
11597 let spec = ModuleSpec {
11598 module_id: "lifetime-increment-path".to_string(),
11599 program: PathBuf::from("/unused/lifetime-increment-path"),
11600 args: Vec::new(),
11601 env: Vec::new(),
11602 reserved: false,
11603 reserved_prefixes: Vec::new(),
11604 protocol: ModuleProtocol::Subc,
11605 overlap: Default::default(),
11606 };
11607
11608 let crash_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11609 assert!(matches!(
11610 on_child_exit(
11611 &spec,
11612 runtime.restart_policy,
11613 &supervisor.registry,
11614 &crash_snapshot,
11615 &runtime.terminal_ring,
11616 &runtime.spawn_events,
11617 &runtime.child_roster,
11618 ExitReport {
11619 kind: ExitKind::Crash,
11620 code: Some(1),
11621 signal: None,
11622 at_ms: 1,
11623 },
11624 )
11625 .await,
11626 NextAction::Restart { schedule: _ }
11627 ));
11628 let (crash_restarts, crash_lifetime) = {
11629 let state = lock_snapshot(&crash_snapshot).unwrap();
11630 (state.crash_restarts.len(), state.lifetime_restarts)
11631 };
11632 assert_eq!(crash_restarts, 1);
11633 assert_eq!(crash_lifetime, 1);
11634
11635 let health_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11636 let mut health_child = None;
11637 assert!(matches!(
11638 health_restart_child(
11639 &spec,
11640 &runtime,
11641 &supervisor.registry,
11642 &supervisor.process_liveness,
11643 &health_snapshot,
11644 &mut health_child,
11645 SupervisorHealthStatus::Failing,
11646 None,
11647 2,
11648 )
11649 .await,
11650 Ok(())
11651 ));
11652 assert!(health_child.is_none());
11653 assert!(lock_snapshot(&health_snapshot).unwrap().respawn_pending);
11654 assert!(runtime.scheduled_respawn.lock().unwrap().take().is_some());
11655 let (health_restarts, health_lifetime) = {
11656 let state = lock_snapshot(&health_snapshot).unwrap();
11657 (state.crash_restarts.len(), state.lifetime_restarts)
11658 };
11659 assert_eq!(health_restarts, 1);
11660 assert_eq!(health_lifetime, 1);
11661
11662 let reload_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11663 let mut reload_child = None;
11664 assert!(matches!(
11665 handle_reload_spawn_failure(
11666 &spec,
11667 &runtime,
11668 &supervisor.process_liveness,
11669 &reload_snapshot,
11670 &mut reload_child,
11671 "forced reload spawn failure".to_string(),
11672 )
11673 .await,
11674 Err(SuperviseError::ReloadFailed { .. })
11675 ));
11676 let (reload_restarts, reload_lifetime) = {
11677 let state = lock_snapshot(&reload_snapshot).unwrap();
11678 (state.crash_restarts.len(), state.lifetime_restarts)
11679 };
11680 assert_eq!(reload_restarts, 1);
11681 assert_eq!(reload_lifetime, 1);
11682 }
11683
11684 #[tokio::test]
11685 async fn deliberately_severed_live_child_records_lifetime_without_spending_restart_budget() {
11686 let supervisor = Supervisor::new_for_test(
11687 Arc::new(Registry::default()),
11688 RestartPolicy::new(3, Duration::ZERO),
11689 );
11690 let runtime = supervisor.runtime_config();
11691 let spec = ModuleSpec {
11692 module_id: "deliberately-severed".to_string(),
11693 program: PathBuf::from("/unused/deliberately-severed"),
11694 args: Vec::new(),
11695 env: Vec::new(),
11696 reserved: false,
11697 reserved_prefixes: Vec::new(),
11698 protocol: ModuleProtocol::Subc,
11699 overlap: Default::default(),
11700 };
11701 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11702 let process = ProcessIdentity {
11703 pid: 41,
11704 start_time: 101,
11705 };
11706 record_deliberate_severance(&snapshot, process).unwrap();
11707 let exit_report = apply_deliberate_severance_marker(
11708 &snapshot,
11709 Some(process),
11710 ExitReport {
11711 kind: ExitKind::Crash,
11712 code: Some(1),
11713 signal: None,
11714 at_ms: 1,
11715 },
11716 );
11717 assert_eq!(exit_report.kind, ExitKind::DeliberateSeverance);
11718
11719 assert!(matches!(
11720 on_child_exit(
11721 &spec,
11722 runtime.restart_policy,
11723 &supervisor.registry,
11724 &snapshot,
11725 &runtime.terminal_ring,
11726 &runtime.spawn_events,
11727 &runtime.child_roster,
11728 exit_report,
11729 )
11730 .await,
11731 NextAction::Restart { schedule: _ }
11732 ));
11733 let state = lock_snapshot(&snapshot).unwrap();
11734 assert_eq!(state.lifetime_restarts, 1);
11735 assert_eq!(state.crash_restarts.len(), 0);
11736 }
11737
11738 #[tokio::test]
11739 async fn genuine_crash_spends_restart_budget_and_records_lifetime() {
11740 let supervisor = Supervisor::new_for_test(
11741 Arc::new(Registry::default()),
11742 RestartPolicy::new(3, Duration::ZERO),
11743 );
11744 let runtime = supervisor.runtime_config();
11745 let spec = ModuleSpec {
11746 module_id: "genuine-crash".to_string(),
11747 program: PathBuf::from("/unused/genuine-crash"),
11748 args: Vec::new(),
11749 env: Vec::new(),
11750 reserved: false,
11751 reserved_prefixes: Vec::new(),
11752 protocol: ModuleProtocol::Subc,
11753 overlap: Default::default(),
11754 };
11755 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11756
11757 assert!(matches!(
11758 on_child_exit(
11759 &spec,
11760 runtime.restart_policy,
11761 &supervisor.registry,
11762 &snapshot,
11763 &runtime.terminal_ring,
11764 &runtime.spawn_events,
11765 &runtime.child_roster,
11766 ExitReport {
11767 kind: ExitKind::Crash,
11768 code: Some(1),
11769 signal: None,
11770 at_ms: 1,
11771 },
11772 )
11773 .await,
11774 NextAction::Restart { schedule: _ }
11775 ));
11776 let state = lock_snapshot(&snapshot).unwrap();
11777 assert_eq!(state.lifetime_restarts, 1);
11778 assert_eq!(state.crash_restarts.len(), 1);
11779 }
11780
11781 fn crash_exit_report(at_ms: u64) -> ExitReport {
11782 ExitReport {
11783 kind: ExitKind::Crash,
11784 code: Some(1),
11785 signal: None,
11786 at_ms,
11787 }
11788 }
11789
11790 fn windowed_crash_spec(module_id: &str) -> ModuleSpec {
11791 ModuleSpec {
11792 module_id: module_id.to_string(),
11793 program: PathBuf::from("/unused").join(module_id),
11794 args: Vec::new(),
11795 env: Vec::new(),
11796 reserved: false,
11797 reserved_prefixes: Vec::new(),
11798 protocol: ModuleProtocol::Subc,
11799 overlap: Default::default(),
11800 }
11801 }
11802
11803 #[tokio::test]
11809 async fn three_crashes_inside_the_window_stop_the_module_and_name_the_window() {
11810 let (logs, _guard) = crate::router::test_log::log_capture(tracing::Level::ERROR);
11811 let supervisor = Supervisor::new_for_test(
11812 Arc::new(Registry::default()),
11813 RestartPolicy::new(2, Duration::ZERO),
11814 );
11815 let runtime = supervisor.runtime_config();
11816 let spec = windowed_crash_spec("crash-loop-in-window");
11817 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11818
11819 for attempt in 1..=2 {
11820 assert!(
11821 matches!(
11822 on_child_exit(
11823 &spec,
11824 runtime.restart_policy,
11825 &supervisor.registry,
11826 &snapshot,
11827 &runtime.terminal_ring,
11828 &runtime.spawn_events,
11829 &runtime.child_roster,
11830 crash_exit_report(attempt),
11831 )
11832 .await,
11833 NextAction::Restart { schedule: _ }
11834 ),
11835 "crash {attempt} is inside the budget and must respawn"
11836 );
11837 }
11838
11839 assert!(matches!(
11840 on_child_exit(
11841 &spec,
11842 runtime.restart_policy,
11843 &supervisor.registry,
11844 &snapshot,
11845 &runtime.terminal_ring,
11846 &runtime.spawn_events,
11847 &runtime.child_roster,
11848 crash_exit_report(3),
11849 )
11850 .await,
11851 NextAction::Stop { .. }
11852 ));
11853
11854 {
11855 let state = lock_snapshot(&snapshot).unwrap();
11856 assert_eq!(state.state, ModuleState::Failed);
11857 assert_eq!(state.crash_restarts.len(), 2);
11858 assert_eq!(state.lifetime_restarts, 2);
11859 }
11860
11861 let history = runtime
11862 .terminal_ring
11863 .lock()
11864 .expect("terminal ring is not poisoned")
11865 .snapshot();
11866 let last = history
11867 .entries
11868 .last()
11869 .expect("the refused crash is retained");
11870 assert_eq!(last.disposition, TerminalDisposition::Failed);
11871 assert_eq!(
11872 last.disposition_detail.as_deref(),
11873 Some("crash budget exhausted: max_restarts=2 within window_secs=600")
11874 );
11875
11876 let captured = crate::router::test_log::captured_logs(&logs);
11877 assert!(
11878 captured.contains("crash budget exhausted: max_restarts=2 within window_secs=600"),
11879 "the stop must be logged with its window: {captured}"
11880 );
11881 }
11882
11883 #[tokio::test]
11891 async fn a_crash_older_than_the_window_frees_its_slot_for_a_later_crash() {
11892 let supervisor = Supervisor::new_for_test(
11893 Arc::new(Registry::default()),
11894 RestartPolicy::new(2, Duration::ZERO),
11895 );
11896 let runtime = supervisor.runtime_config();
11897 let spec = windowed_crash_spec("crash-across-windows");
11898 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11899
11900 for attempt in 1..=2 {
11901 assert!(matches!(
11902 on_child_exit(
11903 &spec,
11904 runtime.restart_policy,
11905 &supervisor.registry,
11906 &snapshot,
11907 &runtime.terminal_ring,
11908 &runtime.spawn_events,
11909 &runtime.child_roster,
11910 crash_exit_report(attempt),
11911 )
11912 .await,
11913 NextAction::Restart { schedule: _ }
11914 ));
11915 }
11916
11917 update_snapshot(&snapshot, Some(&spec.module_id), |state| {
11920 age_oldest_crash_restart_out_of_window(state, runtime.restart_policy.window);
11921 })
11922 .unwrap();
11923
11924 assert!(
11925 matches!(
11926 on_child_exit(
11927 &spec,
11928 runtime.restart_policy,
11929 &supervisor.registry,
11930 &snapshot,
11931 &runtime.terminal_ring,
11932 &runtime.spawn_events,
11933 &runtime.child_roster,
11934 crash_exit_report(3),
11935 )
11936 .await,
11937 NextAction::Restart { schedule: _ }
11938 ),
11939 "a crash older than the window must not hold a budget slot"
11940 );
11941
11942 let state = lock_snapshot(&snapshot).unwrap();
11943 assert_eq!(state.state, ModuleState::Restarting);
11944 assert_eq!(
11945 state.crash_restarts.len(),
11946 2,
11947 "the aged instant is dropped and the new one takes its place"
11948 );
11949 assert_eq!(
11950 state.lifetime_restarts, 3,
11951 "the ledger counts every restart, including the ones the window forgot"
11952 );
11953 }
11954
11955 #[tokio::test]
11960 async fn an_operator_restart_clears_the_ring_and_leaves_the_ledger_alone() {
11961 let supervisor = Supervisor::new_for_test(
11962 Arc::new(Registry::default()),
11963 RestartPolicy::new(2, Duration::ZERO),
11964 );
11965 let runtime = supervisor.runtime_config();
11966 let spec = windowed_crash_spec("operator-cleared-budget");
11967 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11968
11969 for attempt in 1..=2 {
11970 assert!(matches!(
11971 on_child_exit(
11972 &spec,
11973 runtime.restart_policy,
11974 &supervisor.registry,
11975 &snapshot,
11976 &runtime.terminal_ring,
11977 &runtime.spawn_events,
11978 &runtime.child_roster,
11979 crash_exit_report(attempt),
11980 )
11981 .await,
11982 NextAction::Restart { schedule: _ }
11983 ));
11984 }
11985
11986 reset_restart_count(&snapshot, &spec.module_id).unwrap();
11987 {
11988 let state = lock_snapshot(&snapshot).unwrap();
11989 assert!(
11990 state.crash_restarts.is_empty(),
11991 "an operator restart returns the full budget"
11992 );
11993 assert_eq!(
11994 state.lifetime_restarts, 2,
11995 "clearing the budget must not unmake the crashes"
11996 );
11997 }
11998
11999 assert!(
12000 matches!(
12001 on_child_exit(
12002 &spec,
12003 runtime.restart_policy,
12004 &supervisor.registry,
12005 &snapshot,
12006 &runtime.terminal_ring,
12007 &runtime.spawn_events,
12008 &runtime.child_roster,
12009 crash_exit_report(3),
12010 )
12011 .await,
12012 NextAction::Restart { schedule: _ }
12013 ),
12014 "the cleared budget must be spendable again"
12015 );
12016 let state = lock_snapshot(&snapshot).unwrap();
12017 assert_eq!(state.crash_restarts.len(), 1);
12018 assert_eq!(state.lifetime_restarts, 3);
12019 }
12020
12021 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
12022 async fn severance_marker_for_a_dead_child_does_not_label_its_successor() {
12023 let severed = ProcessIdentity {
12024 pid: 41,
12025 start_time: 101,
12026 };
12027 let successor = ProcessIdentity {
12028 pid: 41,
12029 start_time: 202,
12030 };
12031 let module = module_with_recovery_snapshot(ModuleState::Running, true, 0);
12032 update_snapshot(&module.inner.snapshot, Some("recovery-snapshot"), |state| {
12033 state.pid = Some(successor.pid);
12034 state.process_start_time = Some(successor.start_time);
12035 })
12036 .unwrap();
12037 assert!(!module.record_deliberate_severance(severed).unwrap());
12038
12039 let exit_report = apply_deliberate_severance_marker(
12040 &module.inner.snapshot,
12041 Some(successor),
12042 ExitReport {
12043 kind: ExitKind::Crash,
12044 code: Some(1),
12045 signal: None,
12046 at_ms: 1,
12047 },
12048 );
12049
12050 assert_eq!(exit_report.kind, ExitKind::Crash);
12051 }
12052
12053 #[tokio::test]
12054 async fn drain_reap_marks_deliberate_severance_and_records_lifetime_without_budget() {
12055 let registry = Registry::default();
12056 let supervisor = Supervisor::new_for_test(
12057 Arc::new(Registry::default()),
12058 RestartPolicy::new(3, Duration::ZERO),
12059 );
12060 let runtime = supervisor.runtime_config();
12061 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
12062 let spec = ModuleSpec {
12063 module_id: "drain-deliberate-severance".to_string(),
12064 program: fake_aft_stub_path(),
12065 args: Vec::new(),
12066 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
12067 reserved: false,
12068 reserved_prefixes: Vec::new(),
12069 protocol: ModuleProtocol::Subc,
12070 overlap: Default::default(),
12071 };
12072 let mut child = spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap();
12073 let process = ProcessIdentity {
12074 pid: 41,
12075 start_time: 101,
12076 };
12077 child.process_identity = Some(process);
12078 update_snapshot(&snapshot, Some(&spec.module_id), |state| {
12079 state.pid = Some(process.pid);
12080 state.process_start_time = Some(process.start_time);
12081 })
12082 .unwrap();
12083 record_deliberate_severance(&snapshot, process).unwrap();
12084
12085 drain_child_to_state(
12086 &spec.module_id,
12087 spec.protocol,
12088 StopNotice::SentOverConnection,
12091 ®istry,
12092 None,
12093 &snapshot,
12094 &runtime.terminal_ring,
12095 &runtime.spawn_events,
12096 child,
12097 Duration::from_secs(1),
12098 ModuleState::Stopped,
12099 Some(false),
12100 )
12101 .await
12102 .unwrap();
12103
12104 let state = lock_snapshot(&snapshot).unwrap();
12105 assert_eq!(
12106 state.last_exit.as_ref().map(|exit| exit.kind),
12107 Some(ExitKind::DeliberateSeverance)
12108 );
12109 assert_eq!(state.lifetime_restarts, 1);
12110 assert_eq!(state.crash_restarts.len(), 0);
12111 drop(state);
12112 let history = runtime.terminal_ring.lock().unwrap().snapshot();
12113 assert_eq!(
12114 history.entries[0].exit_kind,
12115 subc_control::TerminalExitKind::DeliberateSeverance
12116 );
12117 }
12118
12119 #[tokio::test]
12120 async fn ordinary_drain_reap_does_not_record_a_lifetime_restart() {
12121 let registry = Registry::default();
12122 let supervisor = Supervisor::new_for_test(
12123 Arc::new(Registry::default()),
12124 RestartPolicy::new(3, Duration::ZERO),
12125 );
12126 let runtime = supervisor.runtime_config();
12127 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
12128 let spec = ModuleSpec {
12129 module_id: "ordinary-drain".to_string(),
12130 program: fake_aft_stub_path(),
12131 args: Vec::new(),
12132 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
12133 reserved: false,
12134 reserved_prefixes: Vec::new(),
12135 protocol: ModuleProtocol::Subc,
12136 overlap: Default::default(),
12137 };
12138 let child = spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap();
12139
12140 drain_child_to_state(
12141 &spec.module_id,
12142 spec.protocol,
12143 StopNotice::SentOverConnection,
12146 ®istry,
12147 None,
12148 &snapshot,
12149 &runtime.terminal_ring,
12150 &runtime.spawn_events,
12151 child,
12152 Duration::from_secs(1),
12153 ModuleState::Stopped,
12154 Some(false),
12155 )
12156 .await
12157 .unwrap();
12158
12159 let state = lock_snapshot(&snapshot).unwrap();
12160 assert_eq!(
12161 state.last_exit.as_ref().map(|exit| exit.kind),
12162 Some(ExitKind::Crash)
12163 );
12164 assert_eq!(state.lifetime_restarts, 0);
12165 assert_eq!(state.crash_restarts.len(), 0);
12166 }
12167
12168 #[test]
12169 fn fatal_connection_teardown_cannot_arm_a_marker_for_a_surviving_process() {
12170 assert!(!include_str!("server.rs")
12176 .contains("router.record_deliberate_connection_severance(ctx.connection_id)"));
12177 }
12178
12179 #[test]
12186 fn drained_after_quiescence_wait_passes_ok_through_and_forces_false_on_err() {
12187 assert!(drained_after_quiescence_wait(&Ok(true)));
12188 assert!(!drained_after_quiescence_wait(&Ok(false)));
12189 assert!(!drained_after_quiescence_wait(&Err(
12190 SuperviseError::StatePoisoned { module_id: None }
12191 )));
12192 }
12193
12194 #[test]
12203 fn wait_error_exit_report_records_a_failed_terminal_with_no_code_or_signal() {
12204 let ring = Arc::new(Mutex::new(TerminalRing::new(
12205 TerminalRingConfig::default(),
12206 0,
12207 )));
12208 record_wait_error_terminal("wait-error", &ring, &super::SpawnEventFeed::default());
12209
12210 let snapshot = ring.lock().unwrap().snapshot();
12211 assert_eq!(snapshot.entries.len(), 1);
12212 let entry = &snapshot.entries[0];
12213 assert_eq!(entry.exit_code, None);
12214 assert_eq!(entry.exit_signal, None);
12215 assert_eq!(entry.disposition, TerminalDisposition::Failed);
12216 }
12217
12218 #[test]
12219 fn wait_error_exit_path_preserves_spawn_event_density() {
12220 let feed = super::SpawnEventFeed::default();
12221 feed.configure_incarnation("wait-error-density".to_string());
12222 feed.emit_spawned("wait-error", 41, 1);
12223 let ring = Arc::new(Mutex::new(TerminalRing::new(
12224 TerminalRingConfig::default(),
12225 0,
12226 )));
12227
12228 record_wait_error_terminal("wait-error", &ring, &feed);
12229 feed.emit_spawned("after-wait-error", 42, 2);
12230
12231 let state = feed.0.lock().unwrap();
12232 let sequences = state
12233 .events
12234 .iter()
12235 .map(|event| event.cursor.seq)
12236 .collect::<Vec<_>>();
12237 assert_eq!(sequences, vec![1, 2, 3]);
12238 assert_eq!(state.events[1].kind, SpawnEventKind::Exited);
12239 assert_eq!(state.events[1].exit_code, None);
12240 assert_eq!(state.events[1].exit_signal, None);
12241 }
12242
12243 #[test]
12247 fn wait_error_exit_report_is_classified_as_a_crash() {
12248 assert_eq!(wait_error_exit_report().kind, ExitKind::Crash);
12249 }
12250}
12251
12252#[cfg(all(test, unix))]
12253mod health_event_tests {
12254 use super::*;
12255 use cortexkit_test_support::ScratchDir;
12256 use subc_protocol::{manifest::Concurrency, session::ModuleControlResponse};
12257
12258 struct Actor {
12259 module: SupervisedModule,
12260 registry: Arc<Registry>,
12261 forwarding: Arc<ForwardingTable>,
12262 spec: ModuleSpec,
12263 health: HealthConfig,
12264 rx: mpsc::Receiver<crate::router::OutboundFrame>,
12265 _home: ScratchDir,
12266 }
12267
12268 async fn wait_for<T>(reason: &str, mut observe: impl FnMut() -> Option<T>) -> T {
12269 let deadline = std::time::Instant::now() + Duration::from_secs(10);
12273 let tick = Instant::now();
12274 loop {
12275 if let Some(value) = observe() {
12276 assert_eq!(Instant::now(), tick, "{reason} must not wait for a timer");
12277 return value;
12278 }
12279 assert!(
12280 std::time::Instant::now() < deadline,
12281 "timed out waiting for {reason}"
12282 );
12283 tokio::task::yield_now().await;
12284 }
12285 }
12286
12287 impl Actor {
12288 async fn start(protocol: ModuleProtocol, cadence: Duration) -> Self {
12289 let home = ScratchDir::new("health-events");
12290 let registry = Arc::new(Registry::default());
12291 let forwarding = Arc::new(ForwardingTable::default());
12292 let health = HealthConfig {
12293 cadence,
12294 deadline: Duration::from_secs(3600),
12295 ..HealthConfig::default()
12296 };
12297 let supervisor =
12298 Supervisor::new_for_test(registry.clone(), RestartPolicy::new(3, Duration::ZERO))
12299 .with_forwarding(forwarding.clone())
12300 .with_health_config(health.clone());
12301 let spec = ModuleSpec {
12302 module_id: "event-health-child".into(),
12303 program: PathBuf::from("/bin/sh"),
12304 args: vec!["-c".into(), "exec sleep 600".into()],
12307 env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
12308 .into_iter()
12309 .map(|key| (key.into(), home.path().display().to_string()))
12310 .collect(),
12311 reserved: false,
12312 reserved_prefixes: vec![],
12313 protocol,
12314 overlap: Default::default(),
12315 };
12316 let module = supervisor.spawn(spec.clone()).unwrap();
12317 let (_, rx) = mpsc::channel(8);
12318 let actor = Self {
12319 module,
12320 registry,
12321 forwarding,
12322 spec,
12323 health,
12324 rx,
12325 _home: home,
12326 };
12327 actor
12328 .wait_for_select(|checkpoint| checkpoint.generation > 0)
12329 .await;
12330 actor
12331 }
12332
12333 fn turns(&self) -> u64 {
12334 lock_snapshot(&self.module.inner.snapshot)
12335 .unwrap()
12336 .actor_turns
12337 }
12338
12339 async fn wait_for_select(
12340 &self,
12341 expected: impl Fn(&ActorSelectCheckpoint) -> bool,
12342 ) -> ActorSelectCheckpoint {
12343 wait_for("actor select checkpoint", || {
12344 let state = lock_snapshot(&self.module.inner.snapshot).unwrap();
12345 state.actor_select.filter(|checkpoint| {
12346 checkpoint.generation == state.spawn_generation
12347 && state.state == ModuleState::Running
12348 && state.process_alive
12349 && state.reported_pid().is_some()
12350 && expected(checkpoint)
12351 })
12352 })
12353 .await
12354 }
12355
12356 fn hello(&mut self, connection: u64, health: bool) {
12357 let manifest =
12358 subc_protocol::manifest::ModuleManifest::builder(&self.spec.module_id, "1.0.0")
12359 .protocol_ver(subc_protocol::PROTOCOL_VERSION)
12360 .build();
12361 let connection = ConnectionId::new(connection);
12362 let (tx, rx) = mpsc::channel(8);
12363 self.rx = rx;
12364 self.forwarding
12365 .register_module_connection(
12366 connection,
12367 self.spec.module_id.clone(),
12368 subc_protocol::PROTOCOL_VERSION,
12369 Concurrency::ModuleManaged,
12370 FrameSink::new(tx),
12371 )
12372 .unwrap();
12373 self.registry
12374 .register_with_control_ops(
12375 manifest,
12376 subc_protocol::PROTOCOL_VERSION,
12377 connection,
12378 if health {
12379 vec![MODULE_CONTROL_OP_HEALTH_CHECK.into()]
12380 } else {
12381 vec![]
12382 },
12383 )
12384 .unwrap();
12385 }
12386
12387 async fn probe(&mut self) -> crate::router::OutboundFrame {
12388 let frame = wait_for("outbound health probe", || match self.rx.try_recv() {
12389 Ok(frame) => Some(frame),
12390 Err(mpsc::error::TryRecvError::Empty) => None,
12391 Err(mpsc::error::TryRecvError::Disconnected) => panic!("health peer disconnected"),
12392 })
12393 .await;
12394 let body: Value = serde_json::from_slice(&frame.body).unwrap();
12395 assert_eq!(body["op"], MODULE_CONTROL_OP_HEALTH_CHECK);
12396 frame
12397 }
12398
12399 fn no_probe(&mut self) {
12400 assert!(matches!(
12401 self.rx.try_recv(),
12402 Err(mpsc::error::TryRecvError::Empty)
12403 ));
12404 }
12405
12406 fn answer(&self, connection: u64, frame: crate::router::OutboundFrame) {
12407 self.forwarding
12408 .complete_module_control_rpc(
12409 ConnectionId::new(connection),
12410 frame.header.corr,
12411 Some(MODULE_CONTROL_OP_HEALTH_CHECK),
12412 ModuleControlRpcOutcome::Response(ModuleControlResponse::HealthCheck {
12413 status: HealthStatus::Ok,
12414 detail: None,
12415 metrics: None,
12416 }),
12417 )
12418 .unwrap();
12419 }
12420 }
12421
12422 #[tokio::test(start_paused = true)]
12423 async fn no_health_children_park_for_sixty_seconds() {
12424 let none = Actor::start(ModuleProtocol::None, Duration::from_secs(30)).await;
12425 let unregistered = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12426 let mut unadvertised = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12427 unadvertised.hello(1, false);
12428 for _ in 0..6000 {
12433 tokio::time::advance(Duration::from_millis(10)).await;
12434 tokio::task::yield_now().await;
12435 }
12436 for actor in [&none, &unregistered, &unadvertised] {
12437 assert!(
12438 actor.turns() <= 5,
12439 "no-health actor woke {} times",
12440 actor.turns()
12441 );
12442 assert_eq!(actor.module.state().unwrap(), ModuleState::Running);
12443 }
12444 }
12445
12446 #[tokio::test(start_paused = true)]
12447 async fn late_hello_starts_a_due_probe_in_the_notification_tick() {
12448 let mut actor = Actor::start(ModuleProtocol::Subc, Duration::ZERO).await;
12452 let tick = Instant::now();
12453 actor.hello(1, true);
12454 actor.probe().await;
12455 assert_eq!(Instant::now(), tick, "HELLO must not wait for a poll");
12456 }
12457
12458 #[tokio::test(start_paused = true)]
12459 async fn hello_keeps_the_jittered_cadence_and_catalog_updates_do_not_reset_it() {
12460 let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12461 let hello_tick = Instant::now();
12462 actor.hello(1, true);
12463 let armed = actor
12464 .wait_for_select(|checkpoint| {
12465 checkpoint.registered_connection == Some(ConnectionId::new(1))
12466 && checkpoint.next_probe_at.is_some()
12467 })
12468 .await;
12469 let delay = jittered_health_delay(&actor.spec.module_id, 0, actor.health.cadence);
12470 assert!((Duration::from_secs(30)..Duration::from_secs(33)).contains(&delay));
12471 let first_deadline = hello_tick + delay;
12472 assert_eq!(armed.next_probe_at, Some(first_deadline));
12473 tokio::time::advance(Duration::from_secs(29)).await;
12474 let turns = actor.turns();
12475 actor
12476 .registry
12477 .replace_catalog_for_connection(ConnectionId::new(1), vec![], None, Some(true))
12478 .unwrap();
12479 let updated = actor
12480 .wait_for_select(|checkpoint| checkpoint.turn > turns)
12481 .await;
12482 assert_eq!(
12483 updated.next_probe_at,
12484 Some(first_deadline),
12485 "catalog update must not reset cadence"
12486 );
12487 actor.no_probe();
12488 tokio::time::advance(first_deadline - Instant::now()).await;
12489 let frame = actor.probe().await;
12490 actor.answer(1, frame);
12491 let next = jittered_health_delay(&actor.spec.module_id, 1, actor.health.cadence);
12492 let next_deadline = Instant::now() + next;
12493 let rearmed = actor
12494 .wait_for_select(|checkpoint| {
12495 checkpoint
12496 .next_probe_at
12497 .is_some_and(|deadline| deadline > first_deadline)
12498 })
12499 .await;
12500 assert_eq!(rearmed.next_probe_at, Some(next_deadline));
12501 tokio::time::advance(next - Duration::from_nanos(1)).await;
12502 actor.no_probe();
12503 tokio::time::advance(Duration::from_nanos(1)).await;
12504 actor.probe().await;
12505 }
12506
12507 #[tokio::test(start_paused = true)]
12508 async fn disconnect_disarms_and_reconnect_rearms_health() {
12509 let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12510 actor.hello(1, true);
12511 actor
12512 .wait_for_select(|checkpoint| {
12513 checkpoint.registered_connection == Some(ConnectionId::new(1))
12514 && checkpoint.next_probe_at.is_some()
12515 })
12516 .await;
12517 let turns = actor.turns();
12518 let tick = Instant::now();
12519 actor
12520 .registry
12521 .deregister_connection(ConnectionId::new(1))
12522 .unwrap();
12523 let disarmed = actor
12524 .wait_for_select(|checkpoint| {
12525 checkpoint.turn > turns && checkpoint.registered_connection.is_none()
12526 })
12527 .await;
12528 assert_eq!(disarmed.next_probe_at, None);
12529 assert_eq!(disarmed.wake_after, None);
12530 assert_eq!(
12531 Instant::now(),
12532 tick,
12533 "disconnect must disarm in the notification tick"
12534 );
12535 tokio::time::advance(Duration::from_secs(60)).await;
12536 actor.no_probe();
12537 let reconnect_tick = Instant::now();
12538 actor.hello(2, true);
12539 let rearmed = actor
12540 .wait_for_select(|checkpoint| {
12541 checkpoint.registered_connection == Some(ConnectionId::new(2))
12542 && checkpoint.next_probe_at.is_some()
12543 })
12544 .await;
12545 let delay = jittered_health_delay(&actor.spec.module_id, 0, actor.health.cadence);
12546 assert_eq!(rearmed.next_probe_at, Some(reconnect_tick + delay));
12547 tokio::time::advance(delay).await;
12548 actor.probe().await;
12549 }
12550
12551 #[tokio::test(start_paused = true)]
12552 async fn reregistration_without_health_disarms_the_old_schedule() {
12553 let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12554 actor.hello(1, true);
12555 actor
12556 .wait_for_select(|checkpoint| {
12557 checkpoint.registered_connection == Some(ConnectionId::new(1))
12558 && checkpoint.next_probe_at.is_some()
12559 })
12560 .await;
12561 actor
12562 .registry
12563 .deregister_connection(ConnectionId::new(1))
12564 .unwrap();
12565 actor.hello(2, false);
12566 let disarmed = actor
12567 .wait_for_select(|checkpoint| {
12568 checkpoint.registered_connection == Some(ConnectionId::new(2))
12569 })
12570 .await;
12571 assert_eq!(disarmed.next_probe_at, None);
12572 assert_eq!(disarmed.wake_after, None);
12573 tokio::time::advance(Duration::from_secs(60)).await;
12574 actor.no_probe();
12575 }
12576
12577 #[tokio::test(start_paused = true)]
12578 async fn restart_waits_for_its_own_hello_before_rearming_health() {
12579 let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12580 actor.hello(1, true);
12581 let armed = actor
12582 .wait_for_select(|checkpoint| {
12583 checkpoint.registered_connection == Some(ConnectionId::new(1))
12584 && checkpoint.next_probe_at.is_some()
12585 })
12586 .await;
12587 actor
12591 .registry
12592 .deregister_connection(ConnectionId::new(1))
12593 .unwrap();
12594 actor.module.restart(Some(0)).await.unwrap();
12595 let parked = actor
12596 .wait_for_select(|checkpoint| {
12597 checkpoint.generation > armed.generation
12598 && checkpoint.registered_connection.is_none()
12599 && checkpoint.next_probe_at.is_none()
12600 })
12601 .await;
12602 assert_eq!(parked.wake_after, None);
12603 actor.rx.close();
12604 while actor.rx.try_recv().is_ok() {}
12607 let turns = actor.turns();
12608 tokio::time::advance(Duration::from_secs(60)).await;
12609 let after = actor.turns();
12610 eprintln!(
12611 "restart park turns: before={turns} after={after} delta={}",
12612 after - turns
12613 );
12614 assert_eq!(after, turns, "replacement must park before HELLO");
12615 let hello_tick = Instant::now();
12616 actor.hello(2, true);
12617 let rearmed = actor
12618 .wait_for_select(|checkpoint| {
12619 checkpoint.registered_connection == Some(ConnectionId::new(2))
12620 && checkpoint.next_probe_at.is_some()
12621 })
12622 .await;
12623 let delay = jittered_health_delay(&actor.spec.module_id, 0, actor.health.cadence);
12624 assert_eq!(rearmed.next_probe_at, Some(hello_tick + delay));
12625 tokio::time::advance(delay).await;
12626 actor.probe().await;
12627 }
12628
12629 #[tokio::test(start_paused = true)]
12630 async fn rescan_adds_and_removes_http_health_without_polling() {
12631 use tokio::{
12632 io::{AsyncReadExt, AsyncWriteExt},
12633 net::TcpListener,
12634 };
12635 let mut actor = Actor::start(ModuleProtocol::None, Duration::from_secs(30)).await;
12636 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
12637 actor.health.http = Some(format!("http://{}/health", listener.local_addr().unwrap()));
12638 actor.health.cadence = Duration::from_millis(1);
12640 let (started_tx, mut started_rx) = mpsc::channel(8);
12641 let peer = tokio::spawn(async move {
12642 loop {
12643 let (mut socket, _) = listener.accept().await.unwrap();
12644 let mut request = [0; 1024];
12645 assert!(socket.read(&mut request).await.unwrap() > 0);
12646 started_tx.send(()).await.unwrap();
12647 socket
12648 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")
12649 .await
12650 .unwrap();
12651 }
12652 });
12653 let tick = Instant::now();
12654 let turns = actor.turns();
12655 actor
12656 .module
12657 .update_configuration(actor.spec.clone(), actor.health.clone(), None)
12658 .await
12659 .unwrap();
12660 let armed = actor
12661 .wait_for_select(|checkpoint| {
12662 checkpoint.turn > turns && checkpoint.next_probe_at.is_some()
12663 })
12664 .await;
12665 assert_eq!(armed.next_probe_at, Some(tick + Duration::from_millis(1)));
12666 assert_eq!(Instant::now(), tick);
12667 tokio::time::advance(Duration::from_millis(1)).await;
12668 let mut started = false;
12669 wait_for("successful HTTP probe", || {
12670 started |= started_rx.try_recv().is_ok();
12671 (started && actor.module.status().unwrap().health.status == SupervisorHealthStatus::Ok)
12672 .then_some(())
12673 })
12674 .await;
12675 assert!(started, "added HTTP check must start at its first deadline");
12676 assert_eq!(
12677 actor.module.status().unwrap().health.status,
12678 SupervisorHealthStatus::Ok
12679 );
12680 assert_eq!(Instant::now(), tick + Duration::from_millis(1));
12681 actor.health.http = None;
12682 let turns = actor.turns();
12683 actor
12684 .module
12685 .update_configuration(actor.spec.clone(), actor.health.clone(), None)
12686 .await
12687 .unwrap();
12688 let parked = actor
12689 .wait_for_select(|checkpoint| {
12690 checkpoint.turn > turns && checkpoint.next_probe_at.is_none()
12691 })
12692 .await;
12693 assert_eq!(parked.wake_after, None);
12694 let turns = actor.turns();
12695 tokio::time::advance(Duration::from_secs(60)).await;
12696 assert_eq!(
12697 actor.turns(),
12698 turns,
12699 "removed HTTP check must park the actor"
12700 );
12701 assert!(
12702 started_rx.try_recv().is_err(),
12703 "removed HTTP check must stay disarmed"
12704 );
12705 assert_eq!(
12706 actor.module.status().unwrap().health,
12707 ModuleHealthStatus::default()
12708 );
12709 peer.abort();
12710 }
12711}
12712
12713#[cfg(test)]
12714mod health_evidence_tests {
12715 use super::{HealthProbeError, HealthProbeEvidence};
12716 use std::collections::HashSet;
12717
12718 #[test]
12726 fn only_a_dead_lane_is_proof_of_death() {
12727 assert!(HealthProbeError::lane_dead("gone").is_proof_of_death());
12728 assert!(!HealthProbeError::no_answer("timed out").is_proof_of_death());
12732 assert!(!HealthProbeError::bad_answer("garbage").is_proof_of_death());
12733 assert!(!HealthProbeError::misconfigured("no table").is_proof_of_death());
12734 }
12735
12736 #[test]
12742 fn every_evidence_class_has_a_distinct_label() {
12743 let labels = [
12744 HealthProbeError::lane_dead("").label(),
12745 HealthProbeError::no_answer("").label(),
12746 HealthProbeError::bad_answer("").label(),
12747 HealthProbeError::misconfigured("").label(),
12748 ];
12749 let unique: HashSet<_> = labels.iter().collect();
12750 assert_eq!(unique.len(), labels.len(), "labels collided: {labels:?}");
12751 }
12752
12753 #[test]
12759 fn classification_preserves_the_original_message() {
12760 let err = HealthProbeError::no_answer("module did not answer within 5s");
12761 assert_eq!(err.to_string(), "module did not answer within 5s");
12762 assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
12763 }
12764}
12765
12766#[cfg(test)]
12767mod health_tombstone_tests {
12768 use std::{path::PathBuf, sync::Arc, time::Duration};
12769
12770 use subc_protocol::{
12771 manifest::Concurrency,
12772 session::{HealthStatus, ModuleControlResponse},
12773 };
12774 use tokio::sync::mpsc;
12775
12776 use super::{
12777 probe_module_health, HealthAction, HealthConfig, HealthProbeEvidence, ModuleProtocol,
12778 ModuleSpec, RestartPolicy, Supervisor, SupervisorRuntimeConfig,
12779 };
12780 use crate::{
12781 control::ControlHandler,
12782 forwarding::{ForwardingTable, ModuleControlRpcCompletion, ModuleControlRpcOutcome},
12783 registry::{ConnectionId, Registry},
12784 router::FrameSink,
12785 };
12786
12787 struct ProbeHarness {
12788 spec: ModuleSpec,
12789 runtime: SupervisorRuntimeConfig,
12790 forwarding: Arc<ForwardingTable>,
12791 module_connection: ConnectionId,
12792 module_rx: mpsc::Receiver<crate::router::OutboundFrame>,
12793 handler: ControlHandler,
12794 module: super::SupervisedModule,
12795 }
12796
12797 fn probe_harness() -> ProbeHarness {
12798 let registry = Arc::new(Registry::default());
12799 let forwarding = Arc::new(ForwardingTable::default());
12800 let supervisor_handle = super::SupervisorHandle::new();
12801 let health = HealthConfig {
12802 http: None,
12803 cadence: Duration::from_secs(30),
12804 deadline: Duration::from_secs(5),
12805 failure_threshold: 3,
12806 on_degraded: HealthAction::Report,
12807 on_failing: HealthAction::Report,
12808 critical: false,
12809 };
12810 let supervisor = Supervisor::new_for_test(Arc::clone(®istry), RestartPolicy::default())
12811 .with_forwarding(Arc::clone(&forwarding))
12812 .with_handle(supervisor_handle.clone())
12813 .with_health_config(health);
12814 let spec = ModuleSpec {
12815 module_id: "late-health-module".to_string(),
12816 program: PathBuf::from("disabled-module"),
12817 args: Vec::new(),
12818 env: Vec::new(),
12819 reserved: false,
12820 reserved_prefixes: Vec::new(),
12821 protocol: ModuleProtocol::Subc,
12822 overlap: Default::default(),
12823 };
12824 let module = supervisor
12825 .supervise_configured(spec.clone(), false)
12826 .unwrap();
12827 let runtime = supervisor.runtime_config();
12828 let handler = ControlHandler::with_forwarding(registry, Arc::clone(&forwarding))
12829 .with_supervisor(supervisor_handle);
12830 let module_connection = ConnectionId::new(700);
12831 let (module_tx, module_rx) = mpsc::channel(8);
12832 forwarding
12833 .register_module_connection(
12834 module_connection,
12835 spec.module_id.clone(),
12836 subc_protocol::PROTOCOL_VERSION,
12837 Concurrency::ModuleManaged,
12838 FrameSink::new(module_tx),
12839 )
12840 .unwrap();
12841
12842 ProbeHarness {
12843 spec,
12844 runtime,
12845 forwarding,
12846 module_connection,
12847 module_rx,
12848 handler,
12849 module,
12850 }
12851 }
12852
12853 async fn finish_after(
12854 harness: &mut ProbeHarness,
12855 stall: Duration,
12856 ) -> ModuleControlRpcCompletion {
12857 assert!(stall > harness.runtime.health.deadline);
12858 let deadline = harness.runtime.health.deadline;
12859 let probe = probe_module_health(&harness.spec.module_id, &harness.runtime, None);
12860 let answer = async {
12861 let frame = harness.module_rx.recv().await.expect("health.check frame");
12862 tokio::time::advance(deadline).await;
12863 tokio::task::yield_now().await;
12864 tokio::time::advance(stall - deadline).await;
12865 harness
12866 .forwarding
12867 .complete_module_control_rpc(
12868 harness.module_connection,
12869 frame.header.corr,
12870 Some("health.check"),
12871 ModuleControlRpcOutcome::Response(ModuleControlResponse::HealthCheck {
12872 status: HealthStatus::Ok,
12873 detail: None,
12874 metrics: None,
12875 }),
12876 )
12877 .unwrap()
12878 };
12879 let (probe_result, completion) = tokio::join!(probe, answer);
12880 let err = probe_result.expect_err("probe must miss its deadline");
12881 assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
12882 completion
12883 }
12884
12885 async fn time_out_without_answer(harness: &mut ProbeHarness) {
12886 let deadline = harness.runtime.health.deadline;
12887 let probe = probe_module_health(&harness.spec.module_id, &harness.runtime, None);
12888 let exhaust_deadline = async {
12889 let _frame = harness.module_rx.recv().await.expect("health.check frame");
12890 tokio::time::advance(deadline).await;
12891 tokio::task::yield_now().await;
12892 };
12893 let (probe_result, ()) = tokio::join!(probe, exhaust_deadline);
12894 let err = probe_result.expect_err("probe must miss its deadline");
12895 assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
12896 }
12897
12898 async fn run_probe_cycle(harness: &mut ProbeHarness, answer: bool) {
12899 let registry = Arc::clone(&harness.module.inner.registry);
12900 let snapshot = Arc::clone(&harness.module.inner.snapshot);
12901 let process_liveness = super::SupervisorProcessLiveness::default();
12902 let mut child = None;
12903 let cycle = super::run_health_probe_cycle(
12904 &harness.spec,
12905 &harness.runtime,
12906 ®istry,
12907 &process_liveness,
12908 &snapshot,
12909 &mut child,
12910 );
12911 let peer = async {
12912 let frame = harness.module_rx.recv().await.expect("health.check frame");
12913 if answer {
12914 harness
12915 .forwarding
12916 .complete_module_control_rpc(
12917 harness.module_connection,
12918 frame.header.corr,
12919 Some("health.check"),
12920 ModuleControlRpcOutcome::Response(ModuleControlResponse::HealthCheck {
12921 status: HealthStatus::Ok,
12922 detail: None,
12923 metrics: Some(serde_json::json!({"ready": true})),
12924 }),
12925 )
12926 .unwrap();
12927 } else {
12928 tokio::time::advance(harness.runtime.health.deadline).await;
12929 tokio::task::yield_now().await;
12930 }
12931 };
12932 tokio::join!(cycle, peer);
12933 }
12934
12935 #[tokio::test(start_paused = true)]
12936 async fn unanswered_probe_is_unknown_until_threshold_and_ok_report_recovers() {
12937 let mut harness = probe_harness();
12938 let monitor = harness.module.inner.monitor.lock().unwrap().take().unwrap();
12942 monitor.abort();
12943 let _ = monitor.await;
12944 super::update_snapshot(&harness.module.inner.snapshot, None, |state| {
12945 state.enabled = true;
12946 state.state = super::ModuleState::Running;
12947 state.process_alive = true;
12948 })
12949 .unwrap();
12950
12951 run_probe_cycle(&mut harness, true).await;
12952 assert_eq!(
12953 harness.module.status().unwrap().health.status,
12954 super::SupervisorHealthStatus::Ok
12955 );
12956
12957 for failures in 1..harness.runtime.health.failure_threshold {
12958 run_probe_cycle(&mut harness, false).await;
12959 let status = harness.module.status().unwrap();
12960 assert_eq!(status.health.status, super::SupervisorHealthStatus::Unknown);
12961 assert_eq!(status.health.consecutive_failures, failures);
12962 assert!(status.health.last_probe_ms.is_some());
12963 assert!(status.health.detail.unwrap().starts_with("[no-answer]"));
12964 assert!(status.health.metrics.is_none());
12965 assert_eq!(status.state, super::ModuleState::Running);
12966 assert!(status.process_alive);
12967 assert_eq!(status.restart_count, 0);
12968 assert_eq!(status.lifetime_restarts, 0);
12969 assert!(status.health.last_action.is_none());
12970 assert!(harness.runtime.scheduled_respawn.lock().unwrap().is_none());
12971 }
12972
12973 run_probe_cycle(&mut harness, true).await;
12974 let recovered = harness.module.status().unwrap();
12975 assert_eq!(recovered.health.status, super::SupervisorHealthStatus::Ok);
12976 assert_eq!(recovered.health.consecutive_failures, 0);
12977 assert!(recovered.health.detail.is_none());
12978 assert_eq!(
12979 recovered.health.metrics,
12980 Some(serde_json::json!({"ready": true}))
12981 );
12982 assert_eq!(recovered.lifetime_restarts, 0);
12983
12984 for failures in 1..=harness.runtime.health.failure_threshold {
12985 run_probe_cycle(&mut harness, false).await;
12986 let status = harness.module.status().unwrap();
12987 assert_eq!(status.health.consecutive_failures, failures);
12988 if failures < harness.runtime.health.failure_threshold {
12989 assert_eq!(status.health.status, super::SupervisorHealthStatus::Unknown);
12990 assert_eq!(status.state, super::ModuleState::Running);
12991 assert_eq!(status.lifetime_restarts, 0);
12992 assert!(harness.runtime.scheduled_respawn.lock().unwrap().is_none());
12993 } else {
12994 assert_eq!(
12995 status.health.status,
12996 super::SupervisorHealthStatus::Unresponsive
12997 );
12998 assert_eq!(status.state, super::ModuleState::Restarting);
12999 assert_eq!(status.restart_count, 1);
13000 assert_eq!(status.lifetime_restarts, 1);
13001 assert_eq!(status.health.last_action.as_deref(), Some("restart"));
13002 assert!(harness.runtime.scheduled_respawn.lock().unwrap().is_some());
13003 }
13004 }
13005 }
13006
13007 #[tokio::test(start_paused = true)]
13008 async fn late_health_answers_record_start_anchored_latency_for_two_stalls() {
13009 let mut harness = probe_harness();
13010
13011 let first = finish_after(&mut harness, Duration::from_secs(8)).await;
13012 let first_latency = match &first {
13013 ModuleControlRpcCompletion::LateHealthAnswer { latency, .. } => *latency,
13014 other => panic!("late answer was not retained: {other:?}"),
13015 };
13016 assert!(harness.handler.observe_module_control_completion(first));
13017
13018 let second = finish_after(&mut harness, Duration::from_secs(11)).await;
13019 let second_latency = match &second {
13020 ModuleControlRpcCompletion::LateHealthAnswer { latency, .. } => *latency,
13021 other => panic!("late answer was not retained: {other:?}"),
13022 };
13023 assert!(harness.handler.observe_module_control_completion(second));
13024
13025 assert_eq!(first_latency, Duration::from_secs(8));
13026 assert_eq!(
13027 second_latency - first_latency,
13028 Duration::from_secs(3),
13029 "latency must grow linearly with the additional stall"
13030 );
13031 let health = harness.module.status().unwrap().health;
13032 assert_eq!(health.late_answer_count, 2);
13033 assert_eq!(health.last_late_answer_latency_ms, Some(11_000));
13034 }
13035
13036 #[tokio::test(start_paused = true)]
13044 async fn late_answer_clears_the_consecutive_failure_streak() {
13045 let mut harness = probe_harness();
13046
13047 time_out_without_answer(&mut harness).await;
13049 harness
13050 .module
13051 .record_health_probe_failure_for_test("[no-answer] test miss")
13052 .unwrap();
13053 assert_eq!(
13054 harness.module.status().unwrap().health.consecutive_failures,
13055 1,
13056 "precondition: the miss must be on the streak before the late answer"
13057 );
13058
13059 let late = finish_after(&mut harness, Duration::from_secs(9)).await;
13061 assert!(matches!(
13062 late,
13063 ModuleControlRpcCompletion::LateHealthAnswer { .. }
13064 ));
13065 assert!(harness.handler.observe_module_control_completion(late));
13066
13067 let health = harness.module.status().unwrap().health;
13068 assert_eq!(
13069 health.consecutive_failures, 0,
13070 "a late answer is an answer: the streak must reset"
13071 );
13072 assert_eq!(health.late_answer_count, 1);
13073 }
13074
13075 #[tokio::test(start_paused = true)]
13076 async fn repeated_serial_probe_cycles_keep_one_tombstone_per_endpoint() {
13077 let mut harness = probe_harness();
13078
13079 for _ in 0..20 {
13080 time_out_without_answer(&mut harness).await;
13081 assert_eq!(
13082 harness.forwarding.health_probe_tombstone_count().unwrap(),
13083 1
13084 );
13085 }
13086 }
13087}
13088
13089#[cfg(test)]
13090mod child_env_tests {
13091 #[cfg(windows)]
13092 use super::{
13093 clear_current_process_facts, observed_launch_nonce_source, ModuleState, SupervisorSnapshot,
13094 };
13095 #[cfg(windows)]
13096 use std::sync::{Arc, OnceLock};
13097 #[cfg(windows)]
13098 #[test]
13099 fn windows_wire_spawn_prepares_pipe_and_keeps_phase_one_env() {
13100 let spec = spec(Vec::new());
13101 let mut command = Command::new("unused");
13102 let handoff = apply_wire_spawn_args(&mut command, &spec, None, None)
13103 .unwrap()
13104 .unwrap();
13105 let name = command
13106 .as_std()
13107 .get_envs()
13108 .find(|(key, _)| *key == OsStr::new(subc_os::launch_nonce::LAUNCH_NONCE_PIPE_ENV))
13109 .and_then(|(_, value)| value)
13110 .unwrap();
13111 assert_eq!(name, OsStr::new(handoff.name()));
13112 assert!(handoff.name().starts_with(r"\\.\pipe\subc-launch-"));
13113 assert!(command.as_std().get_envs().any(|(key, value)| key
13114 == OsStr::new(subc_os::LAUNCH_NONCE_PIPE_FALLBACK_ENV)
13115 && value == Some(OsStr::new("env"))));
13116 assert!(command
13117 .as_std()
13118 .get_envs()
13119 .any(|(key, value)| key == OsStr::new(SUBC_LAUNCH_NONCE_ENV) && value.is_some()));
13120 }
13121
13122 #[cfg(windows)]
13123 #[test]
13124 fn windows_source_reporting_tracks_consumption_and_resets_per_spawn() {
13125 let mut snapshot = SupervisorSnapshot::new(ModuleState::Running, true);
13126 snapshot.process_alive = true;
13127 snapshot.spawned_protocol = Some(ModuleProtocol::Subc);
13128 assert_eq!(
13129 observed_launch_nonce_source(&snapshot).as_deref(),
13130 Some("env")
13131 );
13132 let consumed = Arc::new(OnceLock::new());
13133 snapshot.pipe_consumed = Some(consumed.clone());
13134 assert_eq!(
13135 observed_launch_nonce_source(&snapshot).as_deref(),
13136 Some("env")
13137 );
13138 consumed.set(()).unwrap();
13139 assert_eq!(
13140 observed_launch_nonce_source(&snapshot).as_deref(),
13141 Some("pipe")
13142 );
13143 snapshot.pipe_consumed = Some(Arc::new(OnceLock::new()));
13144 assert_eq!(
13145 observed_launch_nonce_source(&snapshot).as_deref(),
13146 Some("env")
13147 );
13148 clear_current_process_facts(&mut snapshot);
13149 assert_eq!(observed_launch_nonce_source(&snapshot), None);
13150 }
13151 use super::{
13152 apply_child_env, apply_spawn_role, apply_wire_spawn_args, ModuleProtocol, ModuleSpec,
13153 SpawnRole, SupervisorHandle, SPAWN_ROLE_SWAP_CANDIDATE, SUBC_ARG, SUBC_LAUNCH_NONCE_ENV,
13154 SUBC_MODULE_ID_ENV, SUBC_SPAWN_ROLE_ENV,
13155 };
13156 use std::{ffi::OsStr, path::PathBuf};
13157 use tokio::process::Command;
13158
13159 fn spec(env: Vec<(String, String)>) -> ModuleSpec {
13160 ModuleSpec {
13161 module_id: "env-plan".to_string(),
13162 program: PathBuf::from("/nonexistent"),
13163 args: Vec::new(),
13164 env,
13165 reserved: false,
13166 reserved_prefixes: Vec::new(),
13167 protocol: ModuleProtocol::Subc,
13168 overlap: Default::default(),
13169 }
13170 }
13171
13172 #[test]
13186 fn ambient_ck_log_is_removed_and_a_configured_one_survives() {
13187 let mut command = Command::new("/nonexistent");
13188 apply_child_env(&mut command, &spec(Vec::new()));
13189 let removed = command
13190 .as_std()
13191 .get_envs()
13192 .any(|(key, value)| key == OsStr::new("CK_LOG") && value.is_none());
13193 assert!(
13194 removed,
13195 "ambient CK_LOG must be explicitly removed for an unconfigured module"
13196 );
13197
13198 let mut configured = Command::new("/nonexistent");
13199 apply_child_env(
13200 &mut configured,
13201 &spec(vec![("CK_LOG".to_string(), "debug".to_string())]),
13202 );
13203 let effective = configured
13204 .as_std()
13205 .get_envs()
13206 .filter(|(key, _)| *key == OsStr::new("CK_LOG"))
13207 .last()
13208 .map(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()));
13209 assert_eq!(
13210 effective,
13211 Some(Some("debug".to_string())),
13212 "a module's configured CK_LOG must survive the ambient removal"
13213 );
13214 }
13215
13216 #[test]
13225 fn protocol_none_spawn_carries_no_subc_argument_and_no_nonce() {
13226 let connection_file = std::path::Path::new("/run/subc-connection.json");
13227 let handle = SupervisorHandle::new();
13228
13229 let mut none_spec = spec(Vec::new());
13230 none_spec.protocol = ModuleProtocol::None;
13231 let mut none = Command::new("/nonexistent");
13232 let none_handoff =
13233 apply_wire_spawn_args(&mut none, &none_spec, Some(connection_file), Some(&handle))
13234 .expect("protocol-none spawn args apply");
13235 assert!(
13236 none_handoff.is_none(),
13237 "protocol:none spawn must not receive a nonce descriptor"
13238 );
13239 #[cfg(windows)]
13240 assert!(!none.as_std().get_envs().any(|(key, value)| key
13241 == OsStr::new(subc_os::LAUNCH_NONCE_PIPE_FALLBACK_ENV)
13242 && value.is_some()));
13243 assert!(
13244 !none.as_std().get_envs().any(|(key, value)| key
13245 == OsStr::new(subc_os::LAUNCH_NONCE_FD_ENV)
13246 && value.is_some()),
13247 "protocol:none spawn must not name a nonce descriptor"
13248 );
13249 let none_args: Vec<String> = none
13250 .as_std()
13251 .get_args()
13252 .map(|a| a.to_string_lossy().into_owned())
13253 .collect();
13254 assert!(
13255 !none_args.iter().any(|a| a == SUBC_ARG),
13256 "protocol:none argv must not carry --subc; got {none_args:?}"
13257 );
13258 let none_has_nonce = none
13259 .as_std()
13260 .get_envs()
13261 .any(|(key, value)| key == OsStr::new(SUBC_LAUNCH_NONCE_ENV) && value.is_some());
13262 assert!(
13263 !none_has_nonce,
13264 "protocol:none spawn must not receive a launch nonce"
13265 );
13266 let none_has_module_id = none
13267 .as_std()
13268 .get_envs()
13269 .any(|(key, value)| key == OsStr::new(SUBC_MODULE_ID_ENV) && value.is_some());
13270 assert!(
13271 none_has_module_id,
13272 "SUBC_MODULE_ID is inert and stays on every path"
13273 );
13274 assert!(
13275 handle.spawn_nonce(&none_spec.module_id).is_none(),
13276 "no nonce record for a process that will never present one"
13277 );
13278
13279 let wire_spec = spec(Vec::new());
13281 let mut wire = Command::new("/nonexistent");
13282 let wire_handoff =
13283 apply_wire_spawn_args(&mut wire, &wire_spec, Some(connection_file), Some(&handle))
13284 .expect("subc-wire spawn args apply");
13285 let wire_fd_env = wire
13286 .as_std()
13287 .get_envs()
13288 .find(|(key, _)| *key == OsStr::new(subc_os::LAUNCH_NONCE_FD_ENV))
13289 .and_then(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()));
13290 #[cfg(unix)]
13291 assert_eq!(
13292 wire_fd_env,
13293 Some(wire_handoff.expect("a descriptor handoff").fd_env_value()),
13294 "a subc-wire spawn names the pipe it will receive at descriptor 3"
13295 );
13296 #[cfg(windows)]
13297 assert!(wire_handoff.is_some() && wire_fd_env.is_none());
13298 #[cfg(not(any(unix, windows)))]
13299 assert!(wire_handoff.is_none() && wire_fd_env.is_none());
13300 let wire_args: Vec<String> = wire
13301 .as_std()
13302 .get_args()
13303 .map(|a| a.to_string_lossy().into_owned())
13304 .collect();
13305 assert_eq!(
13306 wire_args,
13307 vec![
13308 SUBC_ARG.to_string(),
13309 connection_file.to_string_lossy().into_owned()
13310 ],
13311 "a subc-wire spawn still carries --subc <path>"
13312 );
13313 assert_eq!(
13314 wire.as_std()
13315 .get_envs()
13316 .any(|(key, value)| key == OsStr::new(SUBC_LAUNCH_NONCE_ENV) && value.is_some()),
13317 !cfg!(unix),
13318 "only Windows supplies the environment nonce"
13319 );
13320 assert!(handle.spawn_nonce(&wire_spec.module_id).is_some());
13321 }
13322
13323 #[test]
13333 fn plain_spawn_removes_the_spawn_role_even_when_the_spec_sets_it() {
13334 let role = |command: &Command| {
13335 command
13336 .as_std()
13337 .get_envs()
13338 .filter(|(key, _)| *key == OsStr::new(SUBC_SPAWN_ROLE_ENV))
13339 .last()
13340 .map(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()))
13341 };
13342 let forged = spec(vec![(
13343 SUBC_SPAWN_ROLE_ENV.to_string(),
13344 SPAWN_ROLE_SWAP_CANDIDATE.to_string(),
13345 )]);
13346
13347 let mut plain = Command::new("/nonexistent");
13348 apply_child_env(&mut plain, &forged);
13349 apply_spawn_role(&mut plain, SpawnRole::Plain);
13350 assert_eq!(
13351 role(&plain),
13352 Some(None),
13353 "a plain spawn must remove SUBC_SPAWN_ROLE, whatever the spec says"
13354 );
13355
13356 let mut candidate = Command::new("/nonexistent");
13357 apply_child_env(&mut candidate, &spec(Vec::new()));
13358 apply_spawn_role(&mut candidate, SpawnRole::SwapCandidate);
13359 assert_eq!(
13360 role(&candidate),
13361 Some(Some(SPAWN_ROLE_SWAP_CANDIDATE.to_string()))
13362 );
13363 }
13364
13365 #[test]
13371 fn daemon_private_capture_keys_are_not_passed_to_the_child() {
13372 let mut command = Command::new("/nonexistent");
13373 apply_child_env(
13374 &mut command,
13375 &spec(vec![
13376 (super::CAPTURE_KEEP_ENV.to_string(), "5".to_string()),
13377 ("KEPT".to_string(), "yes".to_string()),
13378 ]),
13379 );
13380 let keys: Vec<String> = command
13381 .as_std()
13382 .get_envs()
13383 .filter(|(_, value)| value.is_some())
13384 .map(|(key, _)| key.to_string_lossy().into_owned())
13385 .collect();
13386 assert!(keys.contains(&"KEPT".to_string()), "got {keys:?}");
13387 assert!(
13388 !keys.contains(&super::CAPTURE_KEEP_ENV.to_string()),
13389 "daemon-private capture key leaked to the child: {keys:?}"
13390 );
13391 }
13392}
13393
13394#[cfg(test)]
13395mod jitter_tests {
13396 use super::jittered_health_delay;
13397 use std::{collections::HashSet, time::Duration};
13398
13399 const FLEET: [&str; 14] = [
13408 "aft",
13409 "alfonso-core",
13410 "magic-context",
13411 "broca",
13412 "thalamus",
13413 "quota",
13414 "engram",
13415 "plexus",
13416 "cerebellum",
13417 "astrocyte",
13418 "synapse",
13419 "subc-mcp",
13420 "cortexkit-credentials",
13421 "subc-federation",
13422 ];
13423
13424 #[test]
13432 fn probe_delays_disperse_across_the_fleet() {
13433 let cadence = Duration::from_secs(30);
13434 let delays: HashSet<Duration> = FLEET
13435 .iter()
13436 .map(|id| jittered_health_delay(id, 0, cadence))
13437 .collect();
13438 assert_eq!(
13439 delays.len(),
13440 FLEET.len(),
13441 "every supervised module must land on its own probe offset"
13442 );
13443 }
13444
13445 #[test]
13451 fn jitter_only_delays_and_stays_within_one_tenth_of_cadence() {
13452 let cadence = Duration::from_secs(30);
13453 let span = cadence / 10;
13454 for id in FLEET {
13455 for probe_index in 0..8 {
13456 let delay = jittered_health_delay(id, probe_index, cadence);
13457 assert!(
13458 delay >= cadence,
13459 "{id}#{probe_index}: jitter must not shorten the cadence"
13460 );
13461 assert!(
13462 delay < cadence + span,
13463 "{id}#{probe_index}: jitter must stay inside one tenth of the cadence"
13464 );
13465 }
13466 }
13467 }
13468
13469 #[test]
13475 fn a_module_offset_is_stable_across_restarts() {
13476 let cadence = Duration::from_secs(30);
13477 for id in FLEET {
13478 assert_eq!(
13479 jittered_health_delay(id, 0, cadence),
13480 jittered_health_delay(id, 0, cadence),
13481 "{id}: the same module and probe index must produce the same offset"
13482 );
13483 }
13484 }
13485
13486 #[test]
13488 fn zero_cadence_yields_zero_delay() {
13489 assert_eq!(
13490 jittered_health_delay("aft", 0, Duration::ZERO),
13491 Duration::ZERO
13492 );
13493 }
13494}
13495
13496#[cfg(all(test, target_os = "linux"))]
13497mod cgroup_placement_tests {
13498 use super::{
13499 apply_cgroup_placement, remove_module_cgroup, ModuleProtocol, ModuleSpec, SuperviseError,
13500 SupervisedChild,
13501 };
13502 use crate::stderr_tail::{StderrRing, StderrTailConfig};
13503 use cortexkit_test_support::ScratchDir;
13504 use std::{
13505 fs, io,
13506 path::{Path, PathBuf},
13507 sync::{Arc, Mutex},
13508 };
13509 use tokio::process::Command;
13510
13511 #[tokio::test]
13512 async fn unique_spawn_cgroups_clean_up_across_restarts_and_shutdown() {
13513 use super::*;
13514 let dir = ScratchDir::new("unique-spawn-cgroups");
13515 let root = PathBuf::from(format!(
13516 "/sys/fs/cgroup/subc-unique-test-{}-{}",
13517 std::process::id(),
13518 unix_ms_now()
13519 ));
13520 if let Err(error) = fs::create_dir(&root) {
13521 assert!(
13522 std::env::var_os("SUBC_REQUIRE_CGROUP_TEST").is_none(),
13523 "required cgroup test cannot execute: {error}"
13524 );
13525 eprintln!("SKIP unique_spawn_cgroups_clean_up_across_restarts_and_shutdown: {error}");
13526 return;
13527 }
13528 let placement = subc_cgroup::prepare_at(&root).unwrap().unwrap();
13529 let group_count = || {
13530 fs::read_dir(root.join("subc-modules"))
13531 .unwrap()
13532 .map(|entry| entry.unwrap().file_type().unwrap())
13533 .filter(|kind| kind.is_dir())
13534 .count()
13535 };
13536 let supervisor =
13537 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
13538 .with_cgroup_placement(Some(placement.clone()));
13539 let runtime = supervisor.runtime_config();
13540 let mut spec = ModuleSpec {
13541 module_id: "unique-spawn".into(),
13542 program: PathBuf::from("/bin/sleep"),
13543 args: vec!["60".into()],
13544 env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
13545 .into_iter()
13546 .map(|key| (key.into(), dir.to_string_lossy().into_owned()))
13547 .collect(),
13548 reserved: false,
13549 reserved_prefixes: vec![],
13550 protocol: ModuleProtocol::None,
13551 overlap: Default::default(),
13552 };
13553 let spawn = |spec: &ModuleSpec| {
13554 spawn_child(
13555 spec,
13556 None,
13557 None,
13558 &runtime.stderr_ring,
13559 None,
13560 &runtime.child_roster,
13561 Some(&placement),
13562 )
13563 .unwrap()
13564 };
13565 let mut live = spawn(&spec);
13566 for _ in 0..3 {
13567 let next = spawn(&spec);
13569 assert_ne!(live.module_id, next.module_id);
13570 live.start_kill().unwrap();
13571 live.wait().await.unwrap();
13572 live = next;
13573 assert!(
13574 live.child.try_wait().unwrap().is_none(),
13575 "retiring the old slot must not kill the replacement"
13576 );
13577 assert_eq!(
13578 group_count(),
13579 1,
13580 "only the live spawn's cgroup should remain"
13581 );
13582 }
13583 supervisor.begin_daemon_shutdown();
13584 let reap = tokio::spawn(async move {
13585 live.wait().await.unwrap();
13586 });
13587 supervisor
13588 .end_children_for_daemon_shutdown(false, std::future::pending())
13589 .await;
13590 reap.await.unwrap();
13591 assert_eq!(group_count(), 0);
13592 spec.program = PathBuf::from("/bin/true");
13594 spec.args.clear();
13595 let fresh_roster = ChildRoster::default();
13596 let mut short = spawn_child(
13597 &spec,
13598 None,
13599 None,
13600 &runtime.stderr_ring,
13601 None,
13602 &fresh_roster,
13603 Some(&placement),
13604 )
13605 .unwrap();
13606 short.wait().await.unwrap();
13607 assert_eq!(group_count(), 0);
13608 spec.module_id = "_".repeat(255);
13609 let mut long_id = spawn_child(
13610 &spec,
13611 None,
13612 None,
13613 &runtime.stderr_ring,
13614 None,
13615 &fresh_roster,
13616 Some(&placement),
13617 )
13618 .unwrap();
13619 long_id.wait().await.unwrap();
13620 assert_eq!(
13621 group_count(),
13622 0,
13623 "valid long module IDs must not exceed cgroup NAME_MAX"
13624 );
13625 fs::remove_dir(root.join("subc-modules")).unwrap();
13626 fs::remove_dir(root).unwrap();
13627 eprintln!("EXECUTED unique_spawn_cgroups_clean_up_across_restarts_and_shutdown");
13628 }
13629
13630 #[test]
13631 fn failed_parent_cgroup_open_is_a_cgroup_supervision_error() {
13632 let path = Path::new("/definitely-missing-subc-cgroup");
13633 let mut command = Command::new("true");
13634 let error = apply_cgroup_placement(
13635 &mut command,
13636 &ModuleSpec {
13637 module_id: "broken-cgroup".to_string(),
13638 program: PathBuf::from("true"),
13639 args: Vec::new(),
13640 env: Vec::new(),
13641 reserved: false,
13642 reserved_prefixes: Vec::new(),
13643 protocol: ModuleProtocol::Subc,
13644 overlap: Default::default(),
13645 },
13646 path,
13647 )
13648 .expect_err("a parent cgroup open failure must reject the supervised spawn");
13649 let reason = error.to_string();
13650
13651 assert!(
13652 matches!(error, SuperviseError::Cgroup { .. }),
13653 "parent cgroup open must be reported as a cgroup supervision error: {reason}"
13654 );
13655 assert!(
13656 reason.contains("/definitely-missing-subc-cgroup/cgroup.procs"),
13657 "parent cgroup open failure must name cgroup.procs: {reason}"
13658 );
13659 }
13660
13661 #[tokio::test]
13662 async fn reaping_a_child_removes_its_empty_module_cgroup() {
13663 let root = ScratchDir::new("supervisor-reap-cgroup");
13664 fs::write(root.join("cgroup.procs"), b"").expect("write scratch cgroup marker");
13665 let placement = subc_cgroup::prepare_at(&root)
13666 .expect("prepare scratch cgroup root")
13667 .expect("scratch root has a cgroup.procs marker");
13668 let module_id = "reaped-module";
13669 let module = placement
13670 .module_path(module_id)
13671 .expect("create scratch module cgroup");
13672 let child = Command::new("true")
13673 .env("XDG_DATA_HOME", root.path())
13674 .env("XDG_RUNTIME_DIR", root.path())
13675 .env("XDG_CONFIG_HOME", root.path())
13676 .spawn()
13677 .expect("spawn short-lived child");
13678 let pid = child.id().expect("spawned child has pid");
13679 let mut child = SupervisedChild {
13680 child,
13681 protocol: ModuleProtocol::Subc,
13682 module_id: module_id.to_string(),
13683 cgroup_placement: Some(placement),
13684 stdout_pump: None,
13685 stderr_pump: None,
13686 stderr_ring: Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default()))),
13687 spawned_at_ms: 0,
13688 spawned_from: PathBuf::from("true"),
13689 spawned_file_identity: None,
13690 process_start_time: None,
13691 process_identity: None,
13692 pid,
13693 roster_guard: None,
13694 #[cfg(target_os = "macos")]
13695 privacy_exec: None,
13696 spawn_failure: None,
13697 };
13698
13699 child.wait().await.expect("reap short-lived child");
13700
13701 assert!(
13702 !module.exists(),
13703 "reaping the supervised child must remove its empty cgroup"
13704 );
13705 }
13706
13707 #[test]
13708 fn non_empty_cgroup_removal_is_reported_without_blocking_teardown() {
13709 let root = ScratchDir::new("supervisor-non-empty-cgroup");
13710 fs::write(root.join("cgroup.procs"), b"").expect("write scratch cgroup marker");
13711 let placement = subc_cgroup::prepare_at(&root)
13712 .expect("prepare scratch cgroup root")
13713 .expect("scratch root has a cgroup.procs marker");
13714 let module = placement
13715 .module_path("surviving-module")
13716 .expect("create scratch module cgroup");
13717 fs::write(module.join("surviving-process"), b"still present")
13718 .expect("make scratch cgroup non-empty");
13719 let (logs, _guard) = crate::router::test_log::log_capture(tracing::Level::WARN);
13720
13721 remove_module_cgroup(&placement, "surviving-module");
13722
13723 let logs = crate::router::test_log::captured_logs(&logs);
13724 assert!(
13725 module.exists(),
13726 "failed removal must leave the cgroup intact"
13727 );
13728 assert!(
13729 logs.contains("could not remove module cgroup after process exit; continuing teardown")
13730 && logs.contains("surviving-module"),
13731 "best-effort removal must report the failure without returning it: {logs}"
13732 );
13733 }
13734
13735 #[test]
13736 fn cgroup_pre_exec_spawn_failure_names_the_cgroup_path() {
13737 let cgroup_path = PathBuf::from("/sys/fs/cgroup/subc-modules/broken-module");
13738 let reason = SuperviseError::Spawn {
13739 program: PathBuf::from("/bin/true"),
13740 source: io::Error::from_raw_os_error(13),
13741 cgroup_path: Some(cgroup_path.clone()),
13742 }
13743 .to_string();
13744
13745 assert!(
13746 reason.contains(&cgroup_path.display().to_string()),
13747 "a pre_exec spawn failure must name the cgroup path: {reason}"
13748 );
13749 }
13750}
13751
13752#[cfg(test)]
13753mod spawn_subscriber_lag_tests {
13754 use super::*;
13755
13756 #[tokio::test]
13761 async fn lagged_spawn_subscriber_receives_a_terminal_lagged_error_after_its_queued_frames() {
13762 let feed = SpawnEventFeed::default();
13763 feed.configure_incarnation("lag-incarnation".to_string());
13764 let (tx, mut rx) = mpsc::channel(1);
13767 feed.subscribe(ConnectionId::new(1), 7, 1, None, FrameSink::new(tx))
13768 .expect("subscribe");
13769 let emitted = SPAWN_SUBSCRIBER_BUFFER + 16;
13770 for index in 0..emitted {
13771 feed.emit_spawned(&format!("lag-module-{index}"), 1000, 0);
13772 tokio::task::yield_now().await;
13775 }
13776 assert_eq!(
13777 feed.subscriber_count(),
13778 0,
13779 "the lagged subscriber must be removed"
13780 );
13781
13782 let mut data = Vec::new();
13783 let mut last = None;
13784 loop {
13785 let next = tokio::time::timeout(Duration::from_secs(5), rx.recv())
13786 .await
13787 .expect("the forwarder must finish once the subscriber is dropped");
13788 let Some(outbound) = next else { break };
13789 let frame = outbound.frame;
13790 if frame.header.ty == FrameType::StreamData {
13791 assert!(last.is_none(), "no data may follow the terminal frame");
13792 let event: SpawnEvent = serde_json::from_slice(&frame.body).unwrap();
13793 data.push(event.cursor.seq);
13794 } else {
13795 assert!(last.is_none(), "exactly one terminal frame");
13796 last = Some(frame);
13797 }
13798 }
13799 assert!(!data.is_empty(), "queued frames drain before the terminal");
13800 for pair in data.windows(2) {
13801 assert_eq!(
13802 pair[1],
13803 pair[0] + 1,
13804 "queued frames arrive dense and in order"
13805 );
13806 }
13807 let terminal = last.expect("a lagged subscriber must receive a terminal frame");
13808 assert_eq!(terminal.header.ty, FrameType::Error);
13809 assert_eq!(terminal.header.corr, 7);
13810 let body: subc_protocol::ErrorBody = serde_json::from_slice(&terminal.body).unwrap();
13811 assert_eq!(body.code, SPAWN_SUBSCRIBER_LAGGED_CODE);
13812 let detail = body.detail.expect("lagged error carries detail");
13813 assert_eq!(
13814 detail["first_undelivered_cursor"]["seq"],
13815 data.last().unwrap() + 1,
13816 "the named cursor is the first event the subscriber did not receive"
13817 );
13818 assert_eq!(
13819 detail["first_undelivered_cursor"]["daemon_incarnation"],
13820 "lag-incarnation"
13821 );
13822 }
13823}
13824
13825#[cfg(test)]
13826mod terminal_history_read_concurrency_tests {
13827 use super::*;
13828 use crate::terminal_journal::read_pause;
13829 use cortexkit_test_support::ScratchDir;
13830 use std::sync::mpsc as std_mpsc;
13831
13832 fn journaled_ring(
13833 journal: &Arc<crate::terminal_journal::TerminalJournal>,
13834 ) -> Arc<Mutex<TerminalRing>> {
13835 Arc::new(Mutex::new(
13836 TerminalRing::new(TerminalRingConfig::default(), 1)
13837 .with_journal(Some(Arc::clone(journal))),
13838 ))
13839 }
13840
13841 fn crash(at_ms: u64) -> ExitReport {
13842 ExitReport {
13843 kind: ExitKind::Crash,
13844 code: Some(1),
13845 signal: None,
13846 at_ms,
13847 }
13848 }
13849
13850 fn record_within(
13853 module_id: &'static str,
13854 ring: &Arc<Mutex<TerminalRing>>,
13855 at_ms: u64,
13856 bound: Duration,
13857 ) -> bool {
13858 let ring = Arc::clone(ring);
13859 let (done, done_rx) = std_mpsc::channel();
13860 std::thread::spawn(move || {
13861 record_terminal(
13862 module_id,
13863 &ring,
13864 &SpawnEventFeed::default(),
13865 &crash(at_ms),
13866 TerminalDisposition::Restarting,
13867 );
13868 let _ = done.send(());
13869 });
13870 done_rx.recv_timeout(bound).is_ok()
13871 }
13872
13873 #[test]
13878 fn exits_recorded_during_a_paused_history_read_are_not_blocked_or_half_merged() {
13879 let dir = ScratchDir::new("terminal-history-concurrent-read");
13880 let path = dir.join("terminals.jsonl");
13881 let journal = Arc::new(crate::terminal_journal::TerminalJournal::open(
13882 path.clone(),
13883 "daemon".into(),
13884 ));
13885 let reader_ring = journaled_ring(&journal);
13886 let other_ring = journaled_ring(&journal);
13887 assert!(record_within(
13888 "reader-module",
13889 &reader_ring,
13890 10,
13891 Duration::from_secs(5)
13892 ));
13893
13894 let (started, release) = read_pause::install(&path);
13895 let reading = {
13896 let ring = Arc::clone(&reader_ring);
13897 std::thread::spawn(move || durable_terminal_history_of(&ring, "reader-module"))
13898 };
13899 started
13900 .recv_timeout(Duration::from_secs(5))
13901 .expect("the history read reached its pause");
13902
13903 let bound = Duration::from_secs(1);
13904 assert!(
13905 record_within("other-module", &other_ring, 20, bound),
13906 "another module's exit waited on a history read (journal writer held)"
13907 );
13908 assert!(
13909 record_within("reader-module", &reader_ring, 30, bound),
13910 "the read module's own exit waited on its history read (ring held)"
13911 );
13912
13913 drop(release);
13914 let paused = reading.join().unwrap();
13915 assert_eq!(
13916 paused.entries.iter().map(|e| e.at_ms).collect::<Vec<_>>(),
13917 vec![10],
13918 "an exit recorded after the read began lands in neither half of it"
13919 );
13920 assert_eq!(paused.journal_skipped_lines, 0);
13921 assert_eq!(paused.journal_read_errors, 0);
13922
13923 let after = durable_terminal_history_of(&reader_ring, "reader-module");
13924 assert_eq!(
13925 after.entries.iter().map(|e| e.at_ms).collect::<Vec<_>>(),
13926 vec![10, 30],
13927 "the next read merges ring and journal with no duplicate"
13928 );
13929 assert_eq!(after.journal_skipped_lines, 0);
13930 }
13931}
13932
13933#[cfg(test)]
13938mod stderr_settle_tests {
13939 use std::{
13940 future::Future,
13941 io,
13942 pin::Pin,
13943 sync::{Arc, Mutex},
13944 task::{Context, Poll},
13945 time::Duration,
13946 };
13947
13948 use tokio::{
13949 io::{AsyncRead, ReadBuf},
13950 sync::oneshot,
13951 time::Instant,
13952 };
13953
13954 use super::{settle_stderr_pump, settle_stdout_pump, StderrPump};
13955 use crate::stderr_tail::{
13956 pump_stderr_to, pump_stdout_to, split_capture_stamp, untimed, CaptureState,
13957 ChildOutputSink, OutputSink, StderrRing, StderrTailConfig, TailEntry,
13958 };
13959
13960 const BOUND: Duration = Duration::from_millis(250);
13961
13962 struct HeldReader {
13966 before: Option<Vec<u8>>,
13967 gate: Option<oneshot::Receiver<()>>,
13968 after: io::Cursor<Vec<u8>>,
13969 }
13970
13971 impl AsyncRead for HeldReader {
13972 fn poll_read(
13973 mut self: Pin<&mut Self>,
13974 cx: &mut Context<'_>,
13975 buf: &mut ReadBuf<'_>,
13976 ) -> Poll<io::Result<()>> {
13977 if let Some(bytes) = self.before.take() {
13978 buf.put_slice(&bytes);
13979 return Poll::Ready(Ok(()));
13980 }
13981 if let Some(gate) = self.gate.as_mut() {
13982 match Pin::new(gate).poll(cx) {
13983 Poll::Pending => return Poll::Pending,
13984 Poll::Ready(_) => self.gate = None,
13985 }
13986 }
13987 Pin::new(&mut self.after).poll_read(cx, buf)
13988 }
13989 }
13990
13991 struct DiscardSink;
13992
13993 impl OutputSink for DiscardSink {
13994 fn write_line(&mut self, _line: &[u8]) {}
13995 }
13996
13997 fn line(text: &str) -> TailEntry {
13998 TailEntry::Line {
13999 text: text.to_string(),
14000 truncated: false,
14001 at_ms: None,
14002 }
14003 }
14004
14005 fn lock(ring: &Arc<Mutex<StderrRing>>) -> std::sync::MutexGuard<'_, StderrRing> {
14006 ring.lock().unwrap()
14007 }
14008
14009 fn held_pump(
14013 ring: &Arc<Mutex<StderrRing>>,
14014 before: &str,
14015 after: &str,
14016 ) -> (StderrPump, oneshot::Sender<()>) {
14017 let generation = lock(ring).begin_process();
14018 let (release, gate) = oneshot::channel();
14019 let reader = HeldReader {
14020 before: Some(before.as_bytes().to_vec()),
14021 gate: Some(gate),
14022 after: io::Cursor::new(after.as_bytes().to_vec()),
14023 };
14024 let task = tokio::spawn(pump_stderr_to(
14025 reader,
14026 Arc::clone(ring),
14027 generation,
14028 DiscardSink,
14029 ));
14030 (StderrPump { task, generation }, release)
14031 }
14032
14033 async fn wait_until(ring: &Arc<Mutex<StderrRing>>, done: impl Fn(&StderrRing) -> bool) {
14034 for _ in 0..1000 {
14035 if done(&lock(ring)) {
14036 return;
14037 }
14038 tokio::time::sleep(Duration::from_millis(1)).await;
14039 }
14040 panic!(
14041 "ring never reached the expected state: {:?}",
14042 lock(ring).snapshot(None, None)
14043 );
14044 }
14045
14046 #[tokio::test(start_paused = true)]
14047 async fn a_crash_line_the_reader_had_not_reached_by_the_bound_is_kept_before_the_restart() {
14048 let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
14049 let (pump, release) = held_pump(&ring, "booting\n", "config error: missing storage\n");
14050
14051 settle_stderr_pump("crasher", &ring, pump, BOUND).await;
14052 let before_release = lock(&ring).snapshot(None, None);
14053 assert!(
14054 matches!(before_release.capture, CaptureState::Incomplete { .. }),
14055 "a reader that has not reached EOF cannot claim a whole tail: {before_release:?}"
14056 );
14057
14058 let next = lock(&ring).begin_process();
14061 lock(&ring).push_line_from(next, "next process booting");
14062 release.send(()).unwrap();
14063 wait_until(&ring, |ring| {
14064 ring.snapshot(None, None).capture == CaptureState::Captured
14065 })
14066 .await;
14067
14068 assert_eq!(
14069 untimed(lock(&ring).snapshot(None, None).entries),
14070 vec![
14071 line("booting"),
14072 line("config error: missing storage"),
14073 TailEntry::ProcessStart,
14074 line("next process booting"),
14075 ],
14076 "the crash's last line must survive a slow reader and stay in the crashed process's section"
14077 );
14078 }
14079
14080 #[tokio::test(start_paused = true)]
14081 async fn a_pipe_held_open_by_a_descendant_reads_incomplete_without_delaying_the_restart_past_the_bound(
14082 ) {
14083 let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
14084 let (pump, _held) = held_pump(&ring, "parent exiting\n", "");
14087
14088 let started = Instant::now();
14089 settle_stderr_pump("orphaning", &ring, pump, BOUND).await;
14090 assert_eq!(
14091 started.elapsed(),
14092 BOUND,
14093 "the restart must wait exactly the bound for a pipe that stays open, no longer"
14094 );
14095
14096 let next = lock(&ring).begin_process();
14097 lock(&ring).push_line_from(next, "next process booting");
14098 tokio::time::sleep(Duration::from_secs(60)).await;
14099
14100 let snapshot = lock(&ring).snapshot(None, None);
14101 match &snapshot.capture {
14102 CaptureState::Incomplete { reason } => assert!(
14103 reason.contains("had not reached EOF") && reason.contains("250ms"),
14104 "the reason must say what is missing and after how long: {reason}"
14105 ),
14106 other => panic!("expected Incomplete while the pipe is held open, got {other:?}"),
14107 }
14108 assert_eq!(
14109 untimed(snapshot.entries),
14110 vec![
14111 line("parent exiting"),
14112 TailEntry::ProcessStart,
14113 line("next process booting"),
14114 ]
14115 );
14116 }
14117
14118 #[tokio::test(start_paused = true)]
14119 async fn a_reader_that_reaches_eof_within_the_bound_leaves_the_tail_captured() {
14120 let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
14121 let (pump, release) = held_pump(&ring, "one\n", "two\n");
14122 release.send(()).unwrap();
14123
14124 settle_stderr_pump("clean", &ring, pump, BOUND).await;
14125
14126 let snapshot = lock(&ring).snapshot(None, None);
14127 assert_eq!(snapshot.capture, CaptureState::Captured);
14128 assert_eq!(untimed(snapshot.entries), vec![line("one"), line("two")]);
14129 }
14130
14131 #[tokio::test(start_paused = true)]
14132 async fn stdout_buffered_past_the_exit_drain_bound_reaches_the_capture_file() {
14133 const BURST: usize = 150;
14134 let capture = cortexkit_test_support::ScratchDir::new("stdout-exit-drain");
14135 let path = capture.join("two-pipe.stderr.log");
14136 let sink = ChildOutputSink::open(&path, cortexkit_log::Retention::default()).unwrap();
14137 let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
14138 let generation = lock(&ring).begin_process();
14139 let stdout = (0..BURST)
14140 .map(|i| format!("out-{i:04}-{}\n", "o".repeat(64)))
14141 .collect::<String>();
14142 let stderr = (0..BURST)
14143 .map(|i| format!("err-{i:04}-{}\n", "e".repeat(64)))
14144 .collect::<String>();
14145 let first_chunk = stdout.find('\n').unwrap() + 1 + "out-0001-".len();
14150 let (release, gate) = oneshot::channel();
14151 let reader = HeldReader {
14152 before: Some(stdout.as_bytes()[..first_chunk].to_vec()),
14153 gate: Some(gate),
14154 after: io::Cursor::new(stdout.as_bytes()[first_chunk..stdout.len() - 1].to_vec()),
14155 };
14156 let (finished, completion) = oneshot::channel();
14157 let stdout_sink = sink.clone();
14158 let pump = tokio::spawn(async move {
14159 pump_stdout_to(reader, stdout_sink).await;
14160 finished.send(()).unwrap();
14161 });
14162 pump_stderr_to(io::Cursor::new(stderr.into_bytes()), ring, generation, sink).await;
14163
14164 let started = Instant::now();
14165 settle_stdout_pump("two-pipe", pump, BOUND).await;
14166 assert_eq!(
14167 started.elapsed(),
14168 BOUND,
14169 "a late reader must not hold up restart"
14170 );
14171 assert_eq!(
14172 std::fs::read_to_string(&path).unwrap().lines().count(),
14173 BURST + 1
14174 );
14175
14176 release
14177 .send(())
14178 .expect("stdout reader must survive the exit drain bound");
14179 tokio::time::timeout(Duration::from_secs(1), completion)
14180 .await
14181 .expect("released stdout reader must reach EOF")
14182 .expect("stdout capture must finish at EOF, not be cancelled");
14183 let contents = std::fs::read_to_string(&path).unwrap();
14184 let mut lines = contents
14185 .lines()
14186 .map(|line| split_capture_stamp(line).expect("capture stamp").1)
14187 .collect::<Vec<_>>();
14188 let mut expected = (0..BURST)
14189 .flat_map(|i| {
14190 [
14191 format!("out-{i:04}-{}", "o".repeat(64)),
14192 format!("err-{i:04}-{}", "e".repeat(64)),
14193 ]
14194 })
14195 .collect::<Vec<_>>();
14196 lines.sort_unstable();
14197 expected.sort_unstable();
14198 assert_eq!(
14199 lines, expected,
14200 "both pipes must retain every complete line exactly once"
14201 );
14202 }
14203}
14204
14205#[cfg(all(test, windows))]
14219mod job_containment_tests {
14220 use super::*;
14221 use cortexkit_test_support::ScratchDir;
14222 use std::{
14223 path::{Path, PathBuf},
14224 sync::{Arc, Mutex},
14225 time::{Duration, Instant},
14226 };
14227
14228 fn stub_path() -> PathBuf {
14234 let mut path = std::env::current_exe().expect("current_exe available in tests");
14235 path.pop();
14236 path.pop();
14237 path.push("fake-aft-stub.exe");
14238 assert!(
14239 path.exists(),
14240 "fake-aft-stub not built at {}: run `cargo test -p subc-core` (which builds \
14241 [[bin]] targets) rather than `cargo test -p subc-core --lib` (which does not)",
14242 path.display()
14243 );
14244 path
14245 }
14246
14247 fn read_grandchild_pid(path: &Path) -> u32 {
14249 let deadline = Instant::now() + Duration::from_secs(10);
14250 loop {
14251 if let Ok(contents) = std::fs::read_to_string(path) {
14252 if let Ok(pid) = contents.trim().parse() {
14253 return pid;
14254 }
14255 }
14256 assert!(
14257 Instant::now() < deadline,
14258 "the stub never recorded a grandchild pid at {}",
14259 path.display()
14260 );
14261 std::thread::sleep(Duration::from_millis(10));
14262 }
14263 }
14264
14265 struct Fixture {
14268 _dir: ScratchDir,
14269 module_id: String,
14270 grandchild: u32,
14271 child: Option<SupervisedChild>,
14272 registry: Arc<Registry>,
14273 snapshot: Arc<Mutex<SupervisorSnapshot>>,
14274 terminal_ring: Arc<Mutex<TerminalRing>>,
14275 spawn_events: SpawnEventFeed,
14276 }
14277
14278 fn fixture(label: &str, module_id: &str) -> Fixture {
14279 let dir = ScratchDir::new(label);
14280 let pid_file = dir.join("grandchild.pid");
14281 let supervisor = Supervisor::new_for_test(
14282 Arc::new(Registry::default()),
14283 RestartPolicy::new(3, Duration::ZERO),
14284 );
14285 let runtime = supervisor.runtime_config();
14286 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
14287 let spec = ModuleSpec {
14288 module_id: module_id.to_string(),
14289 program: stub_path(),
14290 args: Vec::new(),
14294 env: vec![
14295 ("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string()),
14296 (
14297 "FAKE_AFT_GRANDCHILD_PID_FILE".to_string(),
14298 pid_file.display().to_string(),
14299 ),
14300 ],
14301 reserved: false,
14302 reserved_prefixes: Vec::new(),
14303 protocol: ModuleProtocol::Subc,
14304 overlap: Default::default(),
14305 };
14306 let child = spawn_and_mark_running(&spec, &runtime, &snapshot)
14307 .expect("spawn the supervised fixture");
14308 let grandchild = read_grandchild_pid(&pid_file);
14309 Fixture {
14310 _dir: dir,
14311 module_id: module_id.to_string(),
14312 grandchild,
14313 child: Some(child),
14314 registry: Arc::new(Registry::default()),
14315 snapshot,
14316 terminal_ring: Arc::clone(&runtime.terminal_ring),
14317 spawn_events: SpawnEventFeed::default(),
14318 }
14319 }
14320
14321 impl Fixture {
14322 async fn drain(&mut self) {
14324 let child = self
14325 .child
14326 .take()
14327 .expect("the fixture child is still present");
14328 drain_child_to_state(
14329 &self.module_id,
14330 ModuleProtocol::Subc,
14331 StopNotice::NotSent,
14334 &self.registry,
14335 None,
14336 &self.snapshot,
14337 &self.terminal_ring,
14338 &self.spawn_events,
14339 child,
14340 Duration::from_millis(500),
14341 ModuleState::Stopped,
14342 Some(false),
14343 )
14344 .await
14345 .expect("drain the supervised fixture");
14346 }
14347 }
14348
14349 #[tokio::test]
14355 async fn teardown_reaps_the_grandchild() {
14356 let mut fixture = fixture("teardown-grandchild", "tree-teardown");
14357 let grandchild = fixture.grandchild;
14358
14359 assert!(
14360 subc_jobobject::process_exists(grandchild),
14361 "grandchild {grandchild} must be alive before teardown, or this proves nothing"
14362 );
14363
14364 fixture.drain().await;
14365
14366 assert!(
14367 subc_jobobject::wait_for_process_exit(grandchild, Duration::from_secs(10)),
14368 "grandchild {grandchild} outlived module teardown: the tree was not contained"
14369 );
14370 }
14371
14372 #[test]
14385 fn an_uncontained_grandchild_survives_a_direct_child_kill() {
14386 let dir = ScratchDir::new("teardown-uncontained");
14387 let pid_file = dir.join("grandchild.pid");
14388 let mut child = std::process::Command::new(stub_path())
14389 .env("FAKE_AFT_NEVER_CONNECT", "1")
14390 .env(
14391 "FAKE_AFT_GRANDCHILD_PID_FILE",
14392 pid_file.display().to_string(),
14393 )
14394 .stdin(std::process::Stdio::null())
14395 .stdout(std::process::Stdio::null())
14396 .stderr(std::process::Stdio::null())
14397 .spawn()
14398 .expect("spawn the uncontained fixture");
14399 let grandchild = read_grandchild_pid(&pid_file);
14400
14401 child.kill().expect("kill the direct child");
14403 let _ = child.wait();
14404
14405 assert!(
14406 subc_jobobject::process_exists(grandchild),
14407 "grandchild {grandchild} died with the direct child, so this control no longer \
14408 distinguishes contained from uncontained teardown and the regression test is \
14409 passing vacuously"
14410 );
14411
14412 kill_tree(grandchild);
14415 }
14416
14417 #[tokio::test]
14426 async fn dropping_containment_reaps_the_grandchild() {
14427 let mut fixture = fixture("drop-containment", "tree-drop");
14428 let grandchild = fixture.grandchild;
14429
14430 assert!(subc_jobobject::process_exists(grandchild));
14431
14432 fixture.child.as_mut().expect("child present").job = None;
14434
14435 assert!(
14436 subc_jobobject::wait_for_process_exit(grandchild, Duration::from_secs(10)),
14437 "grandchild {grandchild} survived the containment handle closing, so a daemon \
14438 crash would leave the tree behind"
14439 );
14440 }
14441
14442 fn kill_tree(pid: u32) {
14444 let _ = std::process::Command::new("taskkill.exe")
14445 .args(["/PID", &pid.to_string(), "/T", "/F"])
14446 .stdin(std::process::Stdio::null())
14447 .stdout(std::process::Stdio::null())
14448 .stderr(std::process::Stdio::null())
14449 .status();
14450 assert!(
14451 subc_jobobject::wait_for_process_exit(pid, Duration::from_secs(10)),
14452 "could not clean up grandchild {pid}"
14453 );
14454 }
14455}
14456
14457#[cfg(test)]
14458mod privacy_trampoline_configuration_tests {
14459 #[tokio::test]
14460 #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
14461 async fn macos_spawn_without_a_configured_trampoline_refuses_by_name() {
14462 #[cfg(target_os = "macos")]
14463 {
14464 let supervisor = super::Supervisor::new(
14465 std::sync::Arc::new(crate::Registry::default()),
14466 super::RestartPolicy::default(),
14467 );
14468 let error = supervisor.spawn(spec()).unwrap_err();
14469 assert!(
14470 error
14471 .to_string()
14472 .contains("no privacy trampoline configured"),
14473 "{error}"
14474 );
14475 }
14476 }
14477
14478 #[tokio::test]
14479 #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
14480 async fn macos_wrong_trampoline_is_rejected_by_the_startup_probe() {
14481 #[cfg(target_os = "macos")]
14482 {
14483 let supervisor = super::Supervisor::new(
14484 std::sync::Arc::new(crate::Registry::default()),
14485 super::RestartPolicy::default(),
14486 )
14487 .with_privacy_trampoline(std::env::current_exe().unwrap());
14488 let error = supervisor.spawn(spec()).unwrap_err();
14489 assert!(
14490 error
14491 .to_string()
14492 .contains("binary does not implement the privacy trampoline protocol"),
14493 "{error}"
14494 );
14495 }
14496 }
14497
14498 #[cfg(target_os = "macos")]
14499 fn spec() -> super::ModuleSpec {
14500 super::ModuleSpec {
14501 module_id: "privacy-configuration".into(),
14502 program: "/bin/sleep".into(),
14503 args: vec!["30".into()],
14504 env: vec![],
14505 reserved: false,
14506 reserved_prefixes: vec![],
14507 protocol: subc_control::ModuleProtocol::None,
14508 overlap: super::ModuleOverlap::Exclusive,
14509 }
14510 }
14511}
14512
14513#[cfg(test)]
14514mod privacy_exec_boundary_tests {
14515 #[cfg(target_os = "macos")]
14516 use super::*;
14517 #[cfg(target_os = "macos")]
14518 use std::{
14519 io::{Read, Write},
14520 net::{TcpListener, TcpStream},
14521 };
14522
14523 #[cfg(target_os = "macos")]
14526 pub(super) fn before_image_sample(spec: &ModuleSpec, pid: u32) {
14527 if let Some((_, path)) = spec
14528 .env
14529 .iter()
14530 .find(|(key, _)| key == "SUBC_TEST_PRIVACY_SAMPLE_BARRIER")
14531 {
14532 let mut barrier = TcpStream::connect(path).unwrap();
14533 barrier
14534 .set_read_timeout(Some(Duration::from_secs(30)))
14535 .unwrap();
14536 barrier.write_all(&pid.to_ne_bytes()).unwrap();
14537 let mut release = [0];
14538 barrier.read_exact(&mut release).unwrap();
14539 assert_eq!(&release, b"X");
14540 }
14541 }
14542
14543 #[cfg(target_os = "macos")]
14544 fn spec(program: &str, args: &[&str]) -> ModuleSpec {
14545 ModuleSpec {
14546 module_id: "privacy-boundary".into(),
14547 program: program.into(),
14548 args: args.iter().map(|arg| (*arg).into()).collect(),
14549 env: vec![],
14550 reserved: false,
14551 reserved_prefixes: vec![],
14552 protocol: ModuleProtocol::None,
14553 overlap: ModuleOverlap::Exclusive,
14554 }
14555 }
14556
14557 #[cfg(target_os = "macos")]
14558 async fn accept(listener: TcpListener) -> TcpStream {
14559 let listener = tokio::net::TcpListener::from_std({
14561 listener.set_nonblocking(true).unwrap();
14562 listener
14563 })
14564 .unwrap();
14565 let (stream, _) = tokio::time::timeout(Duration::from_secs(30), listener.accept())
14566 .await
14567 .unwrap()
14568 .unwrap();
14569 let stream = stream.into_std().unwrap();
14570 stream.set_nonblocking(false).unwrap();
14571 stream
14572 .set_read_timeout(Some(Duration::from_secs(30)))
14573 .unwrap();
14574 stream
14575 }
14576
14577 #[tokio::test]
14578 #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
14579 async fn macos_roster_withholds_a_nonnull_trampoline_image_until_exec_confirmation() {
14580 #[cfg(target_os = "macos")]
14581 {
14582 let root = cortexkit_test_support::ScratchDir::new("privacy-roster-barrier");
14583 let exec_listener = TcpListener::bind("127.0.0.1:0").unwrap();
14586 let sample_listener = TcpListener::bind("127.0.0.1:0").unwrap();
14587 let record = root.join("live-children.json");
14588 let supervisor =
14589 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
14590 .with_live_children_record(&record);
14591 let runtime = supervisor.runtime_config();
14592 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
14593 let mut spec = spec("/bin/sleep", &["30"]);
14594 spec.env = vec![
14595 (
14596 "SUBC_TEST_PRIVACY_EXEC_BARRIER".into(),
14597 exec_listener.local_addr().unwrap().to_string(),
14598 ),
14599 (
14600 "SUBC_TEST_PRIVACY_SAMPLE_BARRIER".into(),
14601 sample_listener.local_addr().unwrap().to_string(),
14602 ),
14603 ];
14604 let spawn = tokio::task::spawn_blocking(move || {
14605 spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap()
14606 });
14607 let mut sample = accept(sample_listener).await;
14608 let mut pid = [0; 4];
14609 sample.read_exact(&mut pid).unwrap();
14610 let pid = u32::from_ne_bytes(pid);
14611 let mut exec = accept(exec_listener).await;
14612 let mut ready = [0];
14613 exec.read_exact(&mut ready).unwrap();
14614 assert_eq!(&ready, b"R");
14615 let trampoline = subc_os::file_identity(&test_privacy_trampoline()).unwrap();
14618 assert_eq!(
14619 observe_spawned_image(pid).unwrap().executable,
14620 Some(trampoline)
14621 );
14622 sample.write_all(b"X").unwrap();
14623 let mut child = spawn.await.unwrap();
14624 let early = crate::live_children::read_record(&record).unwrap();
14625 assert_eq!(early.len(), 1);
14626 assert_eq!(early[0].pid, pid);
14627 assert_eq!(
14628 early[0].executable, None,
14629 "unconfirmed trampoline image entered the roster"
14630 );
14631 assert!(child.report_ready.get().is_none());
14632 child.privacy_exec.as_mut().unwrap().deadline =
14635 tokio::time::Instant::now() + Duration::from_secs(30);
14636 exec.write_all(b"X").unwrap();
14637 child.confirm_privacy_exec().await;
14638 assert_eq!(child.spawn_failure, None);
14639 assert!(child.report_ready.get().is_some());
14640 let confirmed = crate::live_children::read_record(&record).unwrap();
14641 let module = subc_os::file_identity(std::path::Path::new("/bin/sleep")).unwrap();
14642 assert_ne!(module, trampoline);
14643 assert_eq!(confirmed[0].executable, Some(module.into()));
14644 child.start_kill().unwrap();
14645 child.wait().await.unwrap();
14646 child.release_roster();
14647 }
14648 }
14649
14650 #[tokio::test]
14651 #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
14652 async fn macos_already_exited_121_in_try_wait_is_a_module_exit_not_a_trampoline_refusal() {
14653 #[cfg(target_os = "macos")]
14654 {
14655 let registry = Arc::new(Registry::default());
14656 let policy = RestartPolicy::new(0, Duration::ZERO);
14657 let supervisor = Supervisor::new_for_test(Arc::clone(®istry), policy);
14658 let runtime = supervisor.runtime_config();
14659 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
14660 let spec = spec("/bin/sh", &["-c", "exit 121"]);
14661 let mut child = spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap();
14665 let pid = child.pid;
14666 tokio::task::spawn_blocking(move || {
14667 subc_os::fork_exec_test::wait_for_child_exit_without_reaping(pid)
14668 })
14669 .await
14670 .unwrap()
14671 .unwrap();
14672 child.privacy_exec.as_mut().unwrap().deadline =
14673 tokio::time::Instant::now() + Duration::from_secs(30);
14674 let status = child.wait().await.unwrap();
14675 assert_eq!(status.code(), Some(121));
14676 assert!(child.privacy_exec.is_none());
14677 assert!(
14678 child.report_ready.get().is_none(),
14679 "an exited module must not publish a live pid"
14680 );
14681 let report = classify_reaped_child_exit(&snapshot, &child, &status);
14682 on_child_exit(
14683 &spec,
14684 policy,
14685 ®istry,
14686 &snapshot,
14687 &runtime.terminal_ring,
14688 &runtime.spawn_events,
14689 &runtime.child_roster,
14690 report,
14691 )
14692 .await;
14693 let state = lock_snapshot(&snapshot).unwrap();
14694 assert_eq!(state.state, ModuleState::Failed);
14695 assert_eq!(state.last_exit.as_ref().unwrap().code, Some(121));
14696 assert_eq!(state.reported_pid(), None);
14697 drop(state);
14698 let history = runtime.terminal_ring.lock().unwrap().snapshot();
14699 assert_eq!(history.entries.len(), 1);
14700 let terminal = &history.entries[0];
14701 assert_eq!(terminal.exit_code, Some(121));
14702 assert_eq!(terminal.exit_kind, subc_control::TerminalExitKind::Crash);
14703 assert_eq!(terminal.disposition, TerminalDisposition::Failed);
14704 assert_eq!(
14705 terminal.disposition_detail.as_deref(),
14706 Some(policy.budget_exhausted_detail().as_str()),
14707 "module exit 121 was classified as a trampoline refusal: {terminal:?}"
14708 );
14709 assert_eq!(child.spawn_failure, None);
14710 child.release_roster();
14711 }
14712 }
14713}
14714
14715#[cfg(all(test, unix))]
14719mod launch_nonce_descriptor_tests {
14720 use super::{ChildRoster, ModuleProtocol, ModuleSpec, SupervisorHandle};
14721 use crate::stderr_tail::{StderrRing, StderrTailConfig};
14722 use cortexkit_test_support::ScratchDir;
14723 use std::{
14724 path::PathBuf,
14725 sync::{Arc, Mutex},
14726 time::{Duration, Instant},
14727 };
14728
14729 async fn probe(role: super::SpawnRole) {
14730 let scratch = ScratchDir::new("launch-nonce-descriptor");
14731 let fd_copy = scratch.join("from-descriptor");
14732 let env_copy = scratch.join("environment");
14733 let script = format!(
14734 "env > '{env}.tmp' && mv '{env}.tmp' '{env}'; cat <&3 > '{fd}.tmp' && mv '{fd}.tmp' '{fd}'; sleep 30",
14735 fd = fd_copy.display(), env = env_copy.display(),
14736 );
14737 let xdg = |name: &str| (name.to_string(), scratch.join(name).display().to_string());
14738 let spec = ModuleSpec {
14739 module_id: "nonce-descriptor-probe".to_string(),
14740 program: PathBuf::from("/bin/sh"),
14741 args: vec!["-c".to_string(), script],
14742 env: vec![
14743 xdg("XDG_DATA_HOME"),
14744 xdg("XDG_RUNTIME_DIR"),
14745 xdg("XDG_CONFIG_HOME"),
14746 ("SUBC_LAUNCH_NONCE".to_string(), "must-not-leak".to_string()),
14747 ],
14748 reserved: true,
14749 reserved_prefixes: Vec::new(),
14750 protocol: ModuleProtocol::Subc,
14751 overlap: Default::default(),
14752 };
14753 let handle = SupervisorHandle::new();
14754 let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
14755 let roster = ChildRoster::default();
14756 #[cfg(target_os = "macos")]
14757 {
14758 let path = super::test_privacy_trampoline();
14759 roster.set_privacy_trampoline(super::probe_privacy_trampoline(&path).map(|()| path));
14760 }
14761 let child = super::spawn_child_in_slot(
14762 &spec,
14763 None,
14764 Some(&handle),
14765 &ring,
14766 None,
14767 &roster,
14768 #[cfg(target_os = "linux")]
14769 None,
14770 role,
14771 matches!(role, super::SpawnRole::SwapCandidate),
14772 )
14773 .expect("spawn probe");
14774 let deadline = Instant::now() + Duration::from_secs(10);
14775 while !(fd_copy.exists() && env_copy.exists()) {
14776 assert!(Instant::now() < deadline, "probe never wrote its copies");
14777 tokio::time::sleep(Duration::from_millis(20)).await;
14778 }
14779 let nonce = std::fs::read_to_string(fd_copy).unwrap();
14780 assert!(!nonce.is_empty());
14781 let environment = std::fs::read_to_string(env_copy).unwrap();
14782 assert!(environment
14783 .lines()
14784 .any(|line| line.starts_with("SUBC_LAUNCH_NONCE_FD=3:")));
14785 let copy = environment
14786 .lines()
14787 .find_map(|line| line.strip_prefix("SUBC_LAUNCH_NONCE="));
14788 assert_eq!(
14789 copy, None,
14790 "Unix children must never receive the environment nonce"
14791 );
14792 if matches!(role, super::SpawnRole::Plain) {
14793 assert_eq!(
14794 handle.spawn_nonce(&spec.module_id).as_deref(),
14795 Some(nonce.as_str())
14796 );
14797 }
14798 drop(child);
14799 }
14800
14801 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
14802 async fn a_spawned_module_receives_its_nonce_only_on_descriptor_3() {
14803 probe(super::SpawnRole::Plain).await;
14804 }
14805
14806 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
14807 async fn swap_candidate_receives_its_nonce_only_on_descriptor_3() {
14808 probe(super::SpawnRole::SwapCandidate).await;
14809 }
14810}
14811
14812#[cfg(all(test, target_os = "linux"))]
14813mod cgroup_containment_tests {
14814 use super::*;
14815 use cortexkit_test_support::ScratchDir;
14816
14817 fn running(pid: u32) -> bool {
14818 std::fs::read_to_string(format!("/proc/{pid}/stat"))
14820 .ok()
14821 .and_then(|stat| {
14822 stat.rsplit_once(") ")
14823 .map(|(_, rest)| rest.starts_with('Z'))
14824 })
14825 .is_some_and(|zombie| !zombie)
14826 }
14827
14828 #[tokio::test]
14829 async fn linux_teardown_reaps_the_grandchild() {
14830 teardown_tree("linux_teardown_reaps_the_grandchild", false).await;
14831 }
14832
14833 #[tokio::test]
14834 async fn linux_shutdown_straggler_reaps_the_grandchild() {
14835 teardown_tree("linux_shutdown_straggler_reaps_the_grandchild", true).await;
14836 }
14837
14838 async fn teardown_tree(test_name: &str, shutdown: bool) {
14839 let dir = ScratchDir::new(test_name);
14840 let root = PathBuf::from(format!(
14841 "/sys/fs/cgroup/subc-tree-test-{test_name}-{}-{}",
14842 std::process::id(),
14843 unix_ms_now()
14844 ));
14845 if let Err(error) = std::fs::create_dir(&root) {
14846 assert!(
14847 std::env::var_os("SUBC_REQUIRE_CGROUP_TEST").is_none(),
14848 "required cgroup test cannot execute: {error}"
14849 );
14850 eprintln!(
14851 "SKIP {test_name}: no writable delegated cgroup at {}: {error}",
14852 root.display()
14853 );
14854 return;
14855 }
14856 let placement = subc_cgroup::prepare_at(&root)
14857 .expect("prepare isolated kernel cgroup")
14858 .expect("isolated cgroup is delegated");
14859 let module_id = "tree-teardown";
14860 let module = placement
14861 .module_path(module_id)
14862 .expect("create isolated module cgroup");
14863 if !module.join("cgroup.kill").exists() {
14864 std::fs::remove_dir(&module).unwrap();
14865 std::fs::remove_dir(root.join("subc-modules")).unwrap();
14866 std::fs::remove_dir(&root).unwrap();
14867 assert!(
14868 std::env::var_os("SUBC_REQUIRE_CGROUP_TEST").is_none(),
14869 "required cgroup.kill interface unavailable"
14870 );
14871 eprintln!("SKIP {test_name}: cgroup.kill unavailable (kernel < 5.14)");
14872 return;
14873 }
14874 let supervisor = Supervisor::new_for_test(
14875 Arc::new(Registry::default()),
14876 RestartPolicy::new(3, Duration::ZERO),
14877 )
14878 .with_cgroup_placement(Some(placement));
14879 let mut runtime = supervisor.runtime_config();
14880 runtime.child_roster = runtime
14881 .child_roster
14882 .for_module(Arc::new(Mutex::new(Duration::from_millis(100))));
14883 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
14884 let pid_file = dir.join("grandchild.pid");
14885 let spec = ModuleSpec {
14886 module_id: module_id.to_string(),
14887 program: PathBuf::from("/bin/sh"),
14888 args: vec![
14889 "-c".into(),
14890 "trap '' TERM; sleep 600 & echo $! > \"$1\"; wait".into(),
14891 "fixture".into(),
14892 pid_file.display().to_string(),
14893 ],
14894 env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
14895 .into_iter()
14896 .map(|key| (key.to_string(), dir.display().to_string()))
14897 .collect(),
14898 reserved: false,
14899 reserved_prefixes: Vec::new(),
14900 protocol: ModuleProtocol::None,
14901 overlap: Default::default(),
14902 };
14903 let child =
14904 spawn_and_mark_running(&spec, &runtime, &snapshot).expect("spawn supervised tree");
14905 let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
14906 let grandchild: u32 = loop {
14907 if let Ok(contents) = std::fs::read_to_string(&pid_file) {
14908 if let Ok(pid) = contents.trim().parse() {
14909 break pid;
14910 }
14911 }
14912 assert!(
14913 tokio::time::Instant::now() < deadline,
14914 "grandchild pid was not recorded"
14915 );
14916 tokio::time::sleep(Duration::from_millis(10)).await;
14917 };
14918 assert!(
14919 running(grandchild),
14920 "grandchild must be alive before teardown"
14921 );
14922 if shutdown {
14923 let mut child = child;
14924 crate::child_roster::end_children_for_daemon_shutdown(
14925 &runtime.child_roster,
14926 false,
14927 std::future::pending(),
14928 )
14929 .await;
14930 child.wait().await.expect("reap shutdown straggler");
14931 } else {
14932 drain_child_to_state(
14933 module_id,
14934 ModuleProtocol::None,
14935 StopNotice::NotSent,
14936 &Registry::default(),
14937 None,
14938 &snapshot,
14939 &runtime.terminal_ring,
14940 &SpawnEventFeed::default(),
14941 child,
14942 Duration::from_millis(100),
14943 ModuleState::Stopped,
14944 Some(false),
14945 )
14946 .await
14947 .expect("real supervisor teardown");
14948 }
14949 let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
14950 while running(grandchild) && tokio::time::Instant::now() < deadline {
14951 tokio::time::sleep(Duration::from_millis(10)).await;
14952 }
14953 let survived = running(grandchild);
14954 if survived {
14956 let pid = rustix::process::Pid::from_raw(grandchild as i32).unwrap();
14957 let _ = rustix::process::kill_process(pid, rustix::process::Signal::KILL);
14958 tokio::time::sleep(Duration::from_millis(100)).await;
14959 }
14960 if module.exists() {
14961 std::fs::remove_dir(&module).expect("remove empty module cgroup");
14962 }
14963 std::fs::remove_dir(root.join("subc-modules")).unwrap();
14964 std::fs::remove_dir(&root).unwrap();
14965 assert!(
14966 !survived,
14967 "grandchild {grandchild} outlived module teardown"
14968 );
14969 eprintln!("EXECUTED {test_name}: grandchild {grandchild} killed in isolated cgroup");
14970 }
14971}