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 _ if snapshot.enabled && snapshot.respawn_pending => true,
3578 _ => daemon_will_restart(&mut snapshot, &self.inner.restart_policy, Instant::now()),
3579 })
3580 }
3581
3582 #[cfg(test)]
3583 pub(crate) fn is_warming(&self) -> Result<bool, SuperviseError> {
3584 self.is_warming_with_snapshot_lock(None)
3585 }
3586
3587 pub(crate) fn is_warming_for_control(
3588 &self,
3589 caller: &'static str,
3590 ) -> Result<bool, SuperviseError> {
3591 self.is_warming_with_snapshot_lock(Some(caller))
3592 }
3593
3594 fn is_warming_with_snapshot_lock(
3595 &self,
3596 caller: Option<&'static str>,
3597 ) -> Result<bool, SuperviseError> {
3598 let snapshot = match caller {
3599 Some(caller) => {
3600 lock_snapshot_for_control(&self.inner.snapshot, &self.inner.module_id, caller)?
3601 }
3602 None => lock_snapshot(&self.inner.snapshot)?,
3603 }
3604 .clone();
3605 Ok(matches!(
3606 snapshot.state,
3607 ModuleState::Starting | ModuleState::Running | ModuleState::Restarting
3608 ))
3609 }
3610
3611 pub async fn drain(&self) -> Result<(), SuperviseError> {
3613 self.stop().await
3614 }
3615
3616 pub(crate) async fn retire_requested(
3617 &self,
3618 requester: Option<Principal>,
3619 ) -> Result<(), SuperviseError> {
3620 match self.state()? {
3621 ModuleState::Stopped | ModuleState::Failed => return Ok(()),
3622 ModuleState::Starting
3623 | ModuleState::Running
3624 | ModuleState::Unresponsive
3625 | ModuleState::Restarting
3626 | ModuleState::Draining
3627 | ModuleState::Disabled => {}
3628 }
3629
3630 let (reply_tx, reply_rx) = oneshot::channel();
3631 self.inner
3632 .commands
3633 .send(SupervisorCommand::Retire {
3634 requester,
3635 reply: reply_tx,
3636 })
3637 .await
3638 .map_err(|_| SuperviseError::CommandClosed {
3639 module_id: self.inner.module_id.clone(),
3640 })?;
3641 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3642 module_id: self.inner.module_id.clone(),
3643 })?
3644 }
3645
3646 pub async fn stop(&self) -> Result<(), SuperviseError> {
3647 self.stop_requested(None).await
3648 }
3649
3650 pub(crate) async fn stop_requested(
3651 &self,
3652 requester: Option<Principal>,
3653 ) -> Result<(), SuperviseError> {
3654 match self.state()? {
3655 ModuleState::Stopped | ModuleState::Failed => return Ok(()),
3656 ModuleState::Starting
3657 | ModuleState::Running
3658 | ModuleState::Unresponsive
3659 | ModuleState::Restarting
3660 | ModuleState::Draining
3661 | ModuleState::Disabled => {}
3662 }
3663
3664 let (reply_tx, reply_rx) = oneshot::channel();
3665 self.inner
3666 .commands
3667 .send(SupervisorCommand::Drain {
3668 requester,
3669 reply: reply_tx,
3670 })
3671 .await
3672 .map_err(|_| SuperviseError::CommandClosed {
3673 module_id: self.inner.module_id.clone(),
3674 })?;
3675 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3676 module_id: self.inner.module_id.clone(),
3677 })?
3678 }
3679
3680 pub async fn restart(&self, drain_timeout_ms: Option<u64>) -> Result<(), SuperviseError> {
3681 self.restart_requested(drain_timeout_ms, None).await
3682 }
3683
3684 pub(crate) async fn restart_requested(
3685 &self,
3686 drain_timeout_ms: Option<u64>,
3687 requester: Option<Principal>,
3688 ) -> Result<(), SuperviseError> {
3689 let received_at_generation = lock_snapshot(&self.inner.snapshot)?.spawn_generation;
3690 let (reply_tx, reply_rx) = oneshot::channel();
3691 self.inner
3692 .commands
3693 .send(SupervisorCommand::Restart {
3694 requester,
3695 drain_timeout_ms,
3696 received_at_generation,
3697 queued_at: Instant::now(),
3698 reply: reply_tx,
3699 })
3700 .await
3701 .map_err(|_| SuperviseError::CommandClosed {
3702 module_id: self.inner.module_id.clone(),
3703 })?;
3704 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3705 module_id: self.inner.module_id.clone(),
3706 })?
3707 }
3708
3709 pub async fn swap(&self, ready_timeout: Option<Duration>) -> Result<(), SuperviseError> {
3714 self.swap_requested(ready_timeout, None).await
3715 }
3716
3717 pub(crate) async fn swap_requested(
3718 &self,
3719 ready_timeout: Option<Duration>,
3720 requester: Option<Principal>,
3721 ) -> Result<(), SuperviseError> {
3722 let (reply_tx, reply_rx) = oneshot::channel();
3723 self.inner
3724 .commands
3725 .send(SupervisorCommand::Swap {
3726 requester,
3727 ready_timeout,
3728 reply: reply_tx,
3729 })
3730 .await
3731 .map_err(|_| SuperviseError::CommandClosed {
3732 module_id: self.inner.module_id.clone(),
3733 })?;
3734 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3735 module_id: self.inner.module_id.clone(),
3736 })?
3737 }
3738
3739 pub async fn reload(&self) -> Result<(), SuperviseError> {
3740 self.reload_requested(None).await
3741 }
3742
3743 pub(crate) async fn reload_requested(
3744 &self,
3745 requester: Option<Principal>,
3746 ) -> Result<(), SuperviseError> {
3747 let (reply_tx, reply_rx) = oneshot::channel();
3748 self.inner
3749 .commands
3750 .send(SupervisorCommand::Reload {
3751 requester,
3752 reply: reply_tx,
3753 })
3754 .await
3755 .map_err(|_| SuperviseError::CommandClosed {
3756 module_id: self.inner.module_id.clone(),
3757 })?;
3758 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3759 module_id: self.inner.module_id.clone(),
3760 })?
3761 }
3762
3763 pub async fn set_enabled(&self, enabled: bool) -> Result<bool, SuperviseError> {
3764 self.set_enabled_requested(enabled, None).await
3765 }
3766
3767 pub(crate) async fn set_enabled_requested(
3768 &self,
3769 enabled: bool,
3770 requester: Option<Principal>,
3771 ) -> Result<bool, SuperviseError> {
3772 let (reply_tx, reply_rx) = oneshot::channel();
3773 self.inner
3774 .commands
3775 .send(SupervisorCommand::SetEnabled {
3776 requester,
3777 enabled,
3778 reply: reply_tx,
3779 })
3780 .await
3781 .map_err(|_| SuperviseError::CommandClosed {
3782 module_id: self.inner.module_id.clone(),
3783 })?;
3784 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3785 module_id: self.inner.module_id.clone(),
3786 })?
3787 }
3788
3789 pub(crate) fn declared_protocol(&self) -> Result<ModuleProtocol, SuperviseError> {
3793 let configured = self
3794 .inner
3795 .configuration
3796 .lock()
3797 .map_err(|_| SuperviseError::StatePoisoned {
3798 module_id: Some(self.inner.module_id.clone()),
3799 })?
3800 .spec
3801 .protocol;
3802 let state = lock_snapshot(&self.inner.snapshot)?;
3803 Ok(state.spawned_protocol.unwrap_or(configured))
3804 }
3805
3806 pub(crate) fn configuration(&self) -> Result<(ModuleSpec, HealthConfig), SuperviseError> {
3807 let configuration =
3808 self.inner
3809 .configuration
3810 .lock()
3811 .map_err(|_| SuperviseError::StatePoisoned {
3812 module_id: Some(self.inner.module_id.clone()),
3813 })?;
3814 Ok((configuration.spec.clone(), configuration.health.clone()))
3815 }
3816
3817 #[cfg(any(test, feature = "test-support"))]
3821 pub async fn update_spec_for_test(&self, spec: ModuleSpec) -> Result<(), SuperviseError> {
3822 let (_, health) = self.configuration()?;
3823 let drain_timeout_ms = u64::try_from(
3824 self.inner
3825 .effective_drain_timeout
3826 .lock()
3827 .unwrap_or_else(|poisoned| poisoned.into_inner())
3828 .as_millis(),
3829 )
3830 .ok();
3831 self.update_configuration(spec, health, drain_timeout_ms)
3832 .await
3833 }
3834
3835 pub(crate) async fn update_configuration(
3836 &self,
3837 spec: ModuleSpec,
3838 health: HealthConfig,
3839 drain_timeout_ms: Option<u64>,
3840 ) -> Result<(), SuperviseError> {
3841 if spec.module_id != self.inner.module_id {
3842 return Err(SuperviseError::InvalidSpec {
3843 reason: "a supervised module's module_id cannot be changed".to_string(),
3844 });
3845 }
3846 validate_spec(&spec)?;
3847 let (reply_tx, reply_rx) = oneshot::channel();
3848 self.inner
3849 .commands
3850 .send(SupervisorCommand::UpdateConfiguration {
3851 spec: spec.clone(),
3852 health: health.clone(),
3853 drain_timeout_ms,
3854 reply: reply_tx,
3855 })
3856 .await
3857 .map_err(|_| SuperviseError::CommandClosed {
3858 module_id: self.inner.module_id.clone(),
3859 })?;
3860 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3861 module_id: self.inner.module_id.clone(),
3862 })?;
3863 let mut configuration =
3864 self.inner
3865 .configuration
3866 .lock()
3867 .map_err(|_| SuperviseError::StatePoisoned {
3868 module_id: Some(self.inner.module_id.clone()),
3869 })?;
3870 configuration.spec = spec;
3871 configuration.health = health;
3872 Ok(())
3873 }
3874}
3875
3876impl Drop for SupervisedModuleInner {
3877 fn drop(&mut self) {
3878 let Ok(mut monitor) = self.monitor.lock() else {
3879 return;
3880 };
3881 if let Some(monitor) = monitor.as_ref().filter(|monitor| !monitor.is_finished()) {
3882 let _ = update_snapshot(&self.snapshot, Some(&self.module_id), |state| {
3883 state.state = ModuleState::Stopped;
3884 clear_current_process_facts(state);
3885 });
3886 monitor.abort();
3887 }
3888 let _ = monitor.take();
3889 }
3890}
3891
3892#[derive(Debug)]
3893enum SupervisorCommand {
3894 Drain {
3895 requester: Option<Principal>,
3896 reply: oneshot::Sender<Result<(), SuperviseError>>,
3897 },
3898 Retire {
3899 requester: Option<Principal>,
3900 reply: oneshot::Sender<Result<(), SuperviseError>>,
3901 },
3902 Restart {
3903 requester: Option<Principal>,
3904 drain_timeout_ms: Option<u64>,
3909 received_at_generation: u64,
3913 queued_at: Instant,
3916 reply: oneshot::Sender<Result<(), SuperviseError>>,
3917 },
3918 Reload {
3919 requester: Option<Principal>,
3920 reply: oneshot::Sender<Result<(), SuperviseError>>,
3921 },
3922 SetEnabled {
3923 requester: Option<Principal>,
3924 enabled: bool,
3925 reply: oneshot::Sender<Result<bool, SuperviseError>>,
3926 },
3927 UpdateConfiguration {
3928 spec: ModuleSpec,
3929 health: HealthConfig,
3930 drain_timeout_ms: Option<u64>,
3933 reply: oneshot::Sender<()>,
3934 },
3935 Swap {
3936 requester: Option<Principal>,
3937 ready_timeout: Option<Duration>,
3940 reply: oneshot::Sender<Result<(), SuperviseError>>,
3942 },
3943}
3944
3945#[derive(Debug)]
3946pub enum SuperviseError {
3947 InvalidSpec {
3948 reason: String,
3949 },
3950 Spawn {
3951 program: PathBuf,
3952 source: io::Error,
3953 cgroup_path: Option<PathBuf>,
3954 },
3955 Cgroup {
3956 module_id: String,
3957 source: io::Error,
3958 },
3959 LaunchNonce {
3962 reason: String,
3963 },
3964 Wait {
3965 module_id: String,
3966 source: io::Error,
3967 },
3968 Kill {
3969 module_id: String,
3970 source: io::Error,
3971 },
3972 Forwarding(ForwardingError),
3973 Registry(RegistryError),
3974 ReloadUnavailable {
3975 module_id: String,
3976 reason: String,
3977 },
3978 Disabled {
3983 module_id: String,
3984 },
3985 ReloadFailed {
3986 module_id: String,
3987 reason: String,
3988 },
3989 RegistrationStillActive {
3990 module_id: String,
3991 waited: Duration,
3992 },
3993 StatePoisoned {
3994 module_id: Option<String>,
3995 },
3996 CommandClosed {
3997 module_id: String,
3998 },
3999 SwapInProgress {
4003 module_id: String,
4004 },
4005 SwapRefused {
4007 module_id: String,
4008 reason: SwapRefusal,
4009 },
4010 SwapFailed {
4014 module_id: String,
4015 arm: SwapFailureArm,
4016 detail: String,
4017 candidate_exit: Option<ExitReport>,
4020 },
4021}
4022
4023#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4025pub enum SwapRefusal {
4026 OverlapExclusive,
4028 NotRegistered,
4031 ProtocolNone,
4034 NotConfigured,
4037 AlreadySwapping,
4039}
4040
4041impl SwapRefusal {
4042 pub fn as_str(self) -> &'static str {
4043 match self {
4044 Self::OverlapExclusive => "overlap_exclusive",
4045 Self::NotRegistered => "not_registered",
4046 Self::ProtocolNone => "protocol_none",
4047 Self::NotConfigured => "not_configured",
4048 Self::AlreadySwapping => "already_swapping",
4049 }
4050 }
4051}
4052
4053#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4056pub enum SwapFailureArm {
4057 SpawnFailed,
4059 NeverRegistered,
4061 NeverReady,
4063 CandidateExited,
4065 CandidateUnhealthy,
4067 Interrupted,
4071 CutoverLost,
4076}
4077
4078impl SwapFailureArm {
4079 pub fn as_str(self) -> &'static str {
4080 match self {
4081 Self::SpawnFailed => "spawn_failed",
4082 Self::NeverRegistered => "never_registered",
4083 Self::NeverReady => "never_ready",
4084 Self::CandidateExited => "candidate_exited",
4085 Self::CandidateUnhealthy => "candidate_unhealthy",
4086 Self::Interrupted => "interrupted",
4087 Self::CutoverLost => "cutover_lost",
4088 }
4089 }
4090}
4091
4092impl fmt::Display for SuperviseError {
4093 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4094 match self {
4095 Self::InvalidSpec { reason } => write!(f, "invalid module spec: {reason}"),
4096 Self::Spawn {
4097 program,
4098 source,
4099 cgroup_path: Some(cgroup_path),
4100 } => write!(
4101 f,
4102 "failed to place module in cgroup '{}' while spawning '{}': {source}",
4103 cgroup_path.display(),
4104 program.display()
4105 ),
4106 Self::Spawn {
4107 program,
4108 source,
4109 cgroup_path: None,
4110 } => write!(
4111 f,
4112 "failed to spawn module '{}': {source}",
4113 program.display()
4114 ),
4115 Self::Cgroup { module_id, source } => {
4116 write!(
4117 f,
4118 "failed to prepare cgroup for module '{module_id}': {source}"
4119 )
4120 }
4121 Self::LaunchNonce { reason } => {
4122 write!(
4123 f,
4124 "failed to generate reserved-module launch nonce: {reason}"
4125 )
4126 }
4127 Self::Wait { module_id, source } => {
4128 write!(f, "failed to wait for module '{module_id}': {source}")
4129 }
4130 Self::Kill { module_id, source } => {
4131 write!(f, "failed to kill module '{module_id}': {source}")
4132 }
4133 Self::Forwarding(err) => write!(f, "forwarding error: {err}"),
4134 Self::Registry(err) => write!(f, "registry error: {err}"),
4135 Self::ReloadUnavailable { module_id, reason } => {
4136 write!(f, "reload unavailable for module '{module_id}': {reason}")
4137 }
4138 Self::Disabled { module_id } => {
4139 write!(
4140 f,
4141 "module '{module_id}' is disabled; enable it before restart or reload"
4142 )
4143 }
4144 Self::ReloadFailed { module_id, reason } => {
4145 write!(f, "reload failed for module '{module_id}': {reason}")
4146 }
4147 Self::RegistrationStillActive { module_id, waited } => write!(
4148 f,
4149 "module '{module_id}' registration remained active after waiting {waited:?}"
4150 ),
4151 Self::StatePoisoned { module_id } => match module_id {
4152 Some(module_id) => {
4153 write!(f, "supervisor state for module '{module_id}' was poisoned")
4154 }
4155 None => write!(f, "supervisor state was poisoned"),
4156 },
4157 Self::CommandClosed { module_id } => {
4158 write!(
4159 f,
4160 "supervisor command channel for module '{module_id}' is closed"
4161 )
4162 }
4163 Self::SwapInProgress { module_id } => write!(
4164 f,
4165 "module '{module_id}' is being swapped; retry once the swap has cut over or failed, or stop the module to abort the swap"
4166 ),
4167 Self::SwapRefused { module_id, reason } => match reason {
4168 SwapRefusal::OverlapExclusive => write!(
4169 f,
4170 "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"
4171 ),
4172 SwapRefusal::NotRegistered => write!(
4173 f,
4174 "module '{module_id}' is not registered, so there is no serving process to keep while a replacement warms; use a plain restart"
4175 ),
4176 SwapRefusal::ProtocolNone => write!(
4177 f,
4178 "module '{module_id}' is protocol: \"none\" and never registers, so a swap could never see its replacement become ready; use a plain restart"
4179 ),
4180 SwapRefusal::NotConfigured => write!(
4181 f,
4182 "module '{module_id}' cannot be swapped: the supervisor was built without the forwarding table or shared handle a swap needs"
4183 ),
4184 SwapRefusal::AlreadySwapping => {
4185 write!(f, "module '{module_id}' is already being swapped")
4186 }
4187 },
4188 Self::SwapFailed {
4189 module_id,
4190 arm,
4191 detail,
4192 ..
4193 } => write!(
4194 f,
4195 "swap of module '{module_id}' failed ({}): {detail}; the running process was left serving",
4196 arm.as_str()
4197 ),
4198 }
4199 }
4200}
4201
4202impl Error for SuperviseError {
4203 fn source(&self) -> Option<&(dyn Error + 'static)> {
4204 match self {
4205 Self::Spawn { source, .. }
4206 | Self::Cgroup { source, .. }
4207 | Self::Wait { source, .. }
4208 | Self::Kill { source, .. } => Some(source),
4209 Self::Forwarding(err) => Some(err),
4210 Self::Registry(err) => Some(err),
4211 Self::LaunchNonce { .. }
4212 | Self::InvalidSpec { .. }
4213 | Self::ReloadUnavailable { .. }
4214 | Self::Disabled { .. }
4215 | Self::ReloadFailed { .. }
4216 | Self::RegistrationStillActive { .. }
4217 | Self::StatePoisoned { .. }
4218 | Self::CommandClosed { .. }
4219 | Self::SwapInProgress { .. }
4220 | Self::SwapRefused { .. }
4221 | Self::SwapFailed { .. } => None,
4222 }
4223 }
4224}
4225
4226pub(crate) fn validate_spec(spec: &ModuleSpec) -> Result<(), SuperviseError> {
4227 if spec.module_id.trim().is_empty() {
4228 return Err(SuperviseError::InvalidSpec {
4229 reason: "module_id must not be empty".to_string(),
4230 });
4231 }
4232
4233 Ok(())
4234}
4235
4236#[derive(Debug, Default)]
4237struct HealthProbeRuntime {
4238 configured_health: Option<HealthConfig>,
4239 registered_connection: Option<crate::ConnectionId>,
4240 advertised: bool,
4241 next_probe_at: Option<Instant>,
4242 probe_index: u64,
4243}
4244
4245fn running_protocol(spec: &ModuleSpec, snapshot: &SharedSnapshot) -> ModuleProtocol {
4246 lock_snapshot(snapshot)
4247 .ok()
4248 .and_then(|state| state.spawned_protocol)
4249 .unwrap_or(spec.protocol)
4250}
4251
4252impl HealthProbeRuntime {
4253 fn refresh_registration(
4254 &mut self,
4255 spec: &ModuleSpec,
4256 runtime: &SupervisorRuntimeConfig,
4257 registry: &Registry,
4258 snapshot: &SharedSnapshot,
4259 ) {
4260 if self.configured_health.as_ref() != Some(&runtime.health) {
4261 self.configured_health = Some(runtime.health.clone());
4262 self.next_probe_at = None;
4263 self.registered_connection = None;
4264 self.probe_index = 0;
4265 }
4266 if running_protocol(spec, snapshot) == ModuleProtocol::None {
4270 self.registered_connection = None;
4271 self.advertised = runtime.health.http.is_some();
4272 if !self.advertised {
4273 self.next_probe_at = None;
4274 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4275 let unknown = ModuleHealthStatus::default();
4276 if state.health != unknown {
4277 state.health = unknown;
4278 }
4279 });
4280 } else if self.next_probe_at.is_none() {
4281 self.next_probe_at = Some(
4282 Instant::now()
4283 + jittered_health_delay(&spec.module_id, 0, runtime.health.cadence),
4284 );
4285 }
4286 return;
4287 }
4288
4289 let registration = match registry.get_module(&spec.module_id) {
4290 Ok(registration) => registration,
4291 Err(err) => {
4292 warn!(module_id = %spec.module_id, error = %err, "health prober could not read registry");
4293 self.advertised = false;
4294 self.next_probe_at = None;
4295 return;
4296 }
4297 };
4298
4299 let Some(registration) = registration else {
4300 self.registered_connection = None;
4301 self.advertised = false;
4302 self.next_probe_at = None;
4303 return;
4304 };
4305
4306 let advertised = registration
4307 .control_ops
4308 .iter()
4309 .any(|op| op == MODULE_CONTROL_OP_HEALTH_CHECK);
4310 if !advertised {
4311 self.registered_connection = Some(registration.connection_id);
4312 self.advertised = false;
4313 self.next_probe_at = None;
4314 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4315 if state.health.status != SupervisorHealthStatus::Unknown
4316 || state.health.consecutive_failures != 0
4317 || state.health.last_probe_ms.is_some()
4318 || state.health.detail.is_some()
4319 || state.health.metrics.is_some()
4320 {
4321 state.health.status = SupervisorHealthStatus::Unknown;
4322 state.health.consecutive_failures = 0;
4323 state.health.last_probe_ms = None;
4324 state.health.detail = None;
4325 state.health.metrics = None;
4326 }
4327 });
4328 return;
4329 }
4330
4331 let reregistered = self.registered_connection != Some(registration.connection_id);
4332 self.registered_connection = Some(registration.connection_id);
4333 self.advertised = true;
4334 if reregistered || self.next_probe_at.is_none() {
4335 self.probe_index = 0;
4336 self.next_probe_at = Some(
4337 Instant::now() + jittered_health_delay(&spec.module_id, 0, runtime.health.cadence),
4338 );
4339 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4340 state.health.status = SupervisorHealthStatus::Unknown;
4341 state.health.consecutive_failures = 0;
4342 state.health.detail = None;
4343 state.health.metrics = None;
4344 });
4345 }
4346 }
4347
4348 fn wake_after(&self) -> Option<Duration> {
4349 if !self.advertised {
4350 return None;
4351 }
4352 self.next_probe_at
4353 .map(|next| next.saturating_duration_since(Instant::now()))
4354 }
4355
4356 fn due(&self) -> bool {
4357 self.advertised
4358 && self
4359 .next_probe_at
4360 .is_some_and(|next| Instant::now() >= next)
4361 }
4362
4363 fn schedule_next(&mut self, spec: &ModuleSpec, cadence: Duration) {
4364 self.probe_index = self.probe_index.wrapping_add(1);
4365 self.next_probe_at = Some(
4366 Instant::now() + jittered_health_delay(&spec.module_id, self.probe_index, cadence),
4367 );
4368 }
4369}
4370
4371#[derive(Debug)]
4406enum HealthProbeEvidence {
4407 LaneDead,
4409 NoAnswer,
4411 BadAnswer,
4413 Misconfigured,
4415}
4416
4417#[derive(Debug)]
4418struct HealthProbeError {
4419 evidence: HealthProbeEvidence,
4420 message: String,
4421}
4422
4423impl HealthProbeError {
4424 fn lane_dead(message: impl Into<String>) -> Self {
4425 Self::with(HealthProbeEvidence::LaneDead, message)
4426 }
4427
4428 fn no_answer(message: impl Into<String>) -> Self {
4429 Self::with(HealthProbeEvidence::NoAnswer, message)
4430 }
4431
4432 fn bad_answer(message: impl Into<String>) -> Self {
4433 Self::with(HealthProbeEvidence::BadAnswer, message)
4434 }
4435
4436 fn misconfigured(message: impl Into<String>) -> Self {
4437 Self::with(HealthProbeEvidence::Misconfigured, message)
4438 }
4439
4440 fn with(evidence: HealthProbeEvidence, message: impl Into<String>) -> Self {
4441 Self {
4442 evidence,
4443 message: message.into(),
4444 }
4445 }
4446
4447 #[allow(dead_code)]
4461 fn is_proof_of_death(&self) -> bool {
4462 matches!(self.evidence, HealthProbeEvidence::LaneDead)
4463 }
4464
4465 fn label(&self) -> &'static str {
4473 match self.evidence {
4474 HealthProbeEvidence::LaneDead => "lane-dead",
4475 HealthProbeEvidence::NoAnswer => "no-answer",
4476 HealthProbeEvidence::BadAnswer => "bad-answer",
4477 HealthProbeEvidence::Misconfigured => "daemon-misconfigured",
4478 }
4479 }
4480}
4481
4482impl fmt::Display for HealthProbeError {
4483 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4484 f.write_str(&self.message)
4485 }
4486}
4487
4488async fn run_health_probe_cycle(
4489 spec: &ModuleSpec,
4490 runtime: &SupervisorRuntimeConfig,
4491 registry: &Registry,
4492 process_liveness: &SupervisorProcessLiveness,
4493 snapshot: &SharedSnapshot,
4494 child: &mut Option<SupervisedChild>,
4495) {
4496 let now_ms = unix_ms_now();
4497 let http = (running_protocol(spec, snapshot) == ModuleProtocol::None)
4498 .then_some(runtime.health.http.as_deref())
4499 .flatten();
4500 let result = match http {
4501 Some(url) => probe_http_health(url, runtime.health.deadline).await,
4502 None => probe_module_health(&spec.module_id, runtime, None).await,
4503 };
4504 match result {
4505 Ok(report) => {
4506 handle_health_report(
4507 spec,
4508 runtime,
4509 registry,
4510 process_liveness,
4511 snapshot,
4512 child,
4513 report,
4514 now_ms,
4515 )
4516 .await;
4517 }
4518 Err(err) => {
4519 if http.is_some() {
4520 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4521 state.health.status = SupervisorHealthStatus::Failing;
4522 });
4523 }
4524 handle_health_probe_failure(
4525 spec,
4526 runtime,
4527 registry,
4528 process_liveness,
4529 snapshot,
4530 child,
4531 err,
4532 now_ms,
4533 )
4534 .await;
4535 }
4536 }
4537}
4538
4539pub(crate) struct HttpProbeTarget<'a> {
4540 address: std::net::SocketAddr,
4541 localhost: bool,
4542 authority: &'a str,
4543 path: String,
4544}
4545
4546pub(crate) fn parse_http_probe_url(url: &str) -> Result<HttpProbeTarget<'_>, String> {
4549 if url.bytes().any(|b| b <= b' ' || b == 127) || url.contains('#') {
4550 return Err("must not contain whitespace, controls, or a fragment".into());
4551 }
4552 let rest = url
4553 .strip_prefix("http://")
4554 .ok_or("must use plain http://")?;
4555 let split = rest.find(['/', '?']).unwrap_or(rest.len());
4556 let (authority, suffix) = rest.split_at(split);
4557 let (host, port) = if let Some(rest) = authority.strip_prefix("[::1]") {
4558 ("::1", rest)
4559 } else {
4560 let split = authority.find(':').unwrap_or(authority.len());
4561 authority.split_at(split)
4562 };
4563 let ip: std::net::IpAddr = match host {
4564 "127.0.0.1" | "localhost" => std::net::Ipv4Addr::LOCALHOST.into(),
4565 "::1" => std::net::Ipv6Addr::LOCALHOST.into(),
4566 _ => return Err("host must be 127.0.0.1, [::1], or localhost".into()),
4567 };
4568 let port = if port.is_empty() {
4569 80
4570 } else {
4571 port.strip_prefix(':')
4572 .and_then(|p| p.parse::<u16>().ok())
4573 .filter(|p| *p > 0)
4574 .ok_or("must have a valid nonzero TCP port")?
4575 };
4576 let path = if suffix.is_empty() {
4577 "/".into()
4578 } else if suffix.starts_with('?') {
4579 format!("/{suffix}")
4580 } else {
4581 suffix.into()
4582 };
4583 Ok(HttpProbeTarget {
4584 address: std::net::SocketAddr::new(ip, port),
4585 localhost: host == "localhost",
4586 authority,
4587 path,
4588 })
4589}
4590
4591async fn probe_http_health(
4592 url: &str,
4593 deadline: Duration,
4594) -> Result<HealthReport, HealthProbeError> {
4595 use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader};
4596 let target = parse_http_probe_url(url).map_err(HealthProbeError::misconfigured)?;
4597 let mut response_status = String::new();
4600 let mut body = Vec::new();
4601 let probe = async {
4602 let connection = match tokio::net::TcpStream::connect(target.address).await {
4605 Err(_) if target.localhost => {
4606 tokio::net::TcpStream::connect((
4607 std::net::Ipv6Addr::LOCALHOST,
4608 target.address.port(),
4609 ))
4610 .await
4611 }
4612 result => result,
4613 };
4614 let mut stream = connection.map_err(|error| {
4615 HealthProbeError::no_answer(format!("HTTP connect failed: {error}"))
4616 })?;
4617 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
4618 .map_err(|error| HealthProbeError::no_answer(format!("HTTP write failed: {error}")))?;
4619 let mut reader = BufReader::new(stream);
4620 let mut budget = 16 * 1024;
4621 let status = http_line(&mut reader, &mut budget).await?;
4622 let mut words = status.split_ascii_whitespace();
4623 let version = words.next();
4624 let code = words
4625 .next()
4626 .filter(|word| word.len() == 3)
4627 .and_then(|word| word.parse::<u16>().ok());
4628 if !matches!(version, Some("HTTP/1.1" | "HTTP/1.0"))
4629 || !code.is_some_and(|code| (100..600).contains(&code))
4630 {
4631 return Err(HealthProbeError::bad_answer(format!(
4632 "invalid HTTP status: {status}"
4633 )));
4634 }
4635 let code = code.expect("validated status code");
4636 response_status = status.clone();
4637 let mut length = None;
4638 let mut chunked = false;
4639 loop {
4640 let line = http_line(&mut reader, &mut budget).await?;
4641 if line.is_empty() {
4642 break;
4643 }
4644 if let Some((name, value)) = line.split_once(':') {
4645 if name.eq_ignore_ascii_case("content-length") {
4646 length = Some(value.trim().parse::<u64>().map_err(|_| {
4647 HealthProbeError::bad_answer("invalid HTTP Content-Length")
4648 })?);
4649 } else if name.eq_ignore_ascii_case("transfer-encoding") {
4650 chunked = value.trim().eq_ignore_ascii_case("chunked");
4651 }
4652 }
4653 }
4654 if chunked {
4655 while body.len() < 200 {
4656 let line = http_line(&mut reader, &mut budget).await?;
4657 let size = u64::from_str_radix(line.split(';').next().unwrap_or("").trim(), 16)
4658 .map_err(|_| HealthProbeError::bad_answer("invalid HTTP chunk size"))?;
4659 if size == 0 {
4660 break;
4661 }
4662 let count = size.min((200 - body.len()) as u64) as usize;
4663 let start = body.len();
4664 (&mut reader)
4665 .take(count as u64)
4666 .read_to_end(&mut body)
4667 .await
4668 .map_err(|error| {
4669 HealthProbeError::no_answer(format!("HTTP body read failed: {error}"))
4670 })?;
4671 if body.len() - start != count {
4672 return Err(HealthProbeError::bad_answer("truncated HTTP chunk"));
4673 }
4674 if size > count as u64 || body.len() == 200 {
4675 break;
4676 }
4677 if !http_line(&mut reader, &mut budget).await?.is_empty() {
4678 return Err(HealthProbeError::bad_answer("invalid HTTP chunk delimiter"));
4679 }
4680 }
4681 } else {
4682 reader
4683 .take(length.unwrap_or(200).min(200))
4684 .read_to_end(&mut body)
4685 .await
4686 .map_err(|error| {
4687 HealthProbeError::no_answer(format!("HTTP body read failed: {error}"))
4688 })?;
4689 }
4690 if (200..300).contains(&code) {
4691 Ok(HealthReport::ok())
4692 } else {
4693 Err(HealthProbeError::bad_answer(
4694 "HTTP health endpoint returned non-2xx",
4695 ))
4696 }
4697 };
4698 let mut result = timeout(deadline, probe).await.unwrap_or_else(|_| {
4699 Err(HealthProbeError::no_answer(format!(
4700 "HTTP probe timed out after {deadline:?}"
4701 )))
4702 });
4703 if let Err(error) = &mut result {
4704 if !response_status.is_empty() {
4705 error.message = format!(
4706 "{}; {response_status}: {}",
4707 error.message,
4708 String::from_utf8_lossy(&body)
4709 );
4710 }
4711 }
4712 result
4713}
4714
4715async fn http_line(
4716 reader: &mut tokio::io::BufReader<tokio::net::TcpStream>,
4717 remaining: &mut usize,
4718) -> Result<String, HealthProbeError> {
4719 use tokio::io::{AsyncBufReadExt, AsyncReadExt};
4720 let mut line = Vec::new();
4721 (&mut *reader)
4722 .take(*remaining as u64)
4723 .read_until(b'\n', &mut line)
4724 .await
4725 .map_err(|error| {
4726 HealthProbeError::no_answer(format!("HTTP header read failed: {error}"))
4727 })?;
4728 *remaining -= line.len();
4729 if !line.ends_with(b"\r\n") {
4730 return Err(HealthProbeError::bad_answer(
4731 "HTTP headers are incomplete or exceed 16 KiB",
4732 ));
4733 }
4734 line.truncate(line.len() - 2);
4735 String::from_utf8(line).map_err(|_| HealthProbeError::bad_answer("HTTP header is not UTF-8"))
4736}
4737
4738async fn probe_module_health(
4739 module_id: &str,
4740 runtime: &SupervisorRuntimeConfig,
4741 drain_deadline: Option<Instant>,
4742) -> Result<HealthReport, HealthProbeError> {
4743 let Some(forwarding) = runtime.forwarding.as_ref() else {
4744 return Err(HealthProbeError::misconfigured(
4745 "supervisor was not configured with a forwarding table",
4746 ));
4747 };
4748 let probe_started_at = Instant::now();
4749 let mut deadline = probe_started_at + runtime.health.deadline;
4750 if let Some(drain_deadline) = drain_deadline {
4751 deadline = deadline.min(drain_deadline);
4752 }
4753 let pending = if drain_deadline.is_some() {
4754 forwarding.begin_drain_health_probe_rpc_for(
4755 module_id,
4756 MODULE_CONTROL_OP_HEALTH_CHECK,
4757 probe_started_at,
4758 deadline,
4759 )
4760 } else {
4761 forwarding.begin_health_probe_rpc_for(
4762 module_id,
4763 MODULE_CONTROL_OP_HEALTH_CHECK,
4764 probe_started_at,
4765 deadline,
4766 )
4767 }
4768 .map_err(|err| {
4769 HealthProbeError::lane_dead(format!("failed to begin health.check RPC: {err}"))
4772 })?;
4773 await_health_probe(forwarding, pending, deadline, runtime.health.deadline).await
4774}
4775
4776async fn probe_endpoint_health(
4783 endpoint: crate::ModuleEndpointId,
4784 runtime: &SupervisorRuntimeConfig,
4785 deadline_cap: Option<Instant>,
4786) -> Result<HealthReport, HealthProbeError> {
4787 let Some(forwarding) = runtime.forwarding.as_ref() else {
4788 return Err(HealthProbeError::misconfigured(
4789 "supervisor was not configured with a forwarding table",
4790 ));
4791 };
4792 let probe_started_at = Instant::now();
4793 let mut deadline = probe_started_at + runtime.health.deadline;
4794 if let Some(cap) = deadline_cap {
4795 deadline = deadline.min(cap);
4796 }
4797 let pending = forwarding
4798 .begin_endpoint_health_probe_rpc_for(
4799 endpoint,
4800 MODULE_CONTROL_OP_HEALTH_CHECK,
4801 probe_started_at,
4802 deadline,
4803 )
4804 .map_err(|err| {
4805 HealthProbeError::lane_dead(format!("failed to begin health.check RPC: {err}"))
4806 })?;
4807 await_health_probe(forwarding, pending, deadline, runtime.health.deadline).await
4808}
4809
4810async fn await_health_probe(
4812 forwarding: &ForwardingTable,
4813 pending: PendingModuleControlRpc,
4814 deadline: Instant,
4815 probe_budget: Duration,
4816) -> Result<HealthReport, HealthProbeError> {
4817 let PendingModuleControlRpc {
4818 endpoint,
4819 module_sink,
4820 negotiated_ver,
4821 corr,
4822 receiver,
4823 } = pending;
4824 let body = serde_json::to_vec(&ModuleControlRequest::HealthCheck {}).map_err(|err| {
4825 HealthProbeError::misconfigured(format!("failed to encode health.check: {err}"))
4826 })?;
4827 let frame = Frame::build_with_version(
4828 negotiated_ver,
4829 FrameType::Request,
4830 control_flags(),
4831 0,
4832 0,
4833 corr,
4834 body,
4835 )
4836 .map_err(|err| {
4837 HealthProbeError::misconfigured(format!("failed to build health.check frame: {err}"))
4838 })?;
4839
4840 match timeout_at(deadline, module_sink.send(frame)).await {
4846 Ok(Ok(())) => {}
4847 Ok(Err(err)) => {
4848 let _ = forwarding.cancel_module_control_rpc(endpoint, corr);
4849 return Err(HealthProbeError::lane_dead(format!(
4852 "failed to send health.check: {err}"
4853 )));
4854 }
4855 Err(_elapsed) => {
4856 let _ = forwarding.cancel_module_control_rpc(endpoint, corr);
4857 return Err(HealthProbeError::no_answer(
4861 "health.check send timed out before enqueue (module egress full)",
4862 ));
4863 }
4864 }
4865
4866 match timeout_at(deadline, receiver).await {
4867 Ok(Ok(ModuleControlRpcOutcome::Response(response))) => {
4871 response.health_report().ok_or_else(|| {
4872 HealthProbeError::bad_answer("health.check RPC returned a non-health response")
4873 })
4874 }
4875 Ok(Ok(ModuleControlRpcOutcome::Rejected(body))) => Err(HealthProbeError::bad_answer(
4876 format!("health.check rejected: {}", body.message),
4877 )),
4878 Ok(Ok(ModuleControlRpcOutcome::ModuleGone(message))) => {
4879 Err(HealthProbeError::lane_dead(message))
4880 }
4881 Ok(Ok(ModuleControlRpcOutcome::MalformedResponse(message))) => {
4882 Err(HealthProbeError::bad_answer(message))
4883 }
4884 Ok(Ok(ModuleControlRpcOutcome::UnexpectedOp { expected, actual })) => {
4885 Err(HealthProbeError::bad_answer(format!(
4886 "expected module-control op '{expected}', got '{actual}'"
4887 )))
4888 }
4889 Ok(Ok(ModuleControlRpcOutcome::DeadlineElapsed)) => Err(HealthProbeError::bad_answer(
4893 "module answered health.check after its daemon deadline",
4894 )),
4895 Ok(Err(_)) => Err(HealthProbeError::misconfigured(
4896 "health.check waiter was canceled before the module responded",
4897 )),
4898 Err(_) => {
4899 let _ = forwarding.tombstone_health_probe_rpc(endpoint, corr);
4900 Err(HealthProbeError::no_answer(format!(
4901 "module did not answer health.check within {probe_budget:?}"
4902 )))
4903 }
4904 }
4905}
4906
4907#[allow(clippy::too_many_arguments)]
4908async fn handle_health_report(
4909 spec: &ModuleSpec,
4910 runtime: &SupervisorRuntimeConfig,
4911 registry: &Registry,
4912 process_liveness: &SupervisorProcessLiveness,
4913 snapshot: &SharedSnapshot,
4914 child: &mut Option<SupervisedChild>,
4915 report: HealthReport,
4916 now_ms: u64,
4917) {
4918 let status = supervisor_health_status(report.status);
4919 let detail = report.detail.clone();
4920 let metrics = truncate_health_metrics(report.metrics);
4921 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4922 state.health.status = status;
4923 state.health.last_probe_ms = Some(now_ms);
4924 state.health.detail = detail.clone();
4925 state.health.metrics = metrics.clone();
4926 state.health.consecutive_failures = 0;
4927 });
4928
4929 let action = match report.status {
4930 HealthStatus::Ok => return,
4931 HealthStatus::Degraded => runtime.health.on_degraded,
4932 HealthStatus::Failing => runtime.health.on_failing,
4933 };
4934 apply_l3_health_action(
4935 spec,
4936 runtime,
4937 registry,
4938 process_liveness,
4939 snapshot,
4940 child,
4941 status,
4942 detail.as_deref(),
4943 action,
4944 now_ms,
4945 )
4946 .await;
4947}
4948
4949#[allow(clippy::too_many_arguments)]
4950async fn handle_health_probe_failure(
4951 spec: &ModuleSpec,
4952 runtime: &SupervisorRuntimeConfig,
4953 registry: &Registry,
4954 process_liveness: &SupervisorProcessLiveness,
4955 snapshot: &SharedSnapshot,
4956 child: &mut Option<SupervisedChild>,
4957 err: HealthProbeError,
4958 now_ms: u64,
4959) {
4960 let threshold = runtime.health.failure_threshold.max(1);
4961 let mut failures = 0;
4962 let detail = format!("[{}] {err}", err.label());
4967 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4968 if state.spawned_protocol.unwrap_or(spec.protocol) == ModuleProtocol::Subc {
4971 state.health.status = SupervisorHealthStatus::Unknown;
4972 }
4973 state.health.last_probe_ms = Some(now_ms);
4974 state.health.consecutive_failures = state.health.consecutive_failures.saturating_add(1);
4975 state.health.detail = Some(detail.clone());
4976 state.health.metrics = None;
4977 failures = state.health.consecutive_failures;
4978 });
4979
4980 if failures < threshold {
4981 warn!(
4982 module_id = %spec.module_id,
4983 consecutive_failures = failures,
4984 threshold,
4985 evidence = err.label(),
4986 detail = %detail,
4987 "health.check probe failed"
4988 );
4989 return;
4990 }
4991
4992 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4993 state.state = ModuleState::Unresponsive;
4994 state.health.status = SupervisorHealthStatus::Unresponsive;
4995 });
4996 if runtime.health.critical {
5000 error!(
5001 module_id = %spec.module_id,
5002 status = "unresponsive",
5003 evidence = err.label(),
5004 detail = %detail,
5005 "critical module health alert"
5006 );
5007 } else {
5008 warn!(
5009 module_id = %spec.module_id,
5010 status = "unresponsive",
5011 evidence = err.label(),
5012 detail = %detail,
5013 "module health threshold breached"
5014 );
5015 }
5016 if let Err(err) = health_restart_child(
5017 spec,
5018 runtime,
5019 registry,
5020 process_liveness,
5021 snapshot,
5022 child,
5023 SupervisorHealthStatus::Unresponsive,
5024 Some(&detail),
5025 now_ms,
5026 )
5027 .await
5028 {
5029 error!(module_id = %spec.module_id, error = %err, "health-triggered restart failed");
5030 }
5031}
5032
5033#[allow(clippy::too_many_arguments)]
5034async fn apply_l3_health_action(
5035 spec: &ModuleSpec,
5036 runtime: &SupervisorRuntimeConfig,
5037 registry: &Registry,
5038 process_liveness: &SupervisorProcessLiveness,
5039 snapshot: &SharedSnapshot,
5040 child: &mut Option<SupervisedChild>,
5041 status: SupervisorHealthStatus,
5042 detail: Option<&str>,
5043 action: HealthAction,
5044 now_ms: u64,
5045) {
5046 record_health_action(snapshot, &spec.module_id, action.to_string(), now_ms);
5047 match action {
5048 HealthAction::Report => {
5049 info!(
5050 module_id = %spec.module_id,
5051 status = ?status,
5052 detail,
5053 "module reported non-ok health"
5054 );
5055 }
5056 HealthAction::Alert => {
5057 error!(
5058 module_id = %spec.module_id,
5059 status = ?status,
5060 detail,
5061 "module health alert"
5062 );
5063 }
5064 HealthAction::Restart => {
5065 if let Err(err) = health_restart_child(
5066 spec,
5067 runtime,
5068 registry,
5069 process_liveness,
5070 snapshot,
5071 child,
5072 status,
5073 detail,
5074 now_ms,
5075 )
5076 .await
5077 {
5078 error!(module_id = %spec.module_id, error = %err, "health-triggered restart failed");
5079 }
5080 }
5081 }
5082}
5083
5084#[allow(clippy::too_many_arguments)]
5085async fn health_restart_child(
5086 spec: &ModuleSpec,
5087 runtime: &SupervisorRuntimeConfig,
5088 registry: &Registry,
5089 process_liveness: &SupervisorProcessLiveness,
5090 snapshot: &SharedSnapshot,
5091 child: &mut Option<SupervisedChild>,
5092 status: SupervisorHealthStatus,
5093 detail: Option<&str>,
5094 now_ms: u64,
5095) -> Result<(), SuperviseError> {
5096 let (enabled, schedule) = {
5097 let mut state = lock_snapshot(snapshot)?;
5098 let enabled = state.enabled;
5099 let schedule = if enabled {
5100 state.next_crash_restart(&runtime.restart_policy, Instant::now())
5101 } else {
5102 None
5103 };
5104 (enabled, schedule)
5105 };
5106
5107 if !enabled {
5108 return Err(SuperviseError::Disabled {
5109 module_id: spec.module_id.clone(),
5110 });
5111 }
5112
5113 if schedule.is_none() {
5114 record_health_action(snapshot, &spec.module_id, "failed".to_string(), now_ms);
5115 error!(
5116 module_id = %spec.module_id,
5117 status = ?status,
5118 detail,
5119 max_restarts = runtime.restart_policy.max_restarts,
5120 window_secs = runtime.restart_policy.window.as_secs(),
5121 reason = %runtime.restart_policy.budget_exhausted_detail(),
5122 "health restart budget exhausted; marking module failed"
5123 );
5124 let stop_notice = begin_forwarding_drain_if_configured(
5125 spec,
5126 runtime,
5127 registry,
5128 snapshot,
5129 Some(true),
5130 RouteCloseReason::Disable,
5131 )
5132 .await?;
5133 update_snapshot(snapshot, Some(&spec.module_id), |state| {
5134 state.drain_disposition_detail = Some(runtime.restart_policy.budget_exhausted_detail());
5135 })?;
5136 drain_optional_child(
5137 &spec.module_id,
5138 spec.protocol,
5139 stop_notice,
5140 registry,
5141 runtime.forwarding.as_deref(),
5142 snapshot,
5143 &runtime.terminal_ring,
5144 &runtime.spawn_events,
5145 child,
5146 runtime.drain_timeout,
5147 ModuleState::Failed,
5148 Some(true),
5149 )
5150 .await?;
5151 process_liveness.untrack_if_current(&spec.module_id, snapshot);
5152 return Ok(());
5153 }
5154
5155 let schedule = schedule.expect("a health restart must have a crash-restart schedule");
5156 let mut restart_count = 0;
5157 update_snapshot(snapshot, Some(&spec.module_id), |state| {
5158 restart_count = state.crash_restarts.len();
5159 state.state = ModuleState::Unresponsive;
5160 state.health.status = status;
5161 state.health.last_action = Some(HealthAction::Restart.to_string());
5162 state.health.last_action_ms = Some(now_ms);
5163 })?;
5164 warn!(
5165 module_id = %spec.module_id,
5166 status = ?status,
5167 detail,
5168 restart_count,
5169 restart_in_window = schedule.restart_in_window,
5170 delay_ms = schedule.delay.as_millis() as u64,
5171 "health-triggered module restart"
5172 );
5173
5174 let stop_notice = begin_forwarding_drain_if_configured(
5175 spec,
5176 runtime,
5177 registry,
5178 snapshot,
5179 Some(true),
5180 RouteCloseReason::Restart,
5181 )
5182 .await?;
5183 drain_optional_child(
5184 &spec.module_id,
5185 spec.protocol,
5186 stop_notice,
5187 registry,
5188 runtime.forwarding.as_deref(),
5189 snapshot,
5190 &runtime.terminal_ring,
5191 &runtime.spawn_events,
5192 child,
5193 runtime.drain_timeout,
5194 ModuleState::Restarting,
5195 Some(true),
5196 )
5197 .await?;
5198 schedule_respawn(
5199 runtime,
5200 snapshot,
5201 &spec.module_id,
5202 schedule.delay,
5203 RespawnKind::Spawn,
5204 )
5205}
5206
5207fn cancel_deferred_reload(runtime: &SupervisorRuntimeConfig, module_id: &str, reason: &str) {
5208 if let Some(reply) = runtime
5209 .deferred_reload_reply
5210 .lock()
5211 .unwrap_or_else(|p| p.into_inner())
5212 .take()
5213 {
5214 let _ = reply.send(Err(SuperviseError::ReloadFailed {
5215 module_id: module_id.to_string(),
5216 reason: reason.to_string(),
5217 }));
5218 }
5219}
5220
5221fn schedule_respawn(
5222 runtime: &SupervisorRuntimeConfig,
5223 snapshot: &SharedSnapshot,
5224 module_id: &str,
5225 delay: Duration,
5226 kind: RespawnKind,
5227) -> Result<(), SuperviseError> {
5228 cancel_deferred_reload(runtime, module_id, "respawn superseded by another restart");
5229 update_snapshot(snapshot, Some(module_id), |state| {
5230 state.respawn_pending = true
5231 })?;
5232 *runtime
5233 .scheduled_respawn
5234 .lock()
5235 .unwrap_or_else(|p| p.into_inner()) = Some(PendingRespawn {
5236 deadline: Instant::now() + delay,
5237 kind,
5238 });
5239 Ok(())
5240}
5241
5242fn record_health_action(snapshot: &SharedSnapshot, module_id: &str, action: String, now_ms: u64) {
5243 let _ = update_snapshot(snapshot, Some(module_id), |state| {
5244 state.health.last_action = Some(action);
5245 state.health.last_action_ms = Some(now_ms);
5246 });
5247}
5248
5249fn supervisor_health_status(status: HealthStatus) -> SupervisorHealthStatus {
5250 match status {
5251 HealthStatus::Ok => SupervisorHealthStatus::Ok,
5252 HealthStatus::Degraded => SupervisorHealthStatus::Degraded,
5253 HealthStatus::Failing => SupervisorHealthStatus::Failing,
5254 }
5255}
5256
5257fn truncate_health_metrics(metrics: Option<Value>) -> Option<Value> {
5269 let metrics = metrics?;
5270 match serde_json::to_vec(&metrics) {
5271 Ok(encoded) if encoded.len() > MAX_HEALTH_METRICS_BYTES => Some(serde_json::json!({
5272 "truncated": true,
5273 "original_bytes": encoded.len(),
5274 })),
5275 Ok(_) | Err(_) => Some(metrics),
5276 }
5277}
5278
5279fn jittered_health_delay(module_id: &str, probe_index: u64, cadence: Duration) -> Duration {
5285 if cadence.is_zero() {
5286 return Duration::ZERO;
5287 }
5288 let cadence_ms = cadence.as_millis() as u64;
5289 if cadence_ms == 0 {
5305 return cadence;
5306 }
5307 let jitter_span = (cadence_ms / 10).max(1);
5322 let hash = module_id.as_bytes().iter().fold(
5323 probe_index.wrapping_mul(0x9E37_79B9_7F4A_7C15),
5324 |acc, byte| {
5325 acc.wrapping_mul(1099511628211)
5326 .wrapping_add(u64::from(*byte))
5327 },
5328 );
5329 cadence + Duration::from_millis(hash % jitter_span)
5330}
5331
5332#[cfg(test)]
5333mod tests {
5334 use super::*;
5335
5336 #[test]
5337 fn readding_a_module_clears_its_rescan_removal_tombstone() {
5338 let handle = SupervisorHandle::new();
5339 let module_id = "readded-tombstone";
5340 handle.record_rescan_removal(module_id);
5341 assert!(handle.removal_tombstone_age_ms(module_id).is_some());
5342
5343 handle.apply_identity_configuration(&ModuleSpec {
5344 module_id: module_id.to_string(),
5345 program: PathBuf::from("/test/module"),
5346 args: Vec::new(),
5347 env: Vec::new(),
5348 reserved: false,
5349 reserved_prefixes: Vec::new(),
5350 protocol: ModuleProtocol::Subc,
5351 overlap: Default::default(),
5352 });
5353
5354 assert!(
5355 handle.removal_tombstone_age_ms(module_id).is_none(),
5356 "a re-added module must not retain a stale removal tombstone"
5357 );
5358 }
5359
5360 #[derive(Debug, PartialEq, Eq)]
5363 struct OwnerInSpawnWindow {
5364 module_id: String,
5365 configured: bool,
5366 on_roster: bool,
5367 admission_refusal: Option<&'static str>,
5368 }
5369
5370 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5381 async fn a_new_module_is_configured_before_its_first_process_can_register() {
5382 use crate::scopes::ScopeTable;
5383 use subc_protocol::{error_codes, scope::ScopeSelector, Principal};
5384
5385 let dir = cortexkit_test_support::ScratchDir::new("configured-before-spawn");
5386 let stub = |module_id: &str, program: PathBuf| ModuleSpec {
5387 module_id: module_id.to_string(),
5388 program,
5389 args: Vec::new(),
5390 env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
5391 .into_iter()
5392 .map(|key| (key.to_string(), dir.path().display().to_string()))
5393 .chain([("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string())])
5394 .collect(),
5395 reserved: false,
5396 reserved_prefixes: Vec::new(),
5397 protocol: ModuleProtocol::Subc,
5398 overlap: Default::default(),
5399 };
5400 let live = super::terminal_history_tests::fake_aft_stub_path();
5401 let missing = dir.path().join("definitely-missing-module");
5402
5403 let handle = SupervisorHandle::new();
5404 let mut supervisor =
5405 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5406 .with_handle(handle.clone());
5407 let observed = Arc::new(Mutex::new(Vec::<OwnerInSpawnWindow>::new()));
5408 let hook_handle = handle.clone();
5409 let hook_observed = Arc::clone(&observed);
5410 supervisor.test_after_first_spawn = AfterFirstSpawnHook(Some(Arc::new(move |module_id| {
5411 let configured = hook_handle.is_configured(module_id);
5414 let selector = ScopeSelector {
5415 owner: Principal::Reserved {
5416 module_id: module_id.to_string(),
5417 },
5418 scope_ref: "s".to_string(),
5419 scope_epoch: Some(1),
5420 };
5421 let carrier = Principal::Reserved {
5422 module_id: "carrier".to_string(),
5423 };
5424 let admission_refusal = match ScopeTable::new(Vec::<String>::new())
5425 .admit(&carrier, module_id, &selector, configured)
5426 {
5427 Ok(_) => None,
5428 Err(refusal) => Some(refusal.code),
5429 };
5430 hook_observed.lock().unwrap().push(OwnerInSpawnWindow {
5431 module_id: module_id.to_string(),
5432 configured,
5433 on_roster: hook_handle.get(module_id).is_some(),
5434 admission_refusal,
5435 });
5436 })));
5437
5438 let plain = supervisor.spawn(stub("plain", live.clone())).unwrap();
5439 let configured = supervisor
5440 .supervise_configured(stub("configured", live.clone()), true)
5441 .unwrap();
5442 let with_health = supervisor
5443 .supervise_configured_with_health(
5444 stub("with-health", live.clone()),
5445 true,
5446 HealthConfig::default(),
5447 None,
5448 RestartPolicy::default(),
5449 )
5450 .unwrap();
5451 let failed = supervisor
5454 .supervise_configured_with_health(
5455 stub("failed-spawn", missing.clone()),
5456 true,
5457 HealthConfig::default(),
5458 None,
5459 RestartPolicy::default(),
5460 )
5461 .unwrap();
5462 assert!(supervisor.spawn(stub("spawn-error", missing)).is_err());
5465
5466 let expected = [
5467 "plain",
5468 "configured",
5469 "with-health",
5470 "failed-spawn",
5471 "spawn-error",
5472 ]
5473 .into_iter()
5474 .map(|module_id| OwnerInSpawnWindow {
5475 module_id: module_id.to_string(),
5476 configured: true,
5477 on_roster: false,
5478 admission_refusal: Some(error_codes::SCOPE_NOT_SYNCED),
5479 })
5480 .collect::<Vec<_>>();
5481 assert_eq!(*observed.lock().unwrap(), expected);
5482
5483 for module_id in ["plain", "configured", "with-health", "failed-spawn"] {
5484 assert!(
5485 handle.get(module_id).is_some(),
5486 "{module_id} is on the roster"
5487 );
5488 assert!(
5489 handle.is_configured(module_id),
5490 "{module_id} stays configured"
5491 );
5492 }
5493 assert!(handle.get("spawn-error").is_none());
5494 assert!(
5495 !handle.is_configured("spawn-error"),
5496 "a plain spawn that failed must not leave its module marked configured"
5497 );
5498
5499 handle.retire("failed-spawn");
5501 assert!(!handle.is_configured("failed-spawn"));
5502
5503 for module in [plain, configured, with_health] {
5504 module.stop().await.unwrap();
5505 }
5506 drop(failed);
5507 }
5508
5509 fn stale_process_snapshot(state: ModuleState, enabled: bool) -> SharedSnapshot {
5510 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(state, enabled)));
5511 update_snapshot(&snapshot, Some("stale-process-facts"), |snapshot| {
5512 snapshot.process_alive = true;
5513 snapshot.pid = Some(41);
5514 snapshot.spawned_at_ms = Some(42);
5515 snapshot.spawned_from = Some(PathBuf::from("/spawned/module"));
5516 snapshot.spawned_file_identity = Some(SpawnedFileIdentity {
5517 device: 43,
5518 inode: 44,
5519 #[cfg(windows)]
5520 windows_image: None,
5521 });
5522 })
5523 .unwrap();
5524 snapshot
5525 }
5526
5527 fn assert_snapshot_process_facts_cleared(snapshot: &SharedSnapshot) {
5528 let snapshot = lock_snapshot(snapshot).unwrap();
5529 assert!(!snapshot.process_alive);
5530 assert_eq!(snapshot.pid, None);
5531 assert_eq!(snapshot.spawned_at_ms, None);
5532 assert_eq!(snapshot.spawned_from, None);
5533 assert_eq!(snapshot.spawned_file_identity, None);
5534 }
5535
5536 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5537 async fn failed_enable_spawn_clears_preexisting_current_process_facts() {
5538 let supervisor =
5539 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5540 let mut runtime = supervisor.runtime_config();
5541 runtime.test_seed_stale_facts_before_enable_spawn = true;
5542 let snapshot = stale_process_snapshot(ModuleState::Disabled, false);
5543 let mut child = None;
5544 let spec = ModuleSpec {
5545 module_id: "failed-enable-clears-facts".to_string(),
5546 program: PathBuf::from("/definitely/missing/failed-enable-module"),
5547 args: Vec::new(),
5548 env: Vec::new(),
5549 reserved: false,
5550 reserved_prefixes: Vec::new(),
5551 protocol: ModuleProtocol::Subc,
5552 overlap: Default::default(),
5553 };
5554
5555 let result = set_child_enabled(
5556 &spec,
5557 &runtime,
5558 &supervisor.registry,
5559 &supervisor.process_liveness,
5560 &snapshot,
5561 &mut child,
5562 true,
5563 )
5564 .await;
5565
5566 assert!(matches!(result, Err(SuperviseError::Spawn { .. })));
5567 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
5568 assert_snapshot_process_facts_cleared(&snapshot);
5569 }
5570
5571 #[tokio::test]
5572 async fn start_revives_stranded_restarting_but_not_pending_backoff() {
5573 let supervisor =
5574 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5575 let runtime = supervisor.runtime_config();
5576 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(
5577 ModuleState::Restarting,
5578 true,
5579 )));
5580 let spec = ModuleSpec {
5581 module_id: "start-stranded-restarting".to_string(),
5582 program: super::terminal_history_tests::fake_aft_stub_path(),
5583 args: Vec::new(),
5584 env: vec![("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string())],
5585 reserved: false,
5586 reserved_prefixes: Vec::new(),
5587 protocol: ModuleProtocol::None,
5588 overlap: Default::default(),
5589 };
5590 let mut child = None;
5591 lock_snapshot(&snapshot).unwrap().respawn_pending = true;
5592 assert!(!super::set_child_enabled(
5593 &spec,
5594 &runtime,
5595 &Registry::default(),
5596 &supervisor.process_liveness,
5597 &snapshot,
5598 &mut child,
5599 true
5600 )
5601 .await
5602 .unwrap());
5603 assert!(child.is_none());
5604 lock_snapshot(&snapshot).unwrap().respawn_pending = false;
5605 assert!(super::set_child_enabled(
5606 &spec,
5607 &runtime,
5608 &Registry::default(),
5609 &supervisor.process_liveness,
5610 &snapshot,
5611 &mut child,
5612 true
5613 )
5614 .await
5615 .unwrap());
5616 assert_eq!(
5617 lock_snapshot(&snapshot).unwrap().state,
5618 ModuleState::Running
5619 );
5620 let mut child = child.unwrap();
5621 child.start_kill().unwrap();
5622 child.wait().await.unwrap();
5623 }
5624
5625 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5626 async fn failed_reload_spawn_clears_current_process_facts() {
5627 let supervisor =
5628 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5629 let mut runtime = supervisor.runtime_config();
5630 runtime.restart_policy = RestartPolicy::new(0, Duration::ZERO);
5631 let snapshot = stale_process_snapshot(ModuleState::Running, true);
5632 let mut child = None;
5633 let spec = ModuleSpec {
5634 module_id: "failed-reload-clears-facts".to_string(),
5635 program: PathBuf::from("/unused/failed-reload-module"),
5636 args: Vec::new(),
5637 env: Vec::new(),
5638 reserved: false,
5639 reserved_prefixes: Vec::new(),
5640 protocol: ModuleProtocol::Subc,
5641 overlap: Default::default(),
5642 };
5643
5644 let result = handle_reload_spawn_failure(
5645 &spec,
5646 &runtime,
5647 &supervisor.process_liveness,
5648 &snapshot,
5649 &mut child,
5650 "forced reload spawn failure".to_string(),
5651 )
5652 .await;
5653
5654 assert!(matches!(result, Err(SuperviseError::ReloadFailed { .. })));
5655 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
5656 assert_snapshot_process_facts_cleared(&snapshot);
5657 }
5658
5659 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5660 async fn dropping_a_module_with_an_active_monitor_clears_current_process_facts() {
5661 let supervisor =
5662 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5663 let snapshot = stale_process_snapshot(ModuleState::Running, true);
5664 let module = supervisor.supervised_module(
5665 ModuleSpec {
5666 module_id: "drop-clears-facts".to_string(),
5667 program: PathBuf::from("/unused/drop-module"),
5668 args: Vec::new(),
5669 env: Vec::new(),
5670 reserved: false,
5671 reserved_prefixes: Vec::new(),
5672 protocol: ModuleProtocol::Subc,
5673 overlap: Default::default(),
5674 },
5675 supervisor.runtime_config(),
5676 Arc::clone(&snapshot),
5677 None,
5678 );
5679 assert!(!module
5680 .inner
5681 .monitor
5682 .lock()
5683 .unwrap()
5684 .as_ref()
5685 .unwrap()
5686 .is_finished());
5687
5688 drop(module);
5689
5690 assert_eq!(
5691 lock_snapshot(&snapshot).unwrap().state,
5692 ModuleState::Stopped
5693 );
5694 assert_snapshot_process_facts_cleared(&snapshot);
5695 }
5696
5697 #[cfg(unix)]
5698 #[tokio::test]
5699 async fn rescan_preserves_running_protocol_until_respawn() {
5700 let dir = cortexkit_test_support::ScratchDir::new("rescan-protocol");
5701 let initial = ModuleSpec {
5702 module_id: "rescan-protocol".into(),
5703 program: PathBuf::from("/bin/sleep"),
5704 args: vec!["60".into()],
5705 env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
5706 .into_iter()
5707 .map(|key| (key.into(), dir.path().to_string_lossy().into_owned()))
5708 .collect(),
5709 reserved: false,
5710 reserved_prefixes: vec![],
5711 protocol: ModuleProtocol::None,
5712 overlap: Default::default(),
5713 };
5714 let supervisor =
5715 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5716 let module = supervisor.spawn(initial.clone()).unwrap();
5717 assert!(module.status().unwrap().live);
5718 let mut next = initial;
5719 next.protocol = ModuleProtocol::Subc;
5720 module
5721 .update_configuration(next.clone(), HealthConfig::default(), None)
5722 .await
5723 .unwrap();
5724 assert!(
5725 module.status().unwrap().live,
5726 "rescan must not require HELLO from the old non-wire process"
5727 );
5728 let runtime = supervisor.runtime_config();
5729 let action = on_child_exit(
5730 &next,
5731 RestartPolicy::default(),
5732 &supervisor.registry,
5733 &module.inner.snapshot,
5734 &runtime.terminal_ring,
5735 &runtime.spawn_events,
5736 &runtime.child_roster,
5737 ExitReport {
5738 kind: ExitKind::Clean,
5739 code: Some(0),
5740 signal: None,
5741 at_ms: unix_ms_now(),
5742 },
5743 )
5744 .await;
5745 assert!(
5746 matches!(action, NextAction::Restart { .. }),
5747 "the old non-wire process's clean exit must restart"
5748 );
5749 module.drain().await.unwrap();
5750 }
5751
5752 #[cfg(unix)]
5753 #[tokio::test]
5754 async fn orphan_identity_matches_path_names_and_shebang_interpreters() {
5755 use std::os::unix::fs::PermissionsExt;
5756 let dir = cortexkit_test_support::ScratchDir::new("spawn-image-identity");
5757 let script = dir.join("module.sh");
5758 std::fs::write(&script, "#!/bin/sh\nwhile :; do sleep 0.1; done\n").unwrap();
5759 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o700)).unwrap();
5760 let record_path = dir.join("live-children.json");
5761 let supervisor =
5762 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5763 .with_live_children_record(&record_path);
5764 for (program, args) in [
5765 (PathBuf::from("sleep"), vec!["60".into()]),
5766 (script, vec![]),
5767 ] {
5768 let spec = ModuleSpec {
5769 module_id: "image-identity".into(),
5770 program: program.clone(),
5771 args,
5772 env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
5773 .into_iter()
5774 .map(|key| (key.into(), dir.to_string_lossy().into_owned()))
5775 .collect(),
5776 reserved: false,
5777 reserved_prefixes: vec![],
5778 protocol: ModuleProtocol::None,
5779 overlap: Default::default(),
5780 };
5781 let module = supervisor.spawn(spec).unwrap();
5782 #[cfg(target_os = "macos")]
5783 {
5784 let deadline = Instant::now() + Duration::from_secs(5);
5787 while crate::live_children::read_record(&record_path)
5788 .unwrap()
5789 .iter()
5790 .all(|entry| entry.executable.is_none())
5791 {
5792 assert!(Instant::now() < deadline, "module image was not confirmed");
5793 tokio::time::sleep(Duration::from_millis(5)).await;
5794 }
5795 }
5796 let entry = crate::live_children::read_record(&record_path)
5797 .unwrap()
5798 .pop()
5799 .unwrap();
5800 let observed = subc_os::Process::open(entry.pid)
5801 .unwrap()
5802 .unwrap()
5803 .observe()
5804 .unwrap();
5805 let verdict = crate::live_children::identity_verdict(&entry, entry.pid, &observed);
5806 module.drain().await.unwrap();
5807 assert_eq!(
5808 verdict,
5809 crate::live_children::IdentityVerdict::Matches,
5810 "program {program:?}: recorded {entry:?}, observed {observed:?}"
5811 );
5812 }
5813 }
5814
5815 #[cfg(unix)]
5816 fn http_fixture(
5817 dir: &std::path::Path,
5818 url: &str,
5819 threshold: u32,
5820 ) -> crate::daemon_config::ConfiguredModule {
5821 let path = dir.join("subc.jsonc");
5822 std::fs::write(&path, serde_json::json!({"version": 1, "modules": {"http-process": {
5823 "program": "/bin/sleep", "args": ["60"], "protocol": "none",
5824 "env": {"XDG_DATA_HOME":dir,"XDG_RUNTIME_DIR":dir,"XDG_CONFIG_HOME":dir},
5825 "health": {"http":url,"cadence_ms":20,"deadline_ms":200,"failure_threshold":threshold},
5826 "restart": {"backoff_ms":1,"max_backoff_ms":1}, "drain_timeout_ms":10
5827 }}}).to_string()).unwrap();
5828 crate::daemon_config::load(&path)
5829 .unwrap()
5830 .unwrap()
5831 .modules
5832 .pop()
5833 .unwrap()
5834 }
5835
5836 #[cfg(unix)]
5837 async fn wait_http_health(module: &SupervisedModule, status: SupervisorHealthStatus) {
5838 timeout(Duration::from_secs(5), async {
5839 loop {
5840 if module.status().unwrap().health.status == status {
5841 break;
5842 }
5843 sleep(Duration::from_millis(5)).await;
5844 }
5845 })
5846 .await
5847 .unwrap_or_else(|_| {
5848 panic!(
5849 "expected {status:?}, got {:?}",
5850 module.status().unwrap().health
5851 )
5852 });
5853 }
5854
5855 #[cfg(unix)]
5856 #[tokio::test]
5857 async fn http_health_status_flips_ok_failing_ok() {
5858 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5859 let dir = cortexkit_test_support::ScratchDir::new("http-status-flips");
5860 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5861 let status = Arc::new(std::sync::atomic::AtomicU16::new(200));
5862 let serving_status = status.clone();
5863 let url = format!("http://{}/healthz", listener.local_addr().unwrap());
5864 let server = tokio::spawn(async move {
5865 loop {
5866 let (mut stream, _) = listener.accept().await.unwrap();
5867 let mut request = [0u8; 2048];
5868 let count = stream.read(&mut request).await.unwrap();
5869 assert!(count > 0, "a probe must send an HTTP request");
5870 let code = serving_status.load(std::sync::atomic::Ordering::SeqCst);
5871 let body = if code == 200 {
5872 "ready"
5873 } else {
5874 "{\"status\":\"unavailable\",\"error\":\"scratch failure\"}"
5875 };
5876 let response = format!("HTTP/1.1 {code} scratch\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());
5877 let _ = stream.write_all(response.as_bytes()).await;
5878 }
5879 });
5880 let configured = http_fixture(&dir, &url, 1000);
5881 let module =
5882 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5883 .supervise_configured_with_health(
5884 configured.module_spec(),
5885 true,
5886 configured.health,
5887 configured.drain_timeout_ms,
5888 configured.restart,
5889 )
5890 .unwrap();
5891 wait_http_health(&module, SupervisorHealthStatus::Ok).await;
5892 status.store(503, std::sync::atomic::Ordering::SeqCst);
5893 wait_http_health(&module, SupervisorHealthStatus::Failing).await;
5894 assert!(module
5895 .status()
5896 .unwrap()
5897 .health
5898 .detail
5899 .unwrap()
5900 .contains("scratch failure"));
5901 status.store(200, std::sync::atomic::Ordering::SeqCst);
5902 wait_http_health(&module, SupervisorHealthStatus::Ok).await;
5903 assert_eq!(module.status().unwrap().health.consecutive_failures, 0);
5904 let before = module.status().unwrap();
5905 let (spec, mut health) = module.configuration().unwrap();
5906 health.http = None;
5907 module
5908 .update_configuration(spec.clone(), health.clone(), Some(10))
5909 .await
5910 .unwrap();
5911 wait_http_health(&module, SupervisorHealthStatus::Unknown).await;
5912 health.http = Some(url);
5913 module
5914 .update_configuration(spec, health, Some(10))
5915 .await
5916 .unwrap();
5917 wait_http_health(&module, SupervisorHealthStatus::Ok).await;
5918 assert_eq!(
5919 module.status().unwrap().pid,
5920 before.pid,
5921 "changing a probe must apply live, not restart its process"
5922 );
5923 let (spec, mut health) = module.configuration().unwrap();
5924 health.failure_threshold = 2;
5925 module
5926 .update_configuration(spec, health, Some(10))
5927 .await
5928 .unwrap();
5929 status.store(503, std::sync::atomic::Ordering::SeqCst);
5930 timeout(Duration::from_secs(5), async {
5931 while module.status().unwrap().spawn_generation == before.spawn_generation {
5932 sleep(Duration::from_millis(5)).await;
5933 }
5934 })
5935 .await
5936 .expect("sustained HTTP 503 responses must trigger the health restart policy");
5937 module.drain().await.unwrap();
5938 server.abort();
5939 }
5940
5941 #[cfg(unix)]
5942 #[tokio::test]
5943 async fn http_health_no_listener_restarts_after_consecutive_failures() {
5944 let dir = cortexkit_test_support::ScratchDir::new("http-refused");
5945 let unused = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5946 let url = format!("http://{}/healthz", unused.local_addr().unwrap());
5947 drop(unused);
5948 let configured = http_fixture(&dir, &url, 2);
5949 let module =
5950 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5951 .supervise_configured_with_health(
5952 configured.module_spec(),
5953 true,
5954 configured.health,
5955 configured.drain_timeout_ms,
5956 configured.restart,
5957 )
5958 .unwrap();
5959 let before = module.status().unwrap().spawn_generation;
5960 timeout(Duration::from_secs(5), async {
5961 loop {
5962 let status = module.status().unwrap();
5963 if status.spawn_generation > before {
5964 assert!(status.lifetime_restarts > 0);
5965 break;
5966 }
5967 sleep(Duration::from_millis(5)).await;
5968 }
5969 })
5970 .await
5971 .expect("sustained HTTP refusal must trigger the health restart policy");
5972 module.drain().await.unwrap();
5973 }
5974
5975 #[cfg(unix)]
5976 #[tokio::test]
5977 async fn http_health_timeout_honours_deadline() {
5978 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5979 let url = format!("http://{}/healthz", listener.local_addr().unwrap());
5980 let server = tokio::spawn(async move {
5981 let _held = listener.accept().await.unwrap();
5982 std::future::pending::<()>().await;
5983 });
5984 let error = timeout(
5985 Duration::from_secs(1),
5986 probe_http_health(&url, Duration::from_millis(10)),
5987 )
5988 .await
5989 .expect("the probe must enforce its own deadline")
5990 .unwrap_err();
5991 server.abort();
5992 assert!(error.to_string().contains("timed out"));
5993 }
5994
5995 #[cfg(unix)]
5996 #[tokio::test]
5997 async fn http_health_localhost_reaches_an_ipv6_only_listener() {
5998 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5999 let listener = tokio::net::TcpListener::bind("[::1]:0").await.unwrap();
6000 let url = format!(
6001 "http://localhost:{}/healthz",
6002 listener.local_addr().unwrap().port()
6003 );
6004 let server = tokio::spawn(async move {
6005 let (mut stream, _) = listener.accept().await.unwrap();
6006 let mut request = [0u8; 2048];
6007 assert!(stream.read(&mut request).await.unwrap() > 0);
6008 stream
6009 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
6010 .await
6011 .unwrap();
6012 });
6013 assert_eq!(
6018 probe_http_health(&url, Duration::from_secs(10))
6019 .await
6020 .unwrap()
6021 .status,
6022 HealthStatus::Ok
6023 );
6024 server.await.unwrap();
6025 }
6026
6027 #[cfg(unix)]
6028 #[tokio::test]
6029 async fn http_health_timeout_keeps_partial_status_and_body() {
6030 use tokio::io::{AsyncReadExt, AsyncWriteExt};
6031 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6032 let url = format!("http://{}/healthz", listener.local_addr().unwrap());
6033 let server = tokio::spawn(async move {
6034 let (mut stream, _) = listener.accept().await.unwrap();
6035 let mut request = [0u8; 2048];
6036 assert!(stream.read(&mut request).await.unwrap() > 0);
6037 stream
6038 .write_all(
6039 b"HTTP/1.1 503 Unavailable\r\nContent-Length: 100\r\n\r\npartial diagnostic",
6040 )
6041 .await
6042 .unwrap();
6043 std::future::pending::<()>().await;
6044 });
6045 let error = probe_http_health(&url, Duration::from_secs(1))
6046 .await
6047 .unwrap_err()
6048 .to_string();
6049 server.abort();
6050 assert!(
6051 error.contains("timed out")
6052 && error.contains("503 Unavailable")
6053 && error.contains("partial diagnostic"),
6054 "{error}"
6055 );
6056 }
6057
6058 #[cfg(unix)]
6059 #[tokio::test]
6060 async fn http_health_diagnostic_body_is_bounded_to_200_bytes() {
6061 use tokio::io::{AsyncReadExt, AsyncWriteExt};
6062 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6063 let url = format!("http://{}/healthz", listener.local_addr().unwrap());
6064 let server = tokio::spawn(async move {
6065 let (mut stream, _) = listener.accept().await.unwrap();
6066 let mut request = [0u8; 2048];
6067 assert!(stream.read(&mut request).await.unwrap() > 0);
6068 let body = format!("{}not-in-diagnostic", "x".repeat(400));
6069 let response = format!(
6070 "HTTP/1.1 503 Unavailable\r\nContent-Length: {}\r\n\r\n{body}",
6071 body.len()
6072 );
6073 stream.write_all(response.as_bytes()).await.unwrap();
6074 });
6075 let error = probe_http_health(&url, Duration::from_secs(1))
6076 .await
6077 .unwrap_err()
6078 .to_string();
6079 server.await.unwrap();
6080 assert!(error.ends_with(&"x".repeat(200)), "{error}");
6081 assert_eq!(error.split(": ").last().unwrap().len(), 200);
6082 assert!(!error.contains("not-in-diagnostic"));
6083 }
6084
6085 #[cfg(unix)]
6086 #[tokio::test]
6087 async fn http_health_real_nats_server_monitoring() {
6088 if std::process::Command::new("nats-server")
6089 .arg("--version")
6090 .env("XDG_DATA_HOME", std::env::temp_dir())
6091 .env("XDG_RUNTIME_DIR", std::env::temp_dir())
6092 .env("XDG_CONFIG_HOME", std::env::temp_dir())
6093 .output()
6094 .is_err()
6095 {
6096 eprintln!(
6097 "skipped: http_health_real_nats_server_monitoring (nats-server is not on PATH)"
6098 );
6099 return;
6100 }
6101 let dir = cortexkit_test_support::ScratchDir::new("nats-http-monitoring");
6102 let monitor = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6103 let port = monitor.local_addr().unwrap().port();
6104 let client = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6105 let client_port = client.local_addr().unwrap().port();
6106 let config = dir.join("server.conf");
6107 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();
6108 let mut configured = http_fixture(&dir, &format!("http://127.0.0.1:{port}/healthz"), 1000);
6109 configured.program = PathBuf::from("nats-server");
6110 configured.args = vec!["-c".into(), config.to_string_lossy().into_owned()];
6111 drop(monitor);
6112 drop(client);
6113 let module =
6114 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
6115 .supervise_configured_with_health(
6116 configured.module_spec(),
6117 true,
6118 configured.health,
6119 configured.drain_timeout_ms,
6120 configured.restart,
6121 )
6122 .unwrap();
6123 wait_http_health(&module, SupervisorHealthStatus::Ok).await;
6124 module.drain().await.unwrap();
6125 }
6126
6127 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
6128 async fn configuration_update_does_not_replace_captured_running_process_facts() {
6129 let supervisor =
6130 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
6131 let snapshot = stale_process_snapshot(ModuleState::Running, true);
6132 let initial = ModuleSpec {
6133 module_id: "rescan-preserves-spawn-facts".to_string(),
6134 program: PathBuf::from("/spawned/module"),
6135 args: Vec::new(),
6136 env: Vec::new(),
6137 reserved: false,
6138 reserved_prefixes: Vec::new(),
6139 protocol: ModuleProtocol::Subc,
6140 overlap: Default::default(),
6141 };
6142 let module = supervisor.supervised_module(
6143 initial.clone(),
6144 supervisor.runtime_config(),
6145 snapshot,
6146 None,
6147 );
6148 let before = module.status().unwrap();
6149 let mut replacement = initial;
6150 replacement.program = PathBuf::from("/rescanned/replacement-module");
6151
6152 module
6153 .update_configuration(replacement, HealthConfig::default(), None)
6154 .await
6155 .unwrap();
6156
6157 let after = module.status().unwrap();
6158 assert_eq!(after.pid, before.pid);
6159 assert_eq!(after.spawned_at_ms, before.spawned_at_ms);
6160 assert_eq!(after.spawned_from, before.spawned_from);
6161 drop(module);
6162 }
6163}
6164
6165fn unix_ms_now() -> u64 {
6166 SystemTime::now()
6167 .duration_since(UNIX_EPOCH)
6168 .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64)
6169 .unwrap_or(0)
6170}
6171
6172async fn supervise_loop(
6173 mut spec: ModuleSpec,
6174 mut runtime: SupervisorRuntimeConfig,
6175 registry: Arc<Registry>,
6176 process_liveness: Arc<SupervisorProcessLiveness>,
6177 snapshot: SharedSnapshot,
6178 mut child: Option<SupervisedChild>,
6179 mut commands: mpsc::Receiver<SupervisorCommand>,
6180) {
6181 let mut health_probe = HealthProbeRuntime::default();
6182 let mut registration_changes = match registry.subscribe_module_changes(&spec.module_id) {
6186 Ok(changes) => Some(changes),
6187 Err(err) => {
6188 warn!(module_id = %spec.module_id, error = %err, "health prober could not subscribe to registry");
6191 None
6192 }
6193 };
6194 let mut pending_respawn: Option<PendingRespawn> = None;
6198 let mut requeued: VecDeque<SupervisorCommand> = VecDeque::new();
6201 #[cfg(target_os = "linux")]
6202 let mut previous_instance_recheck = tokio::time::interval_at(
6203 Instant::now() + Duration::from_secs(5),
6204 Duration::from_secs(5),
6205 );
6206 #[cfg(target_os = "linux")]
6207 previous_instance_recheck.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
6208 loop {
6209 #[cfg(test)]
6210 {
6211 lock_snapshot(&snapshot).unwrap().actor_select = None;
6212 }
6213 #[cfg(target_os = "macos")]
6214 if let Some(active) = child.as_mut() {
6215 active.confirm_privacy_exec().await;
6216 }
6217 if let Some(scheduled) = runtime
6218 .scheduled_respawn
6219 .lock()
6220 .unwrap_or_else(|p| p.into_inner())
6221 .take()
6222 {
6223 pending_respawn = Some(scheduled);
6224 }
6225 if pending_respawn.is_some() && (child.is_some() || !respawn_still_pending(&snapshot)) {
6226 pending_respawn = None;
6227 cancel_deferred_reload(
6228 &runtime,
6229 &spec.module_id,
6230 "respawn cancelled by a supervisor command",
6231 );
6232 }
6233 if child.is_none()
6234 && pending_respawn.is_none()
6235 && lock_snapshot(&snapshot).is_ok_and(|state| state.waiting_for_previous_pid.is_none())
6236 {
6237 cancel_deferred_reload(
6238 &runtime,
6239 &spec.module_id,
6240 "respawn cancelled before a replacement was spawned",
6241 );
6242 let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| {
6243 state.respawn_pending = false;
6244 state.coalesced_restart_pending = false;
6245 if matches!(
6246 state.state,
6247 ModuleState::Restarting
6248 | ModuleState::Starting
6249 | ModuleState::Draining
6250 | ModuleState::Unresponsive
6251 ) {
6252 error!(module_id = %spec.module_id, state = ?state.state,
6253 "supervision operation ended without a child or pending respawn; marking failed so start can retry");
6254 state.state = ModuleState::Failed;
6255 clear_current_process_facts(state);
6256 }
6257 });
6258 }
6259 if let Some(command) = requeued.pop_front() {
6260 if !handle_supervisor_command(
6261 command,
6262 &mut spec,
6263 &mut runtime,
6264 ®istry,
6265 &process_liveness,
6266 &snapshot,
6267 &mut child,
6268 &mut commands,
6269 &mut requeued,
6270 )
6271 .await
6272 {
6273 return;
6274 }
6275 if child.is_some() || !respawn_still_pending(&snapshot) {
6276 pending_respawn = None;
6277 let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| {
6278 state.respawn_pending = false
6279 });
6280 }
6281 continue;
6282 }
6283 if child.is_some() {
6284 #[cfg(test)]
6285 {
6286 lock_snapshot(&snapshot).unwrap().actor_turns += 1;
6287 }
6288 health_probe.refresh_registration(&spec, &runtime, ®istry, &snapshot);
6289 let wake_after = health_probe.wake_after();
6290 let probe_sleep = async move {
6291 match wake_after {
6292 Some(delay) => sleep(delay).await,
6293 None => std::future::pending().await,
6294 }
6295 };
6296 tokio::pin!(probe_sleep);
6297 let wire_child = running_protocol(&spec, &snapshot) == ModuleProtocol::Subc;
6302 #[cfg(test)]
6303 {
6304 let registration_pending = wire_child
6305 && registration_changes
6306 .as_ref()
6307 .is_some_and(|changes| changes.has_changed().unwrap());
6308 let mut state = lock_snapshot(&snapshot).unwrap();
6309 state.actor_select = (!registration_pending).then_some(ActorSelectCheckpoint {
6310 generation: state.spawn_generation,
6311 turn: state.actor_turns,
6312 registered_connection: health_probe.registered_connection,
6313 next_probe_at: health_probe.next_probe_at,
6314 wake_after,
6315 });
6316 }
6317 let active_child = child.as_mut().expect("child checked above");
6318 tokio::select! {
6319 wait_result = active_child.wait() => {
6320 #[cfg(test)]
6321 { lock_snapshot(&snapshot).unwrap().actor_select = None; }
6322 let exit_report = match wait_result {
6331 Ok(status) => classify_reaped_child_exit(&snapshot, active_child, &status),
6332 Err(err) => {
6333 active_child.drain_stderr(&spec.module_id).await;
6334 fail_after_wait_error(
6335 &spec.module_id,
6336 &snapshot,
6337 &runtime.terminal_ring,
6338 &runtime.spawn_events,
6339 format!("failed to wait for supervised module: {err}"),
6340 );
6341 untrack_if_registration_released(
6342 &process_liveness,
6343 ®istry,
6344 &spec.module_id,
6345 &snapshot,
6346 );
6347 error!(module_id = %spec.module_id, error = %err, "failed to wait for supervised module");
6348 child = None;
6349 continue;
6350 }
6351 };
6352 active_child.drain_stderr(&spec.module_id).await;
6353
6354 let next = on_child_exit(
6355 &spec,
6356 runtime.restart_policy,
6357 ®istry,
6358 &snapshot,
6359 &runtime.terminal_ring,
6360 &runtime.spawn_events,
6361 &runtime.child_roster,
6362 exit_report,
6363 ).await;
6364 runtime.start_requester = None;
6365 runtime.pending_activity_restart = false;
6366 active_child.release_roster();
6369 match next {
6370 NextAction::Stop { registration_released } => {
6371 if registration_released {
6372 process_liveness.untrack_if_current(&spec.module_id, &snapshot);
6373 }
6374 child = None;
6375 }
6376 NextAction::Restart { schedule } => {
6377 let delay = schedule.map_or(
6378 runtime.restart_policy.delay_for_restart(0),
6379 |schedule| schedule.delay,
6380 );
6381 if let Some(schedule) = schedule {
6382 log_crash_respawn(&spec.module_id, schedule);
6383 }
6384 child = None;
6392 pending_respawn = Some(PendingRespawn { deadline: Instant::now() + delay, kind: RespawnKind::Spawn });
6393 let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| state.respawn_pending = true);
6394 }
6395 }
6396 }
6397 command = commands.recv() => {
6398 #[cfg(test)]
6399 { lock_snapshot(&snapshot).unwrap().actor_select = None; }
6400 let Some(command) = command else {
6401 return;
6402 };
6403 if !handle_supervisor_command(
6404 command,
6405 &mut spec,
6406 &mut runtime,
6407 ®istry,
6408 &process_liveness,
6409 &snapshot,
6410 &mut child,
6411 &mut commands,
6412 &mut requeued,
6413 ).await {
6414 return;
6415 }
6416 }
6417 _ = async {
6418 match registration_changes.as_mut() {
6419 Some(changes) => { let _ = changes.changed().await; }
6420 None => std::future::pending().await,
6421 }
6422 }, if wire_child => {
6423 #[cfg(test)]
6424 { lock_snapshot(&snapshot).unwrap().actor_select = None; }
6425 }
6426 _ = &mut probe_sleep => {
6427 #[cfg(test)]
6428 { lock_snapshot(&snapshot).unwrap().actor_select = None; }
6429 if health_probe.due() {
6430 run_health_probe_cycle(
6431 &spec,
6432 &runtime,
6433 ®istry,
6434 &process_liveness,
6435 &snapshot,
6436 &mut child,
6437 ).await;
6438 if child.is_none() {
6439 runtime.start_requester = None;
6440 runtime.pending_activity_restart = false;
6441 }
6442 if child.is_some() {
6443 health_probe.schedule_next(&spec, runtime.health.cadence);
6444 }
6445 }
6446 }
6447 }
6448 } else if let Some(pending) = pending_respawn {
6449 tokio::select! {
6450 _ = sleep_until(pending.deadline) => {
6451 pending_respawn = None;
6452 let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| state.respawn_pending = false);
6453 if !respawn_still_pending(&snapshot) {
6457 continue;
6458 }
6459 if runtime.child_roster.is_closed() {
6464 let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| {
6465 state.state = ModuleState::Stopped;
6466 });
6467 debug!(module_id = %spec.module_id, "crash respawn cancelled by daemon shutdown");
6468 continue;
6469 }
6470 if let Err(err) = release_dead_registration(
6471 ®istry,
6472 runtime.forwarding.as_deref(),
6473 &snapshot,
6474 &spec.module_id,
6475 ).await {
6476 fail_snapshot(&snapshot, Some(&spec.module_id), None);
6477 error!(module_id = %spec.module_id, error = %err, "registration did not release before restart");
6478 continue;
6479 }
6480
6481 if matches!(pending.kind, RespawnKind::Reload) {
6482 let reply = runtime.deferred_reload_reply.lock().unwrap_or_else(|p| p.into_inner()).take();
6483 let result = finish_reload_child(&spec, &runtime, ®istry, &process_liveness, &snapshot, &mut child).await;
6484 if result.is_ok() && runtime.pending_activity_restart {
6485 runtime.record_activity_transition("subc.module_restarted", &spec.module_id, runtime.start_requester.clone());
6486 }
6487 runtime.pending_activity_restart = false;
6488 if let Some(reply) = reply { let _ = reply.send(result); }
6489 continue;
6490 }
6491 process_liveness.track(spec.module_id.clone(), Arc::clone(&snapshot));
6492 match spawn_and_mark_running(&spec, &runtime, &snapshot) {
6493 Ok(next_child) => {
6494 child = Some(next_child);
6495 if runtime.pending_activity_restart {
6496 runtime.record_activity_transition("subc.module_restarted", &spec.module_id, runtime.start_requester.clone());
6497 runtime.pending_activity_restart = false;
6498 }
6499 debug!(module_id = %spec.module_id, "supervised module restarted after crash");
6500 }
6501 Err(err) => {
6502 fail_snapshot(&snapshot, Some(&spec.module_id), None);
6503 process_liveness.untrack_if_current(&spec.module_id, &snapshot);
6504 error!(module_id = %spec.module_id, error = %err, "failed to restart supervised module");
6505 }
6506 }
6507 }
6508 command = commands.recv() => {
6509 let Some(command) = command else {
6510 return;
6511 };
6512 if !handle_supervisor_command(
6513 command,
6514 &mut spec,
6515 &mut runtime,
6516 ®istry,
6517 &process_liveness,
6518 &snapshot,
6519 &mut child,
6520 &mut commands,
6521 &mut requeued,
6522 ).await {
6523 return;
6524 }
6525 if child.is_some() || !respawn_still_pending(&snapshot) {
6530 pending_respawn = None;
6531 let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| state.respawn_pending = false);
6532 }
6533 }
6534 }
6535 } else {
6536 #[cfg(target_os = "linux")]
6537 let command = if lock_snapshot(&snapshot)
6538 .is_ok_and(|state| state.waiting_for_previous_pid.is_some())
6539 {
6540 tokio::select! {
6541 command = commands.recv() => command,
6542 _ = previous_instance_recheck.tick() => {
6543 if runtime.child_roster.previous_instance_pid(&spec.module_id).is_none() {
6544 let enabled = lock_snapshot(&snapshot).is_ok_and(|state| state.enabled);
6545 if enabled && !runtime.child_roster.is_closed() {
6546 process_liveness.track(spec.module_id.clone(), Arc::clone(&snapshot));
6547 match spawn_and_mark_running(&spec, &runtime, &snapshot) {
6548 Ok(next_child) => child = Some(next_child),
6549 Err(error) => {
6550 fail_snapshot(&snapshot, Some(&spec.module_id), None);
6551 error!(module_id = %spec.module_id, %error, "failed to start module after its previous instance exited");
6552 }
6553 }
6554 } else {
6555 let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| {
6556 state.waiting_for_previous_pid = None;
6557 });
6558 }
6559 }
6560 continue;
6561 }
6562 }
6563 } else {
6564 commands.recv().await
6565 };
6566 #[cfg(not(target_os = "linux"))]
6567 let command = commands.recv().await;
6568 let Some(command) = command else {
6569 return;
6570 };
6571 if !handle_supervisor_command(
6572 command,
6573 &mut spec,
6574 &mut runtime,
6575 ®istry,
6576 &process_liveness,
6577 &snapshot,
6578 &mut child,
6579 &mut commands,
6580 &mut requeued,
6581 )
6582 .await
6583 {
6584 return;
6585 }
6586 }
6587 }
6588}
6589
6590fn log_crash_respawn(module_id: &str, schedule: CrashRestartSchedule) {
6591 info!(
6592 module_id,
6593 restart_in_window = schedule.restart_in_window,
6594 delay_ms = schedule.delay.as_millis() as u64,
6595 "respawning after crash"
6596 );
6597}
6598
6599fn respawn_still_pending(snapshot: &SharedSnapshot) -> bool {
6605 matches!(
6606 lock_snapshot(snapshot),
6607 Ok(state) if state.enabled && state.state == ModuleState::Restarting
6608 )
6609}
6610
6611enum NextAction {
6612 Stop {
6613 registration_released: bool,
6614 },
6615 Restart {
6616 schedule: Option<CrashRestartSchedule>,
6617 },
6618}
6619
6620#[allow(clippy::too_many_arguments)]
6621async fn handle_supervisor_command(
6622 command: SupervisorCommand,
6623 spec: &mut ModuleSpec,
6624 runtime: &mut SupervisorRuntimeConfig,
6625 registry: &Arc<Registry>,
6626 process_liveness: &SupervisorProcessLiveness,
6627 snapshot: &SharedSnapshot,
6628 child: &mut Option<SupervisedChild>,
6629 commands: &mut mpsc::Receiver<SupervisorCommand>,
6630 requeued: &mut VecDeque<SupervisorCommand>,
6631) -> bool {
6632 #[cfg(target_os = "linux")]
6633 let command = if let Some(pid) = runtime.child_roster.previous_instance_pid(&spec.module_id) {
6634 match command {
6635 SupervisorCommand::Restart { reply, .. }
6636 | SupervisorCommand::Reload { reply, .. }
6637 | SupervisorCommand::Swap { reply, .. } => {
6638 let _ = reply.send(Err(previous_instance_error(spec, pid)));
6639 return true;
6640 }
6641 SupervisorCommand::SetEnabled {
6642 enabled: true,
6643 reply,
6644 ..
6645 } => {
6646 let _ = reply.send(Err(previous_instance_error(spec, pid)));
6647 return true;
6648 }
6649 other => other,
6650 }
6651 } else {
6652 command
6653 };
6654 match command {
6655 SupervisorCommand::Drain { requester, reply } => {
6656 let result = drain_optional_child(
6659 &spec.module_id,
6660 spec.protocol,
6661 StopNotice::NotSent,
6662 registry,
6663 runtime.forwarding.as_deref(),
6664 snapshot,
6665 &runtime.terminal_ring,
6666 &runtime.spawn_events,
6667 child,
6668 runtime.drain_timeout,
6669 ModuleState::Stopped,
6670 None,
6671 )
6672 .await;
6673 let registration_released = result.is_ok();
6674 if registration_released {
6675 runtime.record_activity_transition(
6676 "subc.module_stopped",
6677 &spec.module_id,
6678 requester,
6679 );
6680 }
6681 let _ = reply.send(result);
6682 if registration_released {
6683 process_liveness.untrack_if_current(&spec.module_id, snapshot);
6684 }
6685 false
6686 }
6687 SupervisorCommand::Retire { requester, reply } => {
6688 let result = async {
6689 let stop_notice = begin_forwarding_drain_if_configured(
6690 spec,
6691 runtime,
6692 registry,
6693 snapshot,
6694 None,
6695 RouteCloseReason::Disable,
6696 )
6697 .await?;
6698 drain_optional_child(
6699 &spec.module_id,
6700 spec.protocol,
6701 stop_notice,
6702 registry,
6703 runtime.forwarding.as_deref(),
6704 snapshot,
6705 &runtime.terminal_ring,
6706 &runtime.spawn_events,
6707 child,
6708 runtime.drain_timeout,
6709 ModuleState::Stopped,
6710 None,
6711 )
6712 .await
6713 }
6714 .await;
6715 let registration_released = result.is_ok();
6716 if registration_released {
6717 runtime.record_activity_transition(
6718 "subc.module_stopped",
6719 &spec.module_id,
6720 requester,
6721 );
6722 }
6723 let _ = reply.send(result);
6724 if registration_released {
6725 process_liveness.untrack_if_current(&spec.module_id, snapshot);
6726 }
6727 false
6728 }
6729 SupervisorCommand::Restart {
6730 requester,
6731 drain_timeout_ms,
6732 received_at_generation,
6733 queued_at,
6734 reply,
6735 } => {
6736 info!(
6740 module_id = %spec.module_id,
6741 queued_ms = u64::try_from(queued_at.elapsed().as_millis()).unwrap_or(u64::MAX),
6742 "restart command dequeued"
6743 );
6744 let validation = match lock_snapshot(snapshot) {
6756 Ok(state) if !state.enabled => Err(SuperviseError::Disabled {
6757 module_id: spec.module_id.clone(),
6758 }),
6759 Ok(_) => Ok(()),
6760 Err(err) => Err(err),
6761 };
6762 let initiated = validation.is_ok();
6763 let _ = reply.send(validation);
6764 let satisfied_by_generation = if initiated && child.is_some() {
6775 lock_snapshot(snapshot).ok().and_then(|state| {
6776 (state.spawn_generation > received_at_generation
6777 && !state.configuration_updated_since_spawn)
6778 .then_some(state.spawn_generation)
6779 })
6780 } else {
6781 None
6782 };
6783 let satisfied_by_pending = initiated
6784 && child.is_none()
6785 && lock_snapshot(snapshot).ok().is_some_and(|mut state| {
6786 let pending = state.respawn_pending && !state.configuration_updated_since_spawn;
6787 if pending {
6788 state.coalesced_restart_pending = true;
6789 }
6790 pending
6791 });
6792 if satisfied_by_pending {
6793 debug!(module_id = %spec.module_id, "restart coalesced into the pending replacement");
6794 } else if let Some(generation) = satisfied_by_generation {
6795 info!(
6796 module_id = %spec.module_id,
6797 received_at_generation,
6798 "restart already satisfied by generation {generation}; not restarting again"
6799 );
6800 } else if initiated {
6801 let drain_timeout = drain_timeout_ms
6804 .map(Duration::from_millis)
6805 .unwrap_or(runtime.drain_timeout);
6806 if let Err(err) = restart_child(
6807 spec,
6808 runtime,
6809 registry,
6810 process_liveness,
6811 snapshot,
6812 child,
6813 drain_timeout,
6814 )
6815 .await
6816 {
6817 warn!(
6818 module_id = %spec.module_id,
6819 error = %err,
6820 "operator restart failed after initiation ack; module state carries the outcome"
6821 );
6822 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
6823 state.state = ModuleState::Failed;
6824 clear_current_process_facts(state);
6825 });
6826 } else {
6827 runtime.start_requester = requester;
6828 runtime.pending_activity_restart = true;
6829 }
6830 }
6831 true
6832 }
6833 SupervisorCommand::Reload { requester, reply } => {
6834 let result =
6835 reload_child(spec, runtime, registry, process_liveness, snapshot, child).await;
6836 if result.is_ok() {
6837 runtime.start_requester = requester;
6838 runtime.pending_activity_restart = true;
6839 }
6840 if result.is_ok()
6841 && runtime
6842 .scheduled_respawn
6843 .lock()
6844 .unwrap_or_else(|p| p.into_inner())
6845 .as_ref()
6846 .is_some_and(|pending| matches!(pending.kind, RespawnKind::Reload))
6847 {
6848 *runtime
6849 .deferred_reload_reply
6850 .lock()
6851 .unwrap_or_else(|p| p.into_inner()) = Some(reply);
6852 } else {
6853 let _ = reply.send(result);
6854 }
6855 true
6856 }
6857 SupervisorCommand::SetEnabled {
6858 enabled,
6859 requester,
6860 reply,
6861 } => {
6862 let previous_requester = runtime.start_requester.clone();
6863 if enabled {
6864 runtime.start_requester = requester.clone();
6865 }
6866 let result = set_child_enabled(
6867 spec,
6868 runtime,
6869 registry,
6870 process_liveness,
6871 snapshot,
6872 child,
6873 enabled,
6874 )
6875 .await;
6876 if matches!(result, Ok(true)) {
6877 runtime.pending_activity_restart = false;
6878 if enabled {
6879 runtime.start_requester = requester;
6880 } else {
6881 runtime.start_requester = None;
6882 runtime.record_activity_transition(
6883 "subc.module_disabled",
6884 &spec.module_id,
6885 requester,
6886 );
6887 }
6888 } else {
6889 runtime.start_requester = previous_requester;
6890 }
6891 let _ = reply.send(result);
6892 true
6893 }
6894 SupervisorCommand::UpdateConfiguration {
6895 spec: next_spec,
6896 health,
6897 drain_timeout_ms,
6898 reply,
6899 } => {
6900 if let Some(handle) = &runtime.supervisor_handle {
6901 handle.apply_identity_configuration(&next_spec);
6902 }
6903 *spec = next_spec;
6904 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
6905 state.configuration_updated_since_spawn = true;
6906 });
6907 let health_changed = runtime.health != health;
6908 runtime.health = health;
6909 if health_changed {
6912 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
6913 state.health = ModuleHealthStatus::default();
6914 });
6915 }
6916 runtime.drain_timeout = drain_timeout_ms
6917 .map(Duration::from_millis)
6918 .unwrap_or(runtime.default_drain_timeout);
6919 *runtime
6920 .effective_drain_timeout
6921 .lock()
6922 .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.drain_timeout;
6923 let _ = reply.send(());
6924 true
6925 }
6926 SupervisorCommand::Swap {
6927 requester,
6928 ready_timeout,
6929 reply,
6930 } => {
6931 let mut swap_runtime = runtime.clone();
6932 swap_runtime.start_requester = requester;
6933 let before = lock_snapshot(snapshot)
6934 .ok()
6935 .map(|state| state.spawn_generation);
6936 let end = swap::run_swap(
6937 spec,
6938 &swap_runtime,
6939 registry,
6940 process_liveness,
6941 snapshot,
6942 child,
6943 commands,
6944 ready_timeout.unwrap_or(DEFAULT_SWAP_READY_TIMEOUT),
6945 reply,
6946 )
6947 .await;
6948 if before
6949 != lock_snapshot(snapshot)
6950 .ok()
6951 .map(|state| state.spawn_generation)
6952 {
6953 runtime.start_requester = swap_runtime.start_requester;
6954 }
6955 requeued.extend(end.requeue);
6956 true
6957 }
6958 }
6959}
6960
6961async fn restart_child(
6962 spec: &ModuleSpec,
6963 runtime: &SupervisorRuntimeConfig,
6964 registry: &Registry,
6965 process_liveness: &SupervisorProcessLiveness,
6966 snapshot: &SharedSnapshot,
6967 child: &mut Option<SupervisedChild>,
6968 drain_timeout: Duration,
6969) -> Result<(), SuperviseError> {
6970 if !lock_snapshot(snapshot)?.enabled {
6972 return Err(SuperviseError::Disabled {
6973 module_id: spec.module_id.clone(),
6974 });
6975 }
6976 let stop_notice = begin_forwarding_drain_with_timeout(
6977 spec,
6978 runtime,
6979 registry,
6980 snapshot,
6981 None,
6982 RouteCloseReason::Restart,
6983 drain_timeout,
6984 )
6985 .await?;
6986
6987 if child.is_some() {
6988 drain_optional_child(
6989 &spec.module_id,
6990 spec.protocol,
6991 stop_notice,
6992 registry,
6993 runtime.forwarding.as_deref(),
6994 snapshot,
6995 &runtime.terminal_ring,
6996 &runtime.spawn_events,
6997 child,
6998 drain_timeout,
6999 ModuleState::Restarting,
7000 Some(true),
7001 )
7002 .await?;
7003 } else {
7004 update_snapshot(snapshot, Some(&spec.module_id), |state| {
7005 state.enabled = true;
7006 state.state = ModuleState::Restarting;
7007 clear_current_process_facts(state);
7008 })?;
7009 release_dead_registration(
7010 registry,
7011 runtime.forwarding.as_deref(),
7012 snapshot,
7013 &spec.module_id,
7014 )
7015 .await?;
7016 }
7017
7018 reset_restart_count(snapshot, &spec.module_id)?;
7019 process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
7020 schedule_respawn(
7021 runtime,
7022 snapshot,
7023 &spec.module_id,
7024 runtime.restart_policy.backoff,
7025 RespawnKind::Spawn,
7026 )
7027}
7028
7029async fn reload_child(
7030 spec: &ModuleSpec,
7031 runtime: &SupervisorRuntimeConfig,
7032 registry: &Registry,
7033 process_liveness: &SupervisorProcessLiveness,
7034 snapshot: &SharedSnapshot,
7035 child: &mut Option<SupervisedChild>,
7036) -> Result<(), SuperviseError> {
7037 if !lock_snapshot(snapshot)?.enabled {
7039 return Err(SuperviseError::Disabled {
7040 module_id: spec.module_id.clone(),
7041 });
7042 }
7043 let stop_notice = begin_forwarding_drain(
7044 spec,
7045 runtime,
7046 registry,
7047 snapshot,
7048 Some(true),
7049 RouteCloseReason::Reload,
7050 )
7051 .await?;
7052
7053 if child.is_some() {
7054 drain_optional_child(
7055 &spec.module_id,
7056 spec.protocol,
7057 stop_notice,
7058 registry,
7059 runtime.forwarding.as_deref(),
7060 snapshot,
7061 &runtime.terminal_ring,
7062 &runtime.spawn_events,
7063 child,
7064 runtime.drain_timeout,
7065 ModuleState::Restarting,
7066 Some(true),
7067 )
7068 .await?;
7069 } else {
7070 update_snapshot(snapshot, Some(&spec.module_id), |state| {
7071 state.enabled = true;
7072 state.state = ModuleState::Restarting;
7073 clear_current_process_facts(state);
7074 })?;
7075 release_dead_registration(
7076 registry,
7077 runtime.forwarding.as_deref(),
7078 snapshot,
7079 &spec.module_id,
7080 )
7081 .await?;
7082 }
7083
7084 reset_restart_count(snapshot, &spec.module_id)?;
7085 process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
7086 schedule_respawn(
7087 runtime,
7088 snapshot,
7089 &spec.module_id,
7090 runtime.restart_policy.backoff,
7091 RespawnKind::Reload,
7092 )
7093}
7094
7095async fn finish_reload_child(
7096 spec: &ModuleSpec,
7097 runtime: &SupervisorRuntimeConfig,
7098 registry: &Registry,
7099 process_liveness: &SupervisorProcessLiveness,
7100 snapshot: &SharedSnapshot,
7101 child: &mut Option<SupervisedChild>,
7102) -> Result<(), SuperviseError> {
7103 process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
7104 let next_child = match spawn_and_mark_running(spec, runtime, snapshot) {
7105 Ok(next_child) => next_child,
7106 Err(err) => {
7107 return handle_reload_spawn_failure(
7108 spec,
7109 runtime,
7110 process_liveness,
7111 snapshot,
7112 child,
7113 format!("new child failed to spawn: {err}"),
7114 )
7115 .await;
7116 }
7117 };
7118 *child = Some(next_child);
7119
7120 let wait_outcome = {
7121 let active_child = child.as_mut().expect("new reload child was just stored");
7122 wait_for_registration_after_reload(
7123 registry,
7124 &spec.module_id,
7125 snapshot,
7126 active_child,
7127 REGISTRY_RELEASE_TIMEOUT,
7128 )
7129 .await?
7130 };
7131
7132 match wait_outcome {
7133 RegistrationWaitOutcome::Registered => {
7134 debug!(module_id = %spec.module_id, "supervised module reloaded and registered");
7135 Ok(())
7136 }
7137 RegistrationWaitOutcome::Exited(exit_report) => {
7138 if let Some(active_child) = child.as_mut() {
7139 active_child.drain_stderr(&spec.module_id).await;
7140 }
7141 let mut exited_child = child.take().expect("exited reload child is still stored");
7144 #[cfg(test)]
7145 if let Some(gate) = &runtime.test_reload_exit_record_gate {
7146 gate.reached.notify_one();
7147 gate.resume.notified().await;
7148 }
7149 let result = handle_reload_child_registration_failure(
7150 spec,
7151 runtime,
7152 registry,
7153 process_liveness,
7154 snapshot,
7155 child,
7156 ReloadRegistrationFailure {
7157 exit_report: registration_failure_exit_report(exit_report),
7158 reason: exited_child
7159 .spawn_failure
7160 .clone()
7161 .unwrap_or_else(|| "new child exited before registering".to_string()),
7162 },
7163 )
7164 .await;
7165 exited_child.release_roster();
7166 result
7167 }
7168 RegistrationWaitOutcome::TimedOut => {
7169 let mut timed_out_child = child
7170 .take()
7171 .expect("timed-out reload child is still running");
7172 timed_out_child
7173 .start_kill()
7174 .map_err(|source| SuperviseError::Kill {
7175 module_id: spec.module_id.clone(),
7176 source,
7177 })?;
7178 let status = timed_out_child
7179 .wait()
7180 .await
7181 .map_err(|source| SuperviseError::Wait {
7182 module_id: spec.module_id.clone(),
7183 source,
7184 })?;
7185 timed_out_child.drain_stderr(&spec.module_id).await;
7186 handle_reload_child_registration_failure(
7187 spec,
7188 runtime,
7189 registry,
7190 process_liveness,
7191 snapshot,
7192 child,
7193 ReloadRegistrationFailure {
7194 exit_report: registration_failure_exit_report(classify_reaped_child_exit(
7195 snapshot,
7196 &timed_out_child,
7197 &status,
7198 )),
7199 reason: format!(
7200 "new child did not register within {:?}",
7201 REGISTRY_RELEASE_TIMEOUT
7202 ),
7203 },
7204 )
7205 .await
7206 }
7207 }
7208}
7209
7210async fn set_child_enabled(
7211 spec: &ModuleSpec,
7212 runtime: &SupervisorRuntimeConfig,
7213 registry: &Registry,
7214 process_liveness: &SupervisorProcessLiveness,
7215 snapshot: &SharedSnapshot,
7216 child: &mut Option<SupervisedChild>,
7217 enabled: bool,
7218) -> Result<bool, SuperviseError> {
7219 let (current_enabled, current_state, respawn_pending) = {
7220 let state = lock_snapshot(snapshot)?;
7221 (state.enabled, state.state, state.respawn_pending)
7222 };
7223 let revive_terminal = enabled
7231 && current_enabled
7232 && child.is_none()
7233 && (matches!(current_state, ModuleState::Failed | ModuleState::Stopped)
7234 || (current_state == ModuleState::Restarting && !respawn_pending));
7235 if current_enabled == enabled && !revive_terminal {
7236 return Ok(false);
7237 }
7238
7239 if enabled {
7240 update_snapshot(snapshot, Some(&spec.module_id), |state| {
7241 state.enabled = true;
7242 state.state = ModuleState::Starting;
7243 clear_current_process_facts(state);
7244 })?;
7245 #[cfg(test)]
7246 if runtime.test_seed_stale_facts_before_enable_spawn {
7247 update_snapshot(snapshot, Some(&spec.module_id), |state| {
7248 state.process_alive = true;
7249 state.pid = Some(41);
7250 state.spawned_at_ms = Some(42);
7251 state.spawned_from = Some(PathBuf::from("/spawned/module"));
7252 state.spawned_file_identity = Some(SpawnedFileIdentity {
7253 device: 43,
7254 inode: 44,
7255 #[cfg(windows)]
7256 windows_image: None,
7257 });
7258 })?;
7259 }
7260 release_dead_registration(
7261 registry,
7262 runtime.forwarding.as_deref(),
7263 snapshot,
7264 &spec.module_id,
7265 )
7266 .await?;
7267 reset_restart_count(snapshot, &spec.module_id)?;
7268 process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
7269 let next_child = match spawn_and_mark_running(spec, runtime, snapshot) {
7270 Ok(next_child) => next_child,
7271 Err(err) => {
7272 if let Err(state_err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
7273 state.state = ModuleState::Failed;
7274 clear_current_process_facts(state);
7275 }) {
7276 error!(module_id = %spec.module_id, error = %state_err, "failed to record enable spawn failure");
7277 }
7278 process_liveness.untrack_if_current(&spec.module_id, snapshot);
7279 return Err(err);
7280 }
7281 };
7282 *child = Some(next_child);
7283 debug!(module_id = %spec.module_id, "supervised module enabled");
7284 Ok(true)
7285 } else {
7286 let stop_notice = begin_forwarding_drain_if_configured(
7287 spec,
7288 runtime,
7289 registry,
7290 snapshot,
7291 Some(false),
7292 RouteCloseReason::Disable,
7293 )
7294 .await?;
7295 drain_optional_child(
7296 &spec.module_id,
7297 spec.protocol,
7298 stop_notice,
7299 registry,
7300 runtime.forwarding.as_deref(),
7301 snapshot,
7302 &runtime.terminal_ring,
7303 &runtime.spawn_events,
7304 child,
7305 runtime.drain_timeout,
7306 ModuleState::Disabled,
7307 Some(false),
7308 )
7309 .await?;
7310 debug!(module_id = %spec.module_id, "supervised module disabled");
7311 Ok(true)
7312 }
7313}
7314
7315#[allow(clippy::too_many_arguments)]
7316async fn on_child_exit(
7317 spec: &ModuleSpec,
7318 policy: RestartPolicy,
7319 registry: &Registry,
7320 snapshot: &SharedSnapshot,
7321 terminal_ring: &Arc<Mutex<TerminalRing>>,
7322 spawn_events: &SpawnEventFeed,
7323 roster: &ChildRoster,
7324 exit_report: ExitReport,
7325) -> NextAction {
7326 if roster.is_closed() {
7332 return on_child_exit_during_daemon_shutdown(
7333 spec,
7334 registry,
7335 snapshot,
7336 terminal_ring,
7337 spawn_events,
7338 exit_report,
7339 )
7340 .await;
7341 }
7342 let unrequested_clean_exit_of_protocol_none = exit_report.kind == ExitKind::Clean
7357 && running_protocol(spec, snapshot) == ModuleProtocol::None;
7358 match exit_report.kind {
7359 ExitKind::Clean if !unrequested_clean_exit_of_protocol_none => {
7360 info!(
7361 module_id = %spec.module_id,
7362 exit_code = ?exit_report.code,
7363 exit_signal = ?exit_report.signal,
7364 "supervised module exited cleanly"
7365 );
7366 if let Err(err) = publish_after_terminal_record(
7367 &spec.module_id,
7368 || {
7369 record_terminal(
7370 &spec.module_id,
7371 terminal_ring,
7372 spawn_events,
7373 &exit_report,
7374 TerminalDisposition::Stopped,
7375 )
7376 },
7377 || {
7378 update_snapshot(snapshot, Some(&spec.module_id), |state| {
7379 state.state = ModuleState::Stopped;
7380 clear_current_process_facts(state);
7381 state.last_exit = Some(exit_report.clone());
7382 })
7383 },
7384 ) {
7385 error!(module_id = %spec.module_id, error = %err, "failed to publish clean module exit");
7386 }
7387 let registration_released = match wait_for_registration_release(
7388 registry,
7389 &spec.module_id,
7390 REGISTRY_RELEASE_TIMEOUT,
7391 )
7392 .await
7393 {
7394 Ok(()) => true,
7395 Err(err) => {
7396 warn!(module_id = %spec.module_id, error = %err, "registration still active after clean exit");
7397 false
7398 }
7399 };
7400 NextAction::Stop {
7401 registration_released,
7402 }
7403 }
7404 ExitKind::Clean | ExitKind::Crash => {
7405 if unrequested_clean_exit_of_protocol_none {
7406 warn!(
7407 module_id = %spec.module_id,
7408 exit_code = ?exit_report.code,
7409 exit_signal = ?exit_report.signal,
7410 "protocol-none module exited cleanly without a stop request; handling it as a crash"
7411 );
7412 } else {
7413 warn!(
7414 module_id = %spec.module_id,
7415 exit_code = ?exit_report.code,
7416 exit_signal = ?exit_report.signal,
7417 "supervised module exited abnormally (crash)"
7418 );
7419 }
7420 let mut restart_schedule = None;
7421 let mut disposition = TerminalDisposition::Disabled;
7422 let mut final_state = ModuleState::Disabled;
7423 let mut disposition_detail = lock_snapshot(snapshot)
7427 .ok()
7428 .and_then(|mut state| state.spawn_failure.take());
7429 let now = Instant::now();
7430 if let Err(err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
7431 if state.enabled {
7432 if let Some(schedule) = state.next_crash_restart(&policy, now) {
7433 state.respawn_pending = true;
7439 final_state = ModuleState::Restarting;
7440 restart_schedule = Some(schedule);
7441 disposition = TerminalDisposition::Restarting;
7442 } else {
7443 final_state = ModuleState::Failed;
7444 disposition = TerminalDisposition::Failed;
7445 let budget = policy.budget_exhausted_detail();
7446 disposition_detail =
7447 Some(disposition_detail.take().map_or_else(
7448 || budget.clone(),
7449 |cause| format!("{cause}; {budget}"),
7450 ));
7451 }
7452 } else {
7453 disposition = TerminalDisposition::Disabled;
7454 }
7455 }) {
7456 error!(module_id = %spec.module_id, error = %err, "failed to record crashed module exit");
7457 return NextAction::Stop {
7458 registration_released: false,
7459 };
7460 }
7461 if disposition == TerminalDisposition::Failed {
7462 error!(
7467 module_id = %spec.module_id,
7468 max_restarts = policy.max_restarts,
7469 window_secs = policy.window.as_secs(),
7470 "module stopped: {}",
7471 policy.budget_exhausted_detail()
7472 );
7473 }
7474 if let Err(err) = publish_after_terminal_record(
7475 &spec.module_id,
7476 || {
7477 record_terminal_with_detail(
7478 &spec.module_id,
7479 terminal_ring,
7480 spawn_events,
7481 &exit_report,
7482 disposition,
7483 disposition_detail,
7484 )
7485 },
7486 || {
7487 update_snapshot(snapshot, Some(&spec.module_id), |state| {
7488 state.state = final_state;
7489 clear_current_process_facts(state);
7490 state.last_exit = Some(exit_report.clone());
7491 })
7492 },
7493 ) {
7494 error!(module_id = %spec.module_id, error = %err, "failed to publish crashed module exit");
7495 }
7496
7497 if let Some(schedule) = restart_schedule {
7498 NextAction::Restart {
7499 schedule: Some(schedule),
7500 }
7501 } else {
7502 let registration_released = match wait_for_registration_release(
7503 registry,
7504 &spec.module_id,
7505 REGISTRY_RELEASE_TIMEOUT,
7506 )
7507 .await
7508 {
7509 Ok(()) => true,
7510 Err(err) => {
7511 warn!(module_id = %spec.module_id, error = %err, "registration still active after failed module");
7512 false
7513 }
7514 };
7515 NextAction::Stop {
7516 registration_released,
7517 }
7518 }
7519 }
7520 ExitKind::DeliberateSeverance => {
7521 warn!(
7522 module_id = %spec.module_id,
7523 exit_code = ?exit_report.code,
7524 exit_signal = ?exit_report.signal,
7525 "supervised module exited after deliberate connection severance"
7526 );
7527 let should_restart = match lock_snapshot(snapshot) {
7528 Ok(state) => state.enabled,
7529 Err(err) => {
7530 error!(module_id = %spec.module_id, error = %err, "failed to read deliberately severed module state");
7531 return NextAction::Stop {
7532 registration_released: false,
7533 };
7534 }
7535 };
7536 let final_state = if should_restart {
7537 ModuleState::Restarting
7538 } else {
7539 ModuleState::Disabled
7540 };
7541 if let Err(err) = publish_after_terminal_record(
7542 &spec.module_id,
7543 || {
7544 record_terminal(
7545 &spec.module_id,
7546 terminal_ring,
7547 spawn_events,
7548 &exit_report,
7549 terminal_disposition(final_state),
7550 )
7551 },
7552 || {
7553 update_snapshot(snapshot, Some(&spec.module_id), |state| {
7554 state.state = final_state;
7555 clear_current_process_facts(state);
7556 state.last_exit = Some(exit_report.clone());
7557 state.lifetime_restarts += 1;
7558 })
7559 },
7560 ) {
7561 error!(module_id = %spec.module_id, error = %err, "failed to publish deliberately severed module exit");
7562 return NextAction::Stop {
7563 registration_released: false,
7564 };
7565 }
7566
7567 if should_restart {
7568 NextAction::Restart { schedule: None }
7569 } else {
7570 let registration_released = match wait_for_registration_release(
7571 registry,
7572 &spec.module_id,
7573 REGISTRY_RELEASE_TIMEOUT,
7574 )
7575 .await
7576 {
7577 Ok(()) => true,
7578 Err(err) => {
7579 warn!(module_id = %spec.module_id, error = %err, "registration still active after deliberately severed module exit");
7580 false
7581 }
7582 };
7583 NextAction::Stop {
7584 registration_released,
7585 }
7586 }
7587 }
7588 }
7589}
7590
7591async fn on_child_exit_during_daemon_shutdown(
7592 spec: &ModuleSpec,
7593 registry: &Registry,
7594 snapshot: &SharedSnapshot,
7595 terminal_ring: &Arc<Mutex<TerminalRing>>,
7596 spawn_events: &SpawnEventFeed,
7597 exit_report: ExitReport,
7598) -> NextAction {
7599 info!(
7600 module_id = %spec.module_id,
7601 exit_code = ?exit_report.code,
7602 exit_signal = ?exit_report.signal,
7603 exit_kind = ?exit_report.kind,
7604 "supervised module exited during daemon shutdown; not restarting it"
7605 );
7606 if let Err(err) = publish_after_terminal_record(
7607 &spec.module_id,
7608 || {
7609 record_terminal(
7610 &spec.module_id,
7611 terminal_ring,
7612 spawn_events,
7613 &exit_report,
7614 TerminalDisposition::DaemonShutdown,
7615 )
7616 },
7617 || {
7618 update_snapshot(snapshot, Some(&spec.module_id), |state| {
7619 state.state = ModuleState::Stopped;
7620 clear_current_process_facts(state);
7621 state.last_exit = Some(exit_report.clone());
7622 })
7623 },
7624 ) {
7625 error!(module_id = %spec.module_id, error = %err, "failed to publish module exit during daemon shutdown");
7626 }
7627 let registration_released =
7628 wait_for_registration_release(registry, &spec.module_id, REGISTRY_RELEASE_TIMEOUT)
7629 .await
7630 .is_ok();
7631 NextAction::Stop {
7632 registration_released,
7633 }
7634}
7635
7636fn publish_after_terminal_record<T>(
7641 module_id: &str,
7642 record: impl FnOnce(),
7643 publish: impl FnOnce() -> T,
7644) -> T {
7645 let recorded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(record));
7646 let published = publish();
7647 if let Err(panic) = recorded {
7648 error!(
7649 module_id,
7650 "terminal recording panicked; published final state before resuming unwind"
7651 );
7652 std::panic::resume_unwind(panic);
7653 }
7654 published
7655}
7656
7657fn fail_after_wait_error(
7658 module_id: &str,
7659 snapshot: &SharedSnapshot,
7660 terminal_ring: &Arc<Mutex<TerminalRing>>,
7661 spawn_events: &SpawnEventFeed,
7662 detail: String,
7663) {
7664 let report = wait_error_exit_report();
7665 publish_after_terminal_record(
7666 module_id,
7667 || {
7668 record_terminal_with_detail(
7669 module_id,
7670 terminal_ring,
7671 spawn_events,
7672 &report,
7673 TerminalDisposition::Failed,
7674 Some(detail),
7675 )
7676 },
7677 || fail_snapshot(snapshot, Some(module_id), Some(report.clone())),
7678 );
7679}
7680
7681#[cfg(test)]
7682fn record_wait_error_terminal(
7683 module_id: &str,
7684 terminal_ring: &Arc<Mutex<TerminalRing>>,
7685 spawn_events: &SpawnEventFeed,
7686) {
7687 record_terminal(
7688 module_id,
7689 terminal_ring,
7690 spawn_events,
7691 &wait_error_exit_report(),
7692 TerminalDisposition::Failed,
7693 );
7694}
7695
7696fn record_terminal(
7697 module_id: &str,
7698 terminal_ring: &Arc<Mutex<TerminalRing>>,
7699 spawn_events: &SpawnEventFeed,
7700 exit_report: &ExitReport,
7701 disposition: TerminalDisposition,
7702) {
7703 record_terminal_with_detail(
7704 module_id,
7705 terminal_ring,
7706 spawn_events,
7707 exit_report,
7708 disposition,
7709 None,
7710 );
7711}
7712
7713fn durable_terminal_history_of(
7717 terminal_ring: &Mutex<TerminalRing>,
7718 module_id: &str,
7719) -> subc_control::TerminalHistory {
7720 let read = terminal_ring
7721 .lock()
7722 .unwrap_or_else(|p| p.into_inner())
7723 .capture_durable_history();
7724 read.read(module_id)
7725}
7726
7727fn record_terminal_with_detail(
7728 module_id: &str,
7729 terminal_ring: &Arc<Mutex<TerminalRing>>,
7730 spawn_events: &SpawnEventFeed,
7731 exit_report: &ExitReport,
7732 disposition: TerminalDisposition,
7733 disposition_detail: Option<String>,
7734) {
7735 #[cfg(test)]
7736 terminal_history_tests::before_terminal_record(spawn_events);
7737 spawn_events.emit_exited(module_id, exit_report.code, exit_report.signal);
7738 let record = TerminalRecord {
7739 exit_code: exit_report.code,
7740 exit_signal: exit_report.signal,
7741 at_ms: exit_report.at_ms,
7742 disposition,
7743 exit_kind: exit_report.kind.into(),
7744 disposition_detail,
7745 };
7746 terminal_ring
7747 .lock()
7748 .unwrap_or_else(|poisoned| poisoned.into_inner())
7749 .record_exit(module_id, record);
7750}
7751
7752fn untrack_if_registration_released(
7753 process_liveness: &SupervisorProcessLiveness,
7754 registry: &Registry,
7755 module_id: &str,
7756 snapshot: &SharedSnapshot,
7757) {
7758 match registry.get_module(module_id) {
7759 Ok(None) => process_liveness.untrack_if_current(module_id, snapshot),
7760 Ok(Some(_)) => {}
7761 Err(err) => {
7762 warn!(module_id, error = %err, "could not determine whether supervisor liveness can be untracked");
7763 }
7764 }
7765}
7766
7767#[cfg(test)]
7781fn apply_wire_spawn_args(
7782 command: &mut Command,
7783 spec: &ModuleSpec,
7784 connection_file_path: Option<&std::path::Path>,
7785 handle: Option<&SupervisorHandle>,
7786) -> Result<Option<NonceHandoff>, SuperviseError> {
7787 apply_wire_spawn_args_for_role(
7788 command,
7789 spec,
7790 connection_file_path,
7791 handle,
7792 SpawnRole::Plain,
7793 )
7794}
7795
7796#[cfg(unix)]
7801type NonceHandoff = subc_os::LaunchNonceHandoff;
7802#[cfg(windows)]
7803type NonceHandoff = subc_os::LaunchNoncePipeHandoff;
7804#[cfg(not(any(unix, windows)))]
7805type NonceHandoff = std::convert::Infallible;
7806
7807fn apply_wire_spawn_args_for_role(
7828 command: &mut Command,
7829 spec: &ModuleSpec,
7830 connection_file_path: Option<&std::path::Path>,
7831 handle: Option<&SupervisorHandle>,
7832 role: SpawnRole,
7833) -> Result<Option<NonceHandoff>, SuperviseError> {
7834 command.env(SUBC_MODULE_ID_ENV, &spec.module_id);
7835 command.env_remove(subc_os::LAUNCH_NONCE_FD_ENV);
7840 #[cfg(windows)]
7841 {
7842 command.env_remove(subc_os::LAUNCH_NONCE_PIPE_ENV);
7843 command.env_remove(subc_os::LAUNCH_NONCE_PIPE_FALLBACK_ENV);
7844 }
7845 command.env_remove(SUBC_LAUNCH_NONCE_ENV);
7847 if spec.protocol == ModuleProtocol::None {
7848 return Ok(None);
7849 }
7850 if let Some(connection_file_path) = connection_file_path {
7851 command.arg(SUBC_ARG).arg(connection_file_path);
7852 }
7853
7854 let nonce = generate_launch_nonce()?;
7858 if let Some(handle) = handle {
7859 match role {
7860 SpawnRole::Plain => {
7861 handle.set_spawn_nonce(&spec.module_id, nonce.clone());
7862 if spec.reserved {
7863 handle.set_reserved_nonce(&spec.module_id, nonce.clone());
7864 }
7865 }
7866 SpawnRole::SwapCandidate => handle.open_swap(&spec.module_id, nonce.clone()),
7867 }
7868 }
7869 #[cfg(unix)]
7870 let handoff = {
7871 let handoff =
7872 subc_os::LaunchNonceHandoff::new(&nonce).map_err(|source| SuperviseError::Spawn {
7873 program: spec.program.clone(),
7874 source,
7875 cgroup_path: None,
7876 })?;
7877 command.env(subc_os::LAUNCH_NONCE_FD_ENV, handoff.fd_env_value());
7878 Some(handoff)
7879 };
7880 #[cfg(windows)]
7881 let handoff = prepare_windows_nonce_pipe(command, spec, &nonce);
7882 #[cfg(not(any(unix, windows)))]
7883 let handoff = None;
7884 #[cfg(not(unix))]
7888 command.env(SUBC_LAUNCH_NONCE_ENV, nonce);
7889 Ok(handoff)
7890}
7891
7892fn apply_child_env(command: &mut Command, spec: &ModuleSpec) {
7893 command.env_remove(CK_LOG_ENV);
7894 command.env_remove(SUBC_SPAWN_ROLE_ENV);
7901 for (key, value) in &spec.env {
7902 if matches!(
7906 key.as_str(),
7907 CAPTURE_MAX_FILE_MB_ENV | CAPTURE_KEEP_ENV | CAPTURE_MAX_AGE_DAYS_ENV
7908 ) || key == SUBC_SPAWN_ROLE_ENV
7909 {
7910 continue;
7911 }
7912 command.env(key, value);
7913 }
7914}
7915
7916#[cfg(windows)]
7917fn prepare_windows_nonce_pipe(
7918 command: &mut Command,
7919 spec: &ModuleSpec,
7920 nonce: &str,
7921) -> Option<NonceHandoff> {
7922 match subc_os::LaunchNoncePipeHandoff::new(nonce) {
7923 Ok(handoff) => {
7924 command.env(subc_os::LAUNCH_NONCE_PIPE_ENV, handoff.name());
7925 command.env(subc_os::LAUNCH_NONCE_PIPE_FALLBACK_ENV, "env");
7930 Some(handoff)
7931 }
7932 Err(error) => {
7933 warn!(module_id = %spec.module_id, %error, "could not prepare launch nonce pipe; retaining the environment handoff");
7934 None
7935 }
7936 }
7937}
7938
7939#[cfg(windows)]
7940fn start_windows_nonce_pipe(
7941 handoff: Option<NonceHandoff>,
7942 pid: u32,
7943 spec: &ModuleSpec,
7944) -> Option<subc_os::LaunchNoncePipeDelivery> {
7945 handoff.and_then(|handoff| match handoff.serve(pid, REGISTRY_RELEASE_TIMEOUT) {
7949 Ok(delivery) => Some(delivery),
7950 Err(error) => {
7951 warn!(module_id = %spec.module_id, %error, "could not serve launch nonce pipe; retaining the environment handoff");
7952 None
7953 }
7954 })
7955}
7956
7957fn observed_launch_nonce_source(snapshot: &SupervisorSnapshot) -> Option<String> {
7958 #[cfg(windows)]
7959 if snapshot.process_alive && snapshot.spawned_protocol == Some(ModuleProtocol::Subc) {
7960 return Some(
7961 if snapshot
7962 .pipe_consumed
7963 .as_ref()
7964 .is_some_and(|consumed| consumed.get().is_some())
7965 {
7966 "pipe"
7967 } else {
7968 "env"
7969 }
7970 .to_owned(),
7971 );
7972 }
7973 let _ = snapshot;
7974 None
7975}
7976
7977#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7980enum SpawnRole {
7981 Plain,
7982 SwapCandidate,
7983}
7984
7985fn apply_spawn_role(command: &mut Command, role: SpawnRole) {
7988 if role == SpawnRole::SwapCandidate {
7989 command.env(SUBC_SPAWN_ROLE_ENV, SPAWN_ROLE_SWAP_CANDIDATE);
7990 }
7991}
7992
7993#[cfg(target_os = "linux")]
7994fn previous_instance_error(spec: &ModuleSpec, pid: u32) -> SuperviseError {
7995 SuperviseError::Spawn {
7996 program: spec.program.clone(),
7997 source: io::Error::new(io::ErrorKind::WouldBlock, format!(
7998 "waiting: previous instance (pid {pid}) is still running but hidden; it cannot be identified"
7999 )),
8000 cgroup_path: None,
8001 }
8002}
8003
8004fn spawn_child(
8005 spec: &ModuleSpec,
8006 connection_file_path: Option<&std::path::Path>,
8007 handle: Option<&SupervisorHandle>,
8008 ring: &Arc<Mutex<StderrRing>>,
8009 capture_logs_dir: Option<&std::path::Path>,
8010 roster: &ChildRoster,
8011 #[cfg(target_os = "linux")] cgroup_placement: Option<&subc_cgroup::Placement>,
8012) -> Result<SupervisedChild, SuperviseError> {
8013 spawn_child_in_slot(
8014 spec,
8015 connection_file_path,
8016 handle,
8017 ring,
8018 capture_logs_dir,
8019 roster,
8020 #[cfg(target_os = "linux")]
8021 cgroup_placement,
8022 SpawnRole::Plain,
8023 false,
8024 )
8025}
8026
8027#[allow(clippy::too_many_arguments)]
8040fn spawn_child_in_slot(
8041 spec: &ModuleSpec,
8042 connection_file_path: Option<&std::path::Path>,
8043 handle: Option<&SupervisorHandle>,
8044 ring: &Arc<Mutex<StderrRing>>,
8045 capture_logs_dir: Option<&std::path::Path>,
8046 roster: &ChildRoster,
8047 #[cfg(target_os = "linux")] cgroup_placement: Option<&subc_cgroup::Placement>,
8048 role: SpawnRole,
8049 alternate_slot: bool,
8050) -> Result<SupervisedChild, SuperviseError> {
8051 #[cfg(target_os = "linux")]
8052 if let Some(pid) = roster.previous_instance_pid(&spec.module_id) {
8053 return Err(previous_instance_error(spec, pid));
8054 }
8055 if roster.is_closed() {
8056 return Err(SuperviseError::Spawn {
8057 program: spec.program.clone(),
8058 source: io::Error::other("the daemon is shutting down; not starting a new process"),
8059 cgroup_path: None,
8060 });
8061 }
8062 #[cfg(target_os = "linux")]
8063 let cgroup_name = {
8064 if cgroup_placement.is_none() {
8069 swap::cgroup_name(&spec.module_id, alternate_slot)
8070 } else {
8071 let nonce = generate_launch_nonce()?;
8072 let suffix = u128::from_str_radix(&nonce[..32], 16).expect("hex launch nonce");
8073 let mut end = spec.module_id.len().min(64);
8076 while !spec.module_id.is_char_boundary(end) {
8077 end -= 1;
8078 }
8079 format!(
8080 "{}_{suffix}",
8081 swap::cgroup_name(&spec.module_id[..end], alternate_slot)
8082 )
8083 }
8084 };
8085 #[cfg(not(target_os = "linux"))]
8086 let _ = alternate_slot;
8087 #[cfg(target_os = "macos")]
8088 let (mut command, privacy_exec, exec_ack) = privacy_command(spec, roster)?;
8089 #[cfg(windows)]
8090 let image_capture = windows_spawn_capture(spec).map_err(|source| SuperviseError::Spawn {
8091 program: spec.program.clone(),
8092 source,
8093 cgroup_path: None,
8094 })?;
8095 #[cfg(windows)]
8096 let mut command = Command::new(image_capture.path());
8097 #[cfg(not(any(target_os = "macos", windows)))]
8098 let mut command = Command::new(&spec.program);
8099 command.args(&spec.args);
8100 apply_child_env(&mut command, spec);
8130 apply_spawn_role(&mut command, role);
8131 let nonce_handoff =
8132 apply_wire_spawn_args_for_role(&mut command, spec, connection_file_path, handle, role)?;
8133
8134 #[cfg(target_os = "linux")]
8135 let cgroup_path = cgroup_placement
8136 .map(|placement| placement.module_path(&cgroup_name))
8137 .transpose()
8138 .map_err(|source| SuperviseError::Cgroup {
8139 module_id: spec.module_id.clone(),
8140 source,
8141 })?;
8142 #[cfg(not(target_os = "linux"))]
8143 let cgroup_path: Option<PathBuf> = None;
8144 #[cfg(target_os = "linux")]
8145 if let Some(path) = &cgroup_path {
8146 if let Err(error) = apply_cgroup_placement(&mut command, spec, path) {
8147 if let Some(placement) = cgroup_placement {
8148 remove_module_cgroup(placement, &cgroup_name);
8149 }
8150 return Err(error);
8151 }
8152 }
8153
8154 let output_sink = if let Some(logs_dir) = capture_logs_dir {
8155 let path = logs_dir.join(format!("{}.stderr.log", spec.module_id));
8156 match ChildOutputSink::open(&path, capture_retention(spec)) {
8157 Ok(sink) => sink,
8158 Err(error) => {
8159 warn!(
8160 module_id = %spec.module_id,
8161 path = %path.display(),
8162 error = %error,
8163 "could not open child output capture file; forwarding to stderr"
8164 );
8165 ChildOutputSink::Stderr
8166 }
8167 }
8168 } else {
8169 ChildOutputSink::Stderr
8170 };
8171
8172 command.stdout(Stdio::piped());
8173 command.stderr(Stdio::piped());
8174 command.kill_on_drop(true);
8175 #[cfg(unix)]
8192 command.process_group(0);
8193 command.stdin(Stdio::null());
8194 #[cfg(unix)]
8198 if let Some(handoff) = nonce_handoff {
8199 handoff.install_last(command.as_std_mut());
8200 }
8201 #[cfg(not(any(unix, windows)))]
8202 let _ = nonce_handoff;
8203
8204 #[cfg(windows)]
8209 subc_jobobject::suspend_on_create_async(&mut command);
8210 let mut child = match command.spawn() {
8211 Ok(child) => child,
8212 Err(source) => {
8213 #[cfg(target_os = "linux")]
8214 if let Some(placement) = cgroup_placement {
8215 remove_module_cgroup(placement, &cgroup_name);
8216 }
8217 return Err(SuperviseError::Spawn {
8218 program: spec.program.clone(),
8219 source,
8220 cgroup_path,
8221 });
8222 }
8223 };
8224 #[cfg(target_os = "macos")]
8229 drop(exec_ack);
8230
8231 #[cfg(windows)]
8236 let windows_image =
8237 Arc::new(
8238 image_capture
8239 .bind_tokio(&child)
8240 .map_err(|source| SuperviseError::Spawn {
8241 program: spec.program.clone(),
8242 source,
8243 cgroup_path: None,
8244 })?,
8245 );
8246 #[cfg(windows)]
8248 let job = contain_spawned_child(&child, spec)?;
8249 let spawned_at_ms = unix_ms_now();
8250 #[cfg(not(windows))]
8251 let spawned_from = spec.program.clone();
8252 #[cfg(windows)]
8253 let spawned_from = windows_image.path().to_path_buf();
8254 #[cfg(not(windows))]
8255 let spawned_file_identity = spawned_file_identity(&spawned_from);
8256 #[cfg(windows)]
8257 let spawned_file_identity = Some(SpawnedFileIdentity {
8258 device: 0,
8259 inode: 0,
8260 windows_image: Some(Arc::clone(&windows_image)),
8261 });
8262 let pid = child.id().ok_or_else(|| SuperviseError::Spawn {
8263 program: spec.program.clone(),
8264 source: io::Error::other("spawned child exposed no live pid"),
8265 cgroup_path: cgroup_path.clone(),
8266 })?;
8267 #[cfg(windows)]
8268 let nonce_delivery = start_windows_nonce_pipe(nonce_handoff, pid, spec);
8269 #[cfg(not(windows))]
8270 let process_start_time = crate::provenance::process_start_time(pid);
8271 #[cfg(windows)]
8272 let process_start_time = Some(windows_image.start_time());
8273 let process_identity = process_start_time.map(|start_time| ProcessIdentity { pid, start_time });
8274 #[cfg(all(test, target_os = "macos"))]
8275 privacy_exec_boundary_tests::before_image_sample(spec, pid);
8276 #[cfg(not(windows))]
8282 let recorded_image = observe_spawned_image(pid);
8283 #[cfg(target_os = "macos")]
8287 let recorded_image = if privacy_exec.is_some() {
8288 None
8289 } else {
8290 recorded_image
8291 };
8292 #[cfg(target_os = "linux")]
8293 let recorded_cgroup_name = cgroup_path.as_ref().map(|_| cgroup_name.clone());
8294 #[cfg(not(target_os = "linux"))]
8295 let recorded_cgroup_name = None;
8296 let roster_guard = roster.admit(
8297 spec.module_id.clone(),
8298 pid,
8299 spec.protocol,
8300 process_start_time,
8301 crate::child_roster::RecordedIdentity {
8302 #[cfg(not(windows))]
8303 start_time: recorded_image.map(|image| image.start_time),
8304 #[cfg(windows)]
8305 start_time: process_start_time,
8306 #[cfg(not(windows))]
8307 executable: recorded_image
8308 .and_then(|image| image.executable)
8309 .map(crate::live_children::ExecutableIdentity::from),
8310 #[cfg(windows)]
8311 executable: Some(crate::live_children::ExecutableIdentity::from(
8312 windows_image.identity(),
8313 )),
8314 cgroup_name: recorded_cgroup_name,
8315 #[cfg(target_os = "linux")]
8316 cgroup_placement: cgroup_placement.cloned(),
8317 },
8318 );
8319 if roster.is_closed() {
8328 #[cfg(target_os = "linux")]
8330 kill_module_cgroup(cgroup_placement, &cgroup_name);
8331 if let Err(error) = child.start_kill() {
8332 debug!(module_id = %spec.module_id, pid, %error, "kill of a process spawned during daemon shutdown failed; it may already have exited");
8333 }
8334 #[cfg(target_os = "linux")]
8335 if let Some(placement) = cgroup_placement {
8336 while matches!(child.try_wait(), Ok(None)) {
8340 std::thread::yield_now();
8341 }
8342 if matches!(
8343 subc_cgroup::kill_module(Some(placement), &cgroup_name),
8344 subc_cgroup::KillOutcome::Killed
8345 ) {
8346 if let Ok(path) = placement.module_path(&cgroup_name) {
8347 while std::fs::read_to_string(path.join("cgroup.events"))
8348 .ok()
8349 .is_some_and(|events| events.lines().any(|line| line == "populated 1"))
8350 {
8351 std::thread::yield_now();
8352 }
8353 }
8354 }
8355 remove_module_cgroup(placement, &cgroup_name);
8356 }
8357 drop(roster_guard);
8358 return Err(SuperviseError::Spawn {
8359 program: spec.program.clone(),
8360 source: io::Error::other(
8361 "the daemon began shutting down while this process was starting; ended it",
8362 ),
8363 cgroup_path,
8364 });
8365 }
8366
8367 let stdout_pump = match child.stdout.take() {
8368 Some(stdout) => Some(tokio::spawn(pump_stdout_to(stdout, output_sink.clone()))),
8369 None => {
8370 warn!(
8371 module_id = %spec.module_id,
8372 "spawned child exposed no stdout pipe; file capture will be incomplete"
8373 );
8374 None
8375 }
8376 };
8377 let stderr_pump = match child.stderr.take() {
8378 Some(stderr) => {
8379 let generation = ring
8380 .lock()
8381 .unwrap_or_else(|poisoned| poisoned.into_inner())
8382 .begin_process();
8383 Some(StderrPump {
8384 task: tokio::spawn(pump_stderr_to(
8385 stderr,
8386 Arc::clone(ring),
8387 generation,
8388 output_sink,
8389 )),
8390 generation,
8391 })
8392 }
8393 None => {
8394 ring.lock()
8398 .unwrap_or_else(|poisoned| poisoned.into_inner())
8399 .mark_not_captured("stderr pipe was not available on spawn");
8400 warn!(
8401 module_id = %spec.module_id,
8402 "spawned child exposed no stderr pipe; tail will be unavailable"
8403 );
8404 None
8405 }
8406 };
8407
8408 Ok(SupervisedChild {
8409 child,
8410 #[cfg(windows)]
8411 nonce_delivery,
8412 protocol: spec.protocol,
8413 #[cfg(target_os = "linux")]
8414 module_id: cgroup_name,
8415 #[cfg(target_os = "linux")]
8416 cgroup_placement: cgroup_placement.cloned(),
8417 #[cfg(windows)]
8418 job,
8419 stdout_pump,
8420 stderr_pump,
8421 stderr_ring: Arc::clone(ring),
8422 spawned_at_ms,
8423 spawned_from,
8424 spawned_file_identity,
8425 process_start_time,
8426 process_identity,
8427 pid,
8428 roster_guard: Some(roster_guard),
8429 #[cfg(target_os = "macos")]
8430 privacy_exec,
8431 #[cfg(target_os = "macos")]
8432 report_ready: Arc::new(OnceLock::new()),
8433 spawn_failure: None,
8434 })
8435}
8436
8437#[cfg(target_os = "linux")]
8438pub(crate) fn kill_module_cgroup(placement: Option<&subc_cgroup::Placement>, module_id: &str) {
8439 use subc_cgroup::KillOutcome;
8440 match subc_cgroup::kill_module(placement, module_id) {
8441 KillOutcome::Killed => {}
8442 KillOutcome::NotPlaced | KillOutcome::Unsupported => {
8443 debug!(
8444 module_id,
8445 "cgroup tree kill unavailable; using direct-child kill"
8446 );
8447 }
8448 KillOutcome::IoError { path, error } => {
8449 warn!(module_id, path = %path.display(), %error, "cgroup tree kill failed; using direct-child kill");
8450 }
8451 }
8452}
8453
8454#[cfg(windows)]
8468fn contain_spawned_child(
8469 child: &Child,
8470 spec: &ModuleSpec,
8471) -> Result<Option<subc_jobobject::JobObject>, SuperviseError> {
8472 let module_id = spec.module_id.as_str();
8473 let Some(pid) = child.id() else {
8474 warn!(
8477 module_id,
8478 "spawned child had already exited before containment; no job object attached"
8479 );
8480 return Ok(None);
8481 };
8482
8483 let job = match subc_jobobject::JobObject::new() {
8484 Ok(job) => job,
8485 Err(source) => {
8486 warn!(
8487 module_id,
8488 error = %source,
8489 "could not create a job object; this module's helper processes will not be \
8490 reaped on teardown"
8491 );
8492 resume_suspended_child(pid, spec)?;
8495 return Ok(None);
8496 }
8497 };
8498
8499 if let Err(source) = job.assign(child) {
8500 warn!(
8501 module_id,
8502 error = %source,
8503 "could not assign the child to its job object; this module's helper processes \
8504 will not be reaped on teardown"
8505 );
8506 resume_suspended_child(pid, spec)?;
8507 return Ok(None);
8508 }
8509
8510 resume_suspended_child(pid, spec)?;
8511 Ok(Some(job))
8512}
8513
8514#[cfg(windows)]
8519fn resume_suspended_child(pid: u32, spec: &ModuleSpec) -> Result<(), SuperviseError> {
8520 if let Err(source) = subc_jobobject::resume_main_thread(pid) {
8521 let _ = std::process::Command::new("taskkill.exe")
8525 .args(["/PID", &pid.to_string(), "/T", "/F"])
8526 .stdin(Stdio::null())
8527 .stdout(Stdio::null())
8528 .stderr(Stdio::null())
8529 .status();
8530 return Err(SuperviseError::Spawn {
8531 program: spec.program.clone(),
8532 source,
8533 cgroup_path: None,
8534 });
8535 }
8536 Ok(())
8537}
8538
8539#[cfg(target_os = "linux")]
8540fn remove_module_cgroup(placement: &subc_cgroup::Placement, module_id: &str) {
8541 match placement.remove_module(module_id) {
8542 Ok(()) => debug!(module_id, "removed module cgroup after process exit"),
8543 Err(error) => warn!(
8544 module_id,
8545 error = %error,
8546 "could not remove module cgroup after process exit; continuing teardown"
8547 ),
8548 }
8549}
8550
8551#[cfg(target_os = "linux")]
8552async fn cleanup_reaped_cgroup(placement: &subc_cgroup::Placement, module_id: &str) {
8553 if matches!(
8557 subc_cgroup::kill_module(Some(placement), module_id),
8558 subc_cgroup::KillOutcome::Killed
8559 ) {
8560 if let Ok(path) = placement.module_path(module_id) {
8561 while std::fs::read_to_string(path.join("cgroup.events"))
8562 .ok()
8563 .is_some_and(|events| events.lines().any(|line| line == "populated 1"))
8564 {
8565 sleep(Duration::from_millis(1)).await;
8566 }
8567 }
8568 }
8569 remove_module_cgroup(placement, module_id);
8570}
8571
8572#[cfg(target_os = "linux")]
8573fn apply_cgroup_placement(
8574 command: &mut Command,
8575 spec: &ModuleSpec,
8576 path: &std::path::Path,
8577) -> Result<(), SuperviseError> {
8578 subc_cgroup::apply(command, path).map_err(|source| SuperviseError::Cgroup {
8579 module_id: spec.module_id.clone(),
8580 source,
8581 })
8582}
8583
8584fn capture_retention(spec: &ModuleSpec) -> Retention {
8585 let defaults = Retention::default();
8586 let value = |name: &str| {
8587 spec.env
8588 .iter()
8589 .rev()
8590 .find_map(|(key, value)| (key == name).then_some(value.as_str()))
8591 };
8592 Retention {
8593 max_file_mb: value(CAPTURE_MAX_FILE_MB_ENV)
8594 .and_then(|value| value.parse().ok())
8595 .unwrap_or(defaults.max_file_mb),
8596 keep: value(CAPTURE_KEEP_ENV)
8597 .and_then(|value| value.parse().ok())
8598 .unwrap_or(defaults.keep),
8599 max_age_days: value(CAPTURE_MAX_AGE_DAYS_ENV)
8600 .and_then(|value| value.parse().ok())
8601 .unwrap_or(defaults.max_age_days),
8602 }
8603}
8604
8605fn generate_launch_nonce() -> Result<String, SuperviseError> {
8608 let mut bytes = [0u8; 32];
8609 getrandom::getrandom(&mut bytes).map_err(|source| SuperviseError::LaunchNonce {
8610 reason: source.to_string(),
8611 })?;
8612 let mut hex = String::with_capacity(64);
8613 for b in bytes {
8614 use std::fmt::Write;
8615 let _ = write!(hex, "{b:02x}");
8616 }
8617 Ok(hex)
8618}
8619
8620fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
8623 if a.len() != b.len() {
8624 return false;
8625 }
8626 let mut diff = 0u8;
8627 for (x, y) in a.iter().zip(b.iter()) {
8628 diff |= x ^ y;
8629 }
8630 diff == 0
8631}
8632
8633#[cfg(not(windows))]
8637fn observe_spawned_image(pid: u32) -> Option<subc_os::Observation> {
8638 subc_os::Process::open(pid)
8639 .ok()
8640 .flatten()
8641 .and_then(|process| process.observe())
8642}
8643
8644#[cfg(windows)]
8647fn windows_spawn_capture(spec: &ModuleSpec) -> io::Result<subc_os::ExecutableCapture> {
8648 let mut program = spec.program.clone();
8649 if program.is_absolute() || program.components().count() > 1 {
8650 match subc_os::ExecutableCapture::open(&program) {
8653 Ok(capture) => return Ok(capture),
8654 Err(error)
8655 if error.kind() == io::ErrorKind::NotFound && program.extension().is_none() => {}
8656 Err(error) => return Err(error),
8657 }
8658 program.set_extension("exe");
8659 return subc_os::ExecutableCapture::open(&program);
8660 }
8661 if program.extension().is_none() {
8662 program.set_extension("exe");
8663 }
8664 let custom_path = spec
8665 .env
8666 .iter()
8667 .rev()
8668 .find(|(key, _)| key.eq_ignore_ascii_case("PATH"));
8669 let mut directories = Vec::new();
8670 if let Some(parent) = std::env::current_exe()?.parent() {
8673 directories.push(parent.to_path_buf());
8674 }
8675 directories.push(std::env::current_dir()?);
8676 if let Some(windows) = std::env::var_os("SystemRoot") {
8677 let windows = PathBuf::from(windows);
8678 directories.push(windows.join("System32"));
8679 directories.push(windows.clone());
8680 }
8681 let path = custom_path
8682 .map(|(_, value)| std::ffi::OsString::from(value))
8683 .or_else(|| std::env::var_os("PATH"));
8684 if let Some(path) = path {
8685 directories.extend(std::env::split_paths(&path));
8686 }
8687 for directory in directories {
8688 match subc_os::ExecutableCapture::open(&directory.join(&program)) {
8689 Ok(capture) => return Ok(capture),
8690 Err(error) if error.kind() == io::ErrorKind::NotFound => continue,
8691 Err(error) => return Err(error),
8692 }
8693 }
8694 Err(io::Error::new(
8695 io::ErrorKind::NotFound,
8696 "resolved executable not found",
8697 ))
8698}
8699
8700fn spawn_and_mark_running(
8701 spec: &ModuleSpec,
8702 runtime: &SupervisorRuntimeConfig,
8703 snapshot: &SharedSnapshot,
8704) -> Result<SupervisedChild, SuperviseError> {
8705 let activity_spawn = runtime.begin_activity_spawn(&spec.module_id);
8706 let child = spawn_child(
8707 spec,
8708 runtime.connection_file_path.as_deref(),
8709 runtime.supervisor_handle.as_ref(),
8710 &runtime.stderr_ring,
8711 runtime.capture_logs_dir.as_deref(),
8712 &runtime.child_roster,
8713 #[cfg(target_os = "linux")]
8714 runtime.cgroup_placement.as_ref(),
8715 )?;
8716 set_running(snapshot, &child, &spec.module_id, &runtime.spawn_events)?;
8717 if let Some(spawn) = activity_spawn {
8718 spawn.complete(snapshot);
8719 }
8720 Ok(child)
8721}
8722
8723enum RegistrationWaitOutcome {
8724 Registered,
8725 Exited(ExitReport),
8726 TimedOut,
8727}
8728
8729struct ReloadRegistrationFailure {
8730 exit_report: ExitReport,
8731 reason: String,
8732}
8733
8734#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8735enum BusyGaugeObservation {
8736 Quiescent,
8737 Busy,
8738 Omitted,
8739}
8740
8741fn busy_gauge_observation(metrics: Option<&Value>, gauges: &[String]) -> BusyGaugeObservation {
8742 let Some(metrics) = metrics.and_then(Value::as_object) else {
8743 return BusyGaugeObservation::Omitted;
8744 };
8745 let mut sum = 0u128;
8746 for gauge in gauges {
8747 let Some(value) = metrics.get(gauge) else {
8748 return BusyGaugeObservation::Omitted;
8749 };
8750 let Some(value) = value.as_u64() else {
8751 return BusyGaugeObservation::Busy;
8752 };
8753 sum = sum.saturating_add(u128::from(value));
8754 }
8755 if sum == 0 {
8756 BusyGaugeObservation::Quiescent
8757 } else {
8758 BusyGaugeObservation::Busy
8759 }
8760}
8761
8762fn declared_busy_gauges(
8763 registry: &Registry,
8764 module_id: &str,
8765) -> Result<Vec<String>, SuperviseError> {
8766 busy_gauges_of(
8767 registry
8768 .get_module(module_id)
8769 .map_err(SuperviseError::Registry)?,
8770 )
8771}
8772
8773fn declared_busy_gauges_for_connection(
8777 registry: &Registry,
8778 connection_id: ConnectionId,
8779) -> Result<Vec<String>, SuperviseError> {
8780 busy_gauges_of(
8781 registry
8782 .get_module_by_connection(connection_id)
8783 .map_err(SuperviseError::Registry)?,
8784 )
8785}
8786
8787fn busy_gauges_of(
8788 registration: Option<crate::registry::ModuleRegistration>,
8789) -> Result<Vec<String>, SuperviseError> {
8790 let Some(registration) = registration else {
8791 return Ok(Vec::new());
8792 };
8793 let Some(self_signals) = registration.manifest.self_signals else {
8794 return Ok(Vec::new());
8795 };
8796
8797 let mut gauges = Vec::new();
8798 for declaration in self_signals {
8799 if declaration.kind != SelfSignalKind::Busy {
8800 continue;
8801 }
8802 match declaration.anchored_to {
8803 SignalAnchor::HealthGauges { gauges: declared } if !declared.is_empty() => {
8804 gauges.extend(declared)
8805 }
8806 _ => {
8807 gauges.push(String::new());
8810 }
8811 }
8812 }
8813 Ok(gauges)
8814}
8815
8816async fn wait_for_forwarding_quiescence(
8821 forwarding: &ForwardingTable,
8822 module_id: &str,
8823 runtime: &SupervisorRuntimeConfig,
8824 endpoint: crate::ModuleEndpointId,
8825 deadline: Instant,
8826 busy_gauges: &[String],
8827 scope: DrainScope,
8828) -> Result<bool, SuperviseError> {
8829 let mut gauges_quiescent = busy_gauges.is_empty();
8830 let mut next_probe_at = Instant::now();
8831 let mut omission_counted = false;
8832
8833 loop {
8834 let now = Instant::now();
8835 if !busy_gauges.is_empty() && now >= next_probe_at && now < deadline {
8836 let report = match scope {
8837 DrainScope::Active => probe_module_health(module_id, runtime, Some(deadline)).await,
8838 DrainScope::Endpoint(endpoint) => {
8839 probe_endpoint_health(endpoint, runtime, Some(deadline)).await
8840 }
8841 };
8842 gauges_quiescent = match report {
8843 Ok(report) => match busy_gauge_observation(report.metrics.as_ref(), busy_gauges) {
8844 BusyGaugeObservation::Quiescent => true,
8845 BusyGaugeObservation::Busy => false,
8846 BusyGaugeObservation::Omitted => {
8847 if !omission_counted {
8848 forwarding
8849 .counters()
8850 .increment_drains_with_undeclared_gauge();
8851 omission_counted = true;
8852 }
8853 false
8854 }
8855 },
8856 Err(err) => {
8857 warn!(
8858 module_id,
8859 error = %err,
8860 "drain health.check did not produce declared busy gauges; treating module as busy"
8861 );
8862 false
8863 }
8864 };
8865 next_probe_at = Instant::now() + runtime.health.cadence.max(REGISTRY_RELEASE_POLL);
8866 }
8867
8868 let in_flight = forwarding
8869 .endpoint_in_flight_count(endpoint)
8870 .map_err(SuperviseError::Forwarding)?;
8871 if in_flight == 0 && gauges_quiescent {
8872 return Ok(true);
8873 }
8874
8875 let now = Instant::now();
8876 if now >= deadline {
8877 return Ok(false);
8878 }
8879 let mut wait = deadline
8880 .saturating_duration_since(now)
8881 .min(REGISTRY_RELEASE_POLL);
8882 if !busy_gauges.is_empty() {
8883 wait = wait.min(next_probe_at.saturating_duration_since(now));
8884 }
8885 sleep(wait).await;
8886 }
8887}
8888
8889fn drained_after_quiescence_wait(wait_result: &Result<bool, SuperviseError>) -> bool {
8897 match wait_result {
8898 Ok(drained) => *drained,
8899 Err(_) => false,
8900 }
8901}
8902
8903fn send_route_goodbyes(forwarding: &ForwardingTable, released_routes: Vec<GoodbyeTarget>) {
8904 for released in released_routes {
8905 let frame = match Frame::build_with_version(
8906 released.negotiated_ver,
8907 FrameType::Goodbye,
8908 control_flags(),
8909 released.channel,
8910 released.epoch,
8911 0,
8912 Vec::new(),
8913 ) {
8914 Ok(frame) => frame,
8915 Err(err) => {
8916 warn!(
8917 route_channel = released.channel,
8918 error = %err,
8919 "failed to build supervisor drain route GOODBYE frame"
8920 );
8921 continue;
8922 }
8923 };
8924 if !released.close_on_delivery_failure() {
8925 crate::forwarding::send_module_route_goodbye(
8926 &forwarding.counters(),
8927 &released.sink,
8928 frame,
8929 released.module_id.as_deref(),
8930 "supervisor drain",
8931 );
8932 continue;
8933 }
8934 if let Err(err) = released.sink.try_send(frame) {
8935 warn!(
8936 target_connection_id = released.connection_id.get(),
8937 route_channel = released.channel,
8938 error = %err,
8939 "supervisor drain route GOODBYE was not delivered to client; closing target connection"
8940 );
8941 let _ = forwarding.escalate_client_delivery_failure(
8942 released.connection_id,
8943 released.channel,
8944 released.epoch,
8945 CloseReason::new(
8946 "route_goodbye_delivery_failed",
8947 format!(
8948 "failed to enqueue supervisor drain route GOODBYE for channel {}: {err}",
8949 released.channel
8950 ),
8951 ),
8952 crate::forwarding::UndeliveredFrame {
8953 module_id: released.module_id.as_deref(),
8954 sink: &released.sink,
8955 },
8956 );
8957 }
8958 }
8959}
8960
8961fn send_module_draining(
8962 module_id: &str,
8963 reason: RouteCloseReason,
8964 deadline_ms: u64,
8965 target: &ModuleDrainTarget,
8966) {
8967 let body = match serde_json::to_vec(&ModuleControlCommand::Draining {
8968 reason,
8969 deadline_ms,
8970 }) {
8971 Ok(body) => body,
8972 Err(err) => {
8973 warn!(
8974 module_id,
8975 error = %err,
8976 "failed to encode module draining command"
8977 );
8978 return;
8979 }
8980 };
8981 let frame = match Frame::build_with_version(
8982 target.negotiated_ver,
8983 FrameType::Push,
8984 control_flags(),
8985 0,
8986 0,
8987 0,
8988 body,
8989 ) {
8990 Ok(frame) => frame,
8991 Err(err) => {
8992 warn!(
8993 module_id,
8994 error = %err,
8995 "failed to build module draining command frame"
8996 );
8997 return;
8998 }
8999 };
9000 if let Err(err) = target.sink.try_send(frame) {
9001 warn!(
9002 module_id,
9003 target_connection_id = target.endpoint.connection_id.get(),
9004 error = %err,
9005 "module draining command was not delivered to peer"
9006 );
9007 }
9008}
9009
9010fn module_goodbye_frame(module_id: &str, negotiated_ver: u8) -> Option<Frame> {
9012 match Frame::build_with_version(
9013 negotiated_ver,
9014 FrameType::Goodbye,
9015 control_flags(),
9016 0,
9017 0,
9018 0,
9019 Vec::new(),
9020 ) {
9021 Ok(frame) => Some(frame),
9022 Err(err) => {
9023 warn!(
9024 module_id,
9025 error = %err,
9026 "failed to build module GOODBYE frame"
9027 );
9028 None
9029 }
9030 }
9031}
9032
9033#[cfg(unix)]
9047async fn send_module_goodbyes_for_daemon_shutdown(
9048 forwarding: &Arc<ForwardingTable>,
9049 reason: &CloseReason,
9050 wait_for_flush: bool,
9051) {
9052 const GOODBYE_BUDGET: Duration = Duration::from_millis(500);
9053 let targets = match forwarding.module_connections() {
9054 Ok(targets) => targets,
9055 Err(err) => {
9056 warn!(error = %err, "could not list module connections for shutdown GOODBYE");
9057 return;
9058 }
9059 };
9060 let deadline = Instant::now() + GOODBYE_BUDGET;
9061 let mut sends = tokio::task::JoinSet::new();
9062 for target in targets {
9063 let Some(frame) = module_goodbye_frame(&target.module_id, target.negotiated_ver) else {
9064 continue;
9065 };
9066 if !wait_for_flush {
9067 if let Err(err) = target.sink.try_send(frame) {
9068 debug!(
9069 module_id = %target.module_id,
9070 error = %err,
9071 "shutdown module GOODBYE was not queued"
9072 );
9073 }
9074 continue;
9075 }
9076 let forwarding = Arc::clone(forwarding);
9077 let reason = reason.clone();
9078 sends.spawn(async move {
9079 match timeout_at(deadline, target.sink.send_flushed(frame)).await {
9080 Ok(Ok(())) => {}
9081 Ok(Err(err)) => debug!(
9082 module_id = %target.module_id,
9083 error = %err,
9084 "module connection closed before its shutdown GOODBYE was written"
9085 ),
9086 Err(_) => warn!(
9087 module_id = %target.module_id,
9088 budget = ?GOODBYE_BUDGET,
9089 "shutdown module GOODBYE was not written within its budget; closing anyway"
9090 ),
9091 }
9092 forwarding.request_connection_close(target.endpoint.connection_id, reason);
9093 });
9094 }
9095 while sends.join_next().await.is_some() {}
9097}
9098
9099fn send_module_goodbye(module_id: &str, forwarding: &ForwardingTable, target: &ModuleDrainTarget) {
9100 let Some(frame) = module_goodbye_frame(module_id, target.negotiated_ver) else {
9101 return;
9102 };
9103 if let Err(err) = target.sink.try_send(frame) {
9104 warn!(
9105 module_id,
9106 target_connection_id = target.endpoint.connection_id.get(),
9107 error = %err,
9108 "supervisor drain module GOODBYE was not delivered to peer; closing module connection"
9109 );
9110 forwarding.request_connection_close(
9111 target.endpoint.connection_id,
9112 CloseReason::new(
9113 "module_goodbye_delivery_failed",
9114 format!("failed to enqueue supervisor drain module GOODBYE for module '{module_id}': {err}"),
9115 ),
9116 );
9117 }
9118}
9119
9120#[derive(Clone, Copy)]
9121struct ForwardingDrainContext<'a> {
9122 spec: &'a ModuleSpec,
9123 runtime: &'a SupervisorRuntimeConfig,
9124 registry: &'a Registry,
9125 scope: DrainScope,
9126}
9127
9128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9130enum DrainScope {
9131 Active,
9134 Endpoint(crate::ModuleEndpointId),
9139}
9140
9141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9149enum StopNotice {
9150 SentOverConnection,
9153 NoConnection,
9157 NotSent,
9161}
9162
9163async fn begin_forwarding_drain(
9164 spec: &ModuleSpec,
9165 runtime: &SupervisorRuntimeConfig,
9166 registry: &Registry,
9167 snapshot: &SharedSnapshot,
9168 enabled: Option<bool>,
9169 reason: RouteCloseReason,
9170) -> Result<StopNotice, SuperviseError> {
9171 let Some(forwarding) = runtime.forwarding.as_ref() else {
9172 return Err(SuperviseError::ReloadUnavailable {
9173 module_id: spec.module_id.clone(),
9174 reason: "supervisor was not configured with a forwarding table".to_string(),
9175 });
9176 };
9177
9178 begin_forwarding_drain_with(
9179 forwarding,
9180 ForwardingDrainContext {
9181 spec,
9182 runtime,
9183 registry,
9184 scope: DrainScope::Active,
9185 },
9186 snapshot,
9187 enabled,
9188 reason,
9189 runtime.drain_timeout,
9190 )
9191 .await
9192}
9193
9194async fn begin_forwarding_drain_if_configured(
9195 spec: &ModuleSpec,
9196 runtime: &SupervisorRuntimeConfig,
9197 registry: &Registry,
9198 snapshot: &SharedSnapshot,
9199 enabled: Option<bool>,
9200 reason: RouteCloseReason,
9201) -> Result<StopNotice, SuperviseError> {
9202 begin_forwarding_drain_with_timeout(
9203 spec,
9204 runtime,
9205 registry,
9206 snapshot,
9207 enabled,
9208 reason,
9209 runtime.drain_timeout,
9210 )
9211 .await
9212}
9213
9214async fn begin_forwarding_drain_with_timeout(
9218 spec: &ModuleSpec,
9219 runtime: &SupervisorRuntimeConfig,
9220 registry: &Registry,
9221 snapshot: &SharedSnapshot,
9222 enabled: Option<bool>,
9223 reason: RouteCloseReason,
9224 drain_timeout: Duration,
9225) -> Result<StopNotice, SuperviseError> {
9226 let Some(forwarding) = runtime.forwarding.as_ref() else {
9227 return Ok(StopNotice::NotSent);
9228 };
9229
9230 begin_forwarding_drain_with(
9231 forwarding,
9232 ForwardingDrainContext {
9233 spec,
9234 runtime,
9235 registry,
9236 scope: DrainScope::Active,
9237 },
9238 snapshot,
9239 enabled,
9240 reason,
9241 drain_timeout,
9242 )
9243 .await
9244}
9245
9246async fn begin_forwarding_drain_with(
9247 forwarding: &ForwardingTable,
9248 context: ForwardingDrainContext<'_>,
9249 snapshot: &SharedSnapshot,
9250 enabled: Option<bool>,
9251 reason: RouteCloseReason,
9252 drain_timeout: Duration,
9253) -> Result<StopNotice, SuperviseError> {
9254 let ForwardingDrainContext {
9255 spec,
9256 runtime,
9257 registry,
9258 scope,
9259 } = context;
9260 debug_assert_ne!(reason, RouteCloseReason::Crash);
9261 let terminal = matches!(reason, RouteCloseReason::Disable);
9262 let drain_started_at = Instant::now();
9263 let drain_deadline = drain_started_at + drain_timeout;
9264 let deadline_ms =
9265 unix_ms_now().saturating_add(u64::try_from(drain_timeout.as_millis()).unwrap_or(u64::MAX));
9266 let busy_gauges = match scope {
9267 DrainScope::Active => declared_busy_gauges(registry, &spec.module_id)?,
9268 DrainScope::Endpoint(endpoint) => {
9269 declared_busy_gauges_for_connection(registry, endpoint.connection_id)?
9270 }
9271 };
9272
9273 let gate_started = Instant::now();
9276 let drain_target = match scope {
9277 DrainScope::Active => forwarding.begin_module_drain(&spec.module_id, reason),
9278 DrainScope::Endpoint(endpoint) => forwarding.begin_endpoint_drain(endpoint, reason),
9279 }
9280 .map_err(SuperviseError::Forwarding)?;
9281 info!(
9286 module_id = %spec.module_id,
9287 ?reason,
9288 gate_ms = u64::try_from(gate_started.elapsed().as_millis()).unwrap_or(u64::MAX),
9289 connected = drain_target.is_some(),
9290 "module drain began; route admission closed"
9291 );
9292 if scope == DrainScope::Active {
9293 update_snapshot(snapshot, Some(&spec.module_id), |state| {
9294 state.state = ModuleState::Draining;
9295 state.draining_to_replace =
9296 matches!(reason, RouteCloseReason::Restart | RouteCloseReason::Reload);
9297 if let Some(enabled) = enabled {
9298 state.enabled = enabled;
9299 }
9300 })?;
9301 }
9302
9303 let Some(target) = drain_target.as_ref() else {
9304 return Ok(StopNotice::NoConnection);
9308 };
9309 {
9310 send_module_draining(&spec.module_id, reason, deadline_ms, target);
9311 let routes = forwarding
9312 .endpoint_routes(target.endpoint)
9313 .map_err(SuperviseError::Forwarding)?;
9314 let routes_notified = routes.len();
9315 crate::control::send_route_control_pushes(
9316 forwarding,
9317 routes.clone(),
9318 ClientControlPush::RouteClosing {
9319 module_id: spec.module_id.clone(),
9320 channels: Vec::new(),
9321 reason,
9322 },
9323 );
9324 send_route_goodbyes(forwarding, target.abandoned_bindings.clone());
9325
9326 let wait_result = wait_for_forwarding_quiescence(
9332 forwarding,
9333 &spec.module_id,
9334 runtime,
9335 target.endpoint,
9336 drain_deadline,
9337 &busy_gauges,
9338 scope,
9339 )
9340 .await;
9341 let drained = drained_after_quiescence_wait(&wait_result);
9342 if let Err(err) = &wait_result {
9343 error!(
9344 module_id = %spec.module_id,
9345 ?reason,
9346 error = %err,
9347 "forwarding quiescence wait failed after route.closing; forcing route.closed(drained: false) so the client is not left waiting on an unfulfilled promise"
9348 );
9349 } else if !drained {
9350 let holdouts = forwarding
9356 .endpoint_drain_holdouts(target.endpoint)
9357 .unwrap_or_default();
9358 warn!(
9359 module_id = %spec.module_id,
9360 waited = ?drain_timeout,
9361 ?reason,
9362 held_requests = holdouts.requests,
9363 held_routes = holdouts.routes,
9364 total_routes = holdouts.total_routes,
9365 top_connections = ?holdouts.top_connections,
9366 held = %holdouts
9369 .held
9370 .iter()
9371 .map(|(channel, corr)| format!("{channel}:{corr}"))
9372 .collect::<Vec<_>>()
9373 .join(","),
9374 "route drain timed out before request quiescence; forcing teardown"
9375 );
9376 }
9377 crate::control::send_route_control_pushes(
9378 forwarding,
9379 routes,
9380 ClientControlPush::RouteClosed {
9381 module_id: spec.module_id.clone(),
9382 channels: Vec::new(),
9383 reason,
9384 drained,
9385 abandoned: target.abandoned_bindings.len() as u32,
9386 excluded_subscriptions: target.excluded_subscriptions,
9387 terminal: Some(terminal),
9388 },
9389 );
9390 wait_result?;
9391
9392 let released_routes = match forwarding.release_module_endpoint_routes(target.endpoint) {
9398 Ok(routes) => routes,
9399 Err(err) => {
9400 warn!(
9401 module_id = %spec.module_id,
9402 ?reason,
9403 error = %err,
9404 "failed to release module endpoint routes after route.closed; module GOODBYE will still be sent"
9405 );
9406 send_module_goodbye(&spec.module_id, forwarding, target);
9407 return Err(SuperviseError::Forwarding(err));
9408 }
9409 };
9410 let route_goodbye_count = released_routes.len();
9411 send_route_goodbyes(forwarding, released_routes);
9412 send_module_goodbye(&spec.module_id, forwarding, target);
9413
9414 info!(
9420 module_id = %spec.module_id,
9421 ?reason,
9422 routes_notified,
9423 route_goodbyes = route_goodbye_count,
9424 abandoned_reservations = target.abandoned_bindings.len(),
9425 excluded_subscriptions = target.excluded_subscriptions,
9426 drained,
9427 "module drain complete; consumers notified via route.closing/route.closed pushes and per-route GOODBYE frames"
9428 );
9429 }
9430
9431 Ok(StopNotice::SentOverConnection)
9432}
9433
9434async fn wait_for_registration_after_reload(
9437 registry: &Registry,
9438 module_id: &str,
9439 snapshot: &SharedSnapshot,
9440 child: &mut SupervisedChild,
9441 wait: Duration,
9442) -> Result<RegistrationWaitOutcome, SuperviseError> {
9443 wait_for_slot_registration(
9444 registry,
9445 crate::registry::RegistrationSlot::Active(module_id),
9446 module_id,
9447 snapshot,
9448 child,
9449 wait,
9450 )
9451 .await
9452}
9453
9454async fn wait_for_slot_registration(
9462 registry: &Registry,
9463 slot: crate::registry::RegistrationSlot<'_>,
9464 module_id: &str,
9465 snapshot: &SharedSnapshot,
9466 child: &mut SupervisedChild,
9467 wait: Duration,
9468) -> Result<RegistrationWaitOutcome, SuperviseError> {
9469 let deadline = Instant::now() + wait;
9470 loop {
9471 if registry
9472 .registration(slot)
9473 .map_err(SuperviseError::Registry)?
9474 .is_some()
9475 {
9476 return Ok(RegistrationWaitOutcome::Registered);
9477 }
9478
9479 let now = Instant::now();
9480 if now >= deadline {
9481 return Ok(RegistrationWaitOutcome::TimedOut);
9482 }
9483 let remaining = deadline.saturating_duration_since(now);
9484 let poll = remaining.min(REGISTRY_RELEASE_POLL);
9485
9486 tokio::select! {
9487 wait_result = child.wait() => {
9488 let status = wait_result.map_err(|source| SuperviseError::Wait {
9489 module_id: module_id.to_string(),
9490 source,
9491 })?;
9492 return Ok(RegistrationWaitOutcome::Exited(classify_reaped_child_exit(
9493 snapshot,
9494 child,
9495 &status,
9496 )));
9497 }
9498 _ = sleep(poll) => {}
9499 }
9500 }
9501}
9502
9503fn registration_failure_exit_report(mut exit_report: ExitReport) -> ExitReport {
9504 if exit_report.kind != ExitKind::DeliberateSeverance {
9507 exit_report.kind = ExitKind::Crash;
9508 }
9509 exit_report
9510}
9511
9512async fn handle_reload_child_registration_failure(
9513 spec: &ModuleSpec,
9514 runtime: &SupervisorRuntimeConfig,
9515 registry: &Registry,
9516 process_liveness: &SupervisorProcessLiveness,
9517 snapshot: &SharedSnapshot,
9518 _child: &mut Option<SupervisedChild>,
9519 failure: ReloadRegistrationFailure,
9520) -> Result<(), SuperviseError> {
9521 let ReloadRegistrationFailure {
9522 exit_report,
9523 reason,
9524 } = failure;
9525 match on_child_exit(
9526 spec,
9527 runtime.restart_policy,
9528 registry,
9529 snapshot,
9530 &runtime.terminal_ring,
9531 &runtime.spawn_events,
9532 &runtime.child_roster,
9533 exit_report,
9534 )
9535 .await
9536 {
9537 NextAction::Stop {
9538 registration_released,
9539 } => {
9540 if registration_released {
9541 process_liveness.untrack_if_current(&spec.module_id, snapshot);
9542 }
9543 }
9544 NextAction::Restart { schedule } => {
9545 let delay = schedule.map_or(runtime.restart_policy.delay_for_restart(0), |schedule| {
9546 schedule.delay
9547 });
9548 if let Some(schedule) = schedule {
9549 log_crash_respawn(&spec.module_id, schedule);
9550 }
9551 schedule_respawn(
9552 runtime,
9553 snapshot,
9554 &spec.module_id,
9555 delay,
9556 RespawnKind::Spawn,
9557 )?;
9558 }
9559 }
9560 Err(SuperviseError::ReloadFailed {
9561 module_id: spec.module_id.clone(),
9562 reason,
9563 })
9564}
9565
9566async fn handle_reload_spawn_failure(
9567 spec: &ModuleSpec,
9568 runtime: &SupervisorRuntimeConfig,
9569 process_liveness: &SupervisorProcessLiveness,
9570 snapshot: &SharedSnapshot,
9571 _child: &mut Option<SupervisedChild>,
9572 reason: String,
9573) -> Result<(), SuperviseError> {
9574 let now = Instant::now();
9575 let mut schedule = None;
9576 update_snapshot(snapshot, Some(&spec.module_id), |state| {
9577 clear_current_process_facts(state);
9578 if state.enabled {
9579 schedule = state.next_crash_restart(&runtime.restart_policy, now);
9580 state.state = if schedule.is_some() {
9581 ModuleState::Restarting
9582 } else {
9583 ModuleState::Failed
9584 };
9585 } else {
9586 state.state = ModuleState::Disabled;
9587 }
9588 })?;
9589 if let Some(schedule) = schedule {
9590 schedule_respawn(
9591 runtime,
9592 snapshot,
9593 &spec.module_id,
9594 schedule.delay,
9595 RespawnKind::Spawn,
9596 )?;
9597 } else {
9598 process_liveness.untrack_if_current(&spec.module_id, snapshot);
9599 }
9600 Err(SuperviseError::ReloadFailed {
9601 module_id: spec.module_id.clone(),
9602 reason,
9603 })
9604}
9605
9606fn control_flags() -> Flags {
9607 Flags::new(false, Priority::Passive, false)
9608}
9609
9610#[allow(clippy::too_many_arguments)]
9611async fn drain_optional_child(
9612 module_id: &str,
9613 protocol: ModuleProtocol,
9614 stop_notice: StopNotice,
9615 registry: &Registry,
9616 forwarding: Option<&ForwardingTable>,
9617 snapshot: &SharedSnapshot,
9618 terminal_ring: &Arc<Mutex<TerminalRing>>,
9619 spawn_events: &SpawnEventFeed,
9620 child: &mut Option<SupervisedChild>,
9621 drain_timeout: Duration,
9622 final_state: ModuleState,
9623 enabled: Option<bool>,
9624) -> Result<(), SuperviseError> {
9625 if let Some(child) = child.take() {
9626 drain_child_to_state(
9627 module_id,
9628 protocol,
9629 stop_notice,
9630 registry,
9631 forwarding,
9632 snapshot,
9633 terminal_ring,
9634 spawn_events,
9635 child,
9636 drain_timeout,
9637 final_state,
9638 enabled,
9639 )
9640 .await
9641 } else {
9642 update_snapshot(snapshot, Some(module_id), |state| {
9643 state.state = final_state;
9644 if let Some(enabled) = enabled {
9645 state.enabled = enabled;
9646 }
9647 clear_current_process_facts(state);
9648 })?;
9649 release_dead_registration(registry, forwarding, snapshot, module_id).await
9650 }
9651}
9652
9653#[allow(clippy::too_many_arguments)]
9654async fn drain_child_to_state(
9655 module_id: &str,
9656 _protocol: ModuleProtocol,
9657 stop_notice: StopNotice,
9658 registry: &Registry,
9659 forwarding: Option<&ForwardingTable>,
9660 snapshot: &SharedSnapshot,
9661 terminal_ring: &Arc<Mutex<TerminalRing>>,
9662 spawn_events: &SpawnEventFeed,
9663 mut child: SupervisedChild,
9664 drain_timeout: Duration,
9665 final_state: ModuleState,
9666 enabled: Option<bool>,
9667) -> Result<(), SuperviseError> {
9668 let protocol = child.protocol;
9669 update_snapshot(snapshot, Some(module_id), |state| {
9670 state.state = ModuleState::Draining;
9671 state.draining_to_replace = final_state == ModuleState::Restarting;
9672 if let Some(enabled) = enabled {
9673 state.enabled = enabled;
9674 }
9675 })?;
9676
9677 if stop_notice != StopNotice::SentOverConnection {
9688 if protocol == ModuleProtocol::Subc && stop_notice == StopNotice::NoConnection {
9689 info!(
9690 module_id,
9691 pid = child.pid,
9692 budget_ms = u64::try_from(drain_timeout.as_millis()).unwrap_or(u64::MAX),
9693 "module has no connection yet; requesting stop by signal"
9694 );
9695 }
9696 request_graceful_stop(module_id, &child);
9697 }
9698
9699 let exit_report = match timeout(drain_timeout, child.wait()).await {
9700 Ok(Ok(status)) => classify_reaped_child_exit(snapshot, &child, &status),
9701 Ok(Err(source)) => {
9702 fail_after_wait_error(
9703 module_id,
9704 snapshot,
9705 terminal_ring,
9706 spawn_events,
9707 format!("failed to wait during drain: {source}"),
9708 );
9709 return Err(SuperviseError::Wait {
9710 module_id: module_id.to_string(),
9711 source,
9712 });
9713 }
9714 Err(_) => {
9715 warn!(
9728 module_id,
9729 pid = child.pid,
9730 budget_ms = u64::try_from(drain_timeout.as_millis()).unwrap_or(u64::MAX),
9731 reason = ?final_state,
9732 ?stop_notice,
9733 "drain budget expired before the module exited; killing it"
9734 );
9735 child.start_kill().map_err(|source| {
9736 fail_after_wait_error(
9737 module_id,
9738 snapshot,
9739 terminal_ring,
9740 spawn_events,
9741 format!("failed to kill during drain: {source}"),
9742 );
9743 SuperviseError::Kill {
9744 module_id: module_id.to_string(),
9745 source,
9746 }
9747 })?;
9748 let status = child.wait().await.map_err(|source| {
9749 fail_after_wait_error(
9750 module_id,
9751 snapshot,
9752 terminal_ring,
9753 spawn_events,
9754 format!("failed to wait after drain kill: {source}"),
9755 );
9756 SuperviseError::Wait {
9757 module_id: module_id.to_string(),
9758 source,
9759 }
9760 })?;
9761 classify_reaped_child_exit(snapshot, &child, &status)
9762 }
9763 };
9764
9765 let detail = lock_snapshot(snapshot)?.drain_disposition_detail.take();
9766 publish_after_terminal_record(
9767 module_id,
9768 || {
9769 record_terminal_with_detail(
9770 module_id,
9771 terminal_ring,
9772 spawn_events,
9773 &exit_report,
9774 terminal_disposition(final_state),
9775 detail,
9776 )
9777 },
9778 || {
9779 update_snapshot(snapshot, Some(module_id), |state| {
9780 state.state = final_state;
9781 if let Some(enabled) = enabled {
9782 state.enabled = enabled;
9783 }
9784 clear_current_process_facts(state);
9785 state.last_exit = Some(exit_report.clone());
9786 if exit_report.kind == ExitKind::DeliberateSeverance {
9787 state.lifetime_restarts += 1;
9788 }
9789 })
9790 },
9791 )?;
9792 child.drain_stderr(module_id).await;
9793
9794 release_dead_registration(registry, forwarding, snapshot, module_id).await
9795}
9796
9797#[cfg(unix)]
9817fn request_graceful_stop(module_id: &str, child: &SupervisedChild) {
9818 let Some(pid) = child
9819 .id()
9820 .and_then(|pid| i32::try_from(pid).ok())
9821 .and_then(rustix::process::Pid::from_raw)
9822 else {
9823 debug!(
9824 module_id,
9825 "no pid to signal for teardown; falling through to the drain wait"
9826 );
9827 return;
9828 };
9829 match rustix::process::kill_process(pid, rustix::process::Signal::TERM) {
9830 Ok(()) => debug!(
9831 module_id,
9832 "sent SIGTERM to a module nothing else asked to stop"
9833 ),
9834 Err(err) => debug!(
9835 module_id,
9836 error = %err,
9837 "SIGTERM to module failed; the drain wait and kill still apply"
9838 ),
9839 }
9840}
9841
9842#[cfg(not(unix))]
9850fn request_graceful_stop(module_id: &str, _child: &SupervisedChild) {
9851 debug!(
9852 module_id,
9853 "no graceful stop signal exists on this platform; teardown of a module nothing asked to stop waits, then kills"
9854 );
9855}
9856
9857fn terminal_disposition(final_state: ModuleState) -> TerminalDisposition {
9858 match final_state {
9859 ModuleState::Stopped => TerminalDisposition::Stopped,
9860 ModuleState::Disabled => TerminalDisposition::Disabled,
9861 ModuleState::Restarting => TerminalDisposition::Restarting,
9862 ModuleState::Failed => TerminalDisposition::Failed,
9863 ModuleState::Starting
9864 | ModuleState::Running
9865 | ModuleState::Unresponsive
9866 | ModuleState::Draining => {
9867 unreachable!("terminal exits only finish in terminal or restarting states")
9868 }
9869 }
9870}
9871
9872async fn release_dead_registration(
9880 registry: &Registry,
9881 forwarding: Option<&ForwardingTable>,
9882 snapshot: &SharedSnapshot,
9883 module_id: &str,
9884) -> Result<(), SuperviseError> {
9885 let result = async {
9886 let registration = registry
9887 .get_module(module_id)
9888 .map_err(SuperviseError::Registry)?;
9889 match wait_for_registration_release(registry, module_id, REGISTRY_RELEASE_TIMEOUT).await {
9890 Ok(()) => return Ok(()),
9891 Err(SuperviseError::RegistrationStillActive { .. }) => {}
9892 Err(err) => return Err(err),
9893 }
9894 let pid = lock_snapshot(snapshot)?.reaped_pid;
9895 if let (Some(registration), Some(forwarding), Some(pid)) = (registration, forwarding, pid) {
9896 warn!(
9897 module_id,
9898 pid,
9899 connection_id = registration.connection_id.get(),
9900 "reaped module registration outlived release grace; closing dead connection"
9901 );
9902 forwarding.request_connection_close(
9903 registration.connection_id,
9904 CloseReason::new(
9905 "supervised_process_reaped",
9906 format!("module '{module_id}' pid {pid} exited"),
9907 ),
9908 );
9909 wait_for_slot_registration_release(
9910 registry,
9911 crate::registry::RegistrationSlot::Connection(registration.connection_id),
9912 REGISTRY_RELEASE_TIMEOUT,
9913 )
9914 .await?;
9915 }
9916 wait_for_registration_release(registry, module_id, Duration::ZERO).await
9917 }
9918 .await;
9919 if let Err(err) = &result {
9920 fail_snapshot(snapshot, Some(module_id), None);
9921 error!(module_id, error = %err, "registration release failed after child exit; module is failed and start can retry");
9922 }
9923 result
9924}
9925
9926async fn wait_for_registration_release(
9929 registry: &Registry,
9930 module_id: &str,
9931 wait: Duration,
9932) -> Result<(), SuperviseError> {
9933 wait_for_slot_registration_release(
9934 registry,
9935 crate::registry::RegistrationSlot::Active(module_id),
9936 wait,
9937 )
9938 .await
9939}
9940
9941async fn wait_for_slot_registration_release(
9949 registry: &Registry,
9950 slot: crate::registry::RegistrationSlot<'_>,
9951 wait: Duration,
9952) -> Result<(), SuperviseError> {
9953 let deadline = Instant::now() + wait;
9954 let mut release_events = registration_release_events().subscribe();
9955 let still_active = |registration: &crate::registry::ModuleRegistration| {
9956 SuperviseError::RegistrationStillActive {
9957 module_id: registration.manifest.module_id.clone(),
9958 waited: wait,
9959 }
9960 };
9961 loop {
9962 let _observed_generation = *release_events.borrow_and_update();
9963 let Some(registration) = registry
9964 .registration(slot)
9965 .map_err(SuperviseError::Registry)?
9966 else {
9967 return Ok(());
9968 };
9969
9970 let now = Instant::now();
9971 if now >= deadline {
9972 return Err(still_active(®istration));
9973 }
9974
9975 let remaining = deadline.saturating_duration_since(now);
9976 match timeout(remaining, release_events.changed()).await {
9977 Ok(Ok(())) | Ok(Err(_)) => {}
9978 Err(_) => return Err(still_active(®istration)),
9979 }
9980 }
9981}
9982
9983#[cfg(test)]
9984mod slot_registration_wait_tests {
9985 use super::*;
9986 use crate::registry::{ConnectionId, RegistrationSlot};
9987 use subc_protocol::manifest::ModuleManifest;
9988
9989 #[tokio::test]
9990 async fn enable_release_failure_is_failed_and_a_second_enable_retries() {
9991 let registry = Arc::new(Registry::default());
9992 let supervisor = Supervisor::new_for_test(Arc::clone(®istry), RestartPolicy::default());
9993 let runtime = supervisor.runtime_config();
9994 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::disabled()));
9995 let spec = ModuleSpec {
9996 module_id: "enable-stale-registration".to_string(),
9997 program: PathBuf::from("/missing/enable-retry-test"),
9998 args: Vec::new(),
9999 env: Vec::new(),
10000 reserved: false,
10001 reserved_prefixes: Vec::new(),
10002 protocol: ModuleProtocol::Subc,
10003 overlap: Default::default(),
10004 };
10005 let connection = ConnectionId::new(90);
10006 registry
10007 .register_with_control_ops(
10008 ModuleManifest::builder(&spec.module_id, "0.1.0").build(),
10009 1,
10010 connection,
10011 Vec::new(),
10012 )
10013 .unwrap();
10014 let mut child = None;
10015 let err = set_child_enabled(
10016 &spec,
10017 &runtime,
10018 ®istry,
10019 &supervisor.process_liveness,
10020 &snapshot,
10021 &mut child,
10022 true,
10023 )
10024 .await
10025 .unwrap_err();
10026 assert!(matches!(
10027 err,
10028 SuperviseError::RegistrationStillActive { .. }
10029 ));
10030 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
10031 assert!(child.is_none());
10032 registry.deregister_connection(connection).unwrap();
10033 let err = set_child_enabled(
10034 &spec,
10035 &runtime,
10036 ®istry,
10037 &supervisor.process_liveness,
10038 &snapshot,
10039 &mut child,
10040 true,
10041 )
10042 .await
10043 .unwrap_err();
10044 assert!(
10045 matches!(err, SuperviseError::Spawn { .. }),
10046 "second enable must attempt a spawn: {err}"
10047 );
10048 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
10049 }
10050
10051 const INCUMBENT: u64 = 1;
10052 const CANDIDATE: u64 = 2;
10053
10054 fn swapped_registry() -> Arc<Registry> {
10055 let registry = Arc::new(Registry::default());
10056 let manifest = ModuleManifest::builder("m", "0.1.0").build();
10057 registry
10058 .register_with_control_ops(
10059 manifest.clone(),
10060 1,
10061 ConnectionId::new(INCUMBENT),
10062 Vec::new(),
10063 )
10064 .unwrap();
10065 registry
10066 .register_candidate_with_control_ops(
10067 manifest,
10068 1,
10069 ConnectionId::new(CANDIDATE),
10070 Vec::new(),
10071 )
10072 .unwrap();
10073 registry
10074 }
10075
10076 #[tokio::test]
10080 async fn incumbent_release_is_awaited_by_connection_not_by_module_id() {
10081 let registry = swapped_registry();
10082 registry.promote_candidate("m").unwrap().unwrap();
10083
10084 assert!(matches!(
10085 wait_for_registration_release(®istry, "m", Duration::from_millis(50)).await,
10086 Err(SuperviseError::RegistrationStillActive { .. })
10087 ));
10088
10089 assert!(matches!(
10091 wait_for_slot_registration_release(
10092 ®istry,
10093 RegistrationSlot::Connection(ConnectionId::new(INCUMBENT)),
10094 Duration::from_millis(50),
10095 )
10096 .await,
10097 Err(SuperviseError::RegistrationStillActive { .. })
10098 ));
10099
10100 let releaser = Arc::clone(®istry);
10101 let release = tokio::spawn(async move {
10102 sleep(Duration::from_millis(20)).await;
10103 releaser
10104 .deregister_connection(ConnectionId::new(INCUMBENT))
10105 .unwrap();
10106 notify_registration_release();
10107 });
10108 wait_for_slot_registration_release(
10109 ®istry,
10110 RegistrationSlot::Connection(ConnectionId::new(INCUMBENT)),
10111 Duration::from_secs(5),
10112 )
10113 .await
10114 .expect("the incumbent's own registration is released");
10115 release.await.unwrap();
10116 assert!(registry.get_module("m").unwrap().is_some());
10117 }
10118
10119 #[tokio::test]
10122 async fn candidate_slot_wait_ignores_the_incumbents_registration() {
10123 let registry = swapped_registry();
10124 assert!(matches!(
10125 wait_for_slot_registration_release(
10126 ®istry,
10127 RegistrationSlot::Candidate("m"),
10128 Duration::from_millis(50),
10129 )
10130 .await,
10131 Err(SuperviseError::RegistrationStillActive { .. })
10132 ));
10133 registry
10134 .deregister_connection(ConnectionId::new(CANDIDATE))
10135 .unwrap();
10136 wait_for_slot_registration_release(
10137 ®istry,
10138 RegistrationSlot::Candidate("m"),
10139 Duration::from_millis(50),
10140 )
10141 .await
10142 .expect("a candidate slot with no candidate is released");
10143 assert!(registry
10144 .registration(RegistrationSlot::Active("m"))
10145 .unwrap()
10146 .is_some());
10147 }
10148}
10149
10150fn classify_exit(status: &ExitStatus) -> ExitReport {
10151 ExitReport {
10152 kind: if status.success() {
10153 ExitKind::Clean
10154 } else {
10155 ExitKind::Crash
10156 },
10157 code: status.code(),
10158 signal: exit_signal(status),
10159 at_ms: unix_ms_now(),
10160 }
10161}
10162
10163fn wait_error_exit_report() -> ExitReport {
10169 ExitReport {
10170 kind: ExitKind::Crash,
10171 code: None,
10172 signal: None,
10173 at_ms: unix_ms_now(),
10174 }
10175}
10176
10177#[cfg(unix)]
10178fn exit_signal(status: &ExitStatus) -> Option<i32> {
10179 use std::os::unix::process::ExitStatusExt;
10180
10181 status.signal()
10182}
10183
10184#[cfg(not(unix))]
10185fn exit_signal(_status: &ExitStatus) -> Option<i32> {
10186 None
10187}
10188
10189fn reset_restart_count(snapshot: &SharedSnapshot, module_id: &str) -> Result<(), SuperviseError> {
10195 update_snapshot(snapshot, Some(module_id), |state| {
10196 state.clear_crash_restarts();
10197 })
10198}
10199
10200fn set_running(
10201 snapshot: &SharedSnapshot,
10202 child: &SupervisedChild,
10203 module_id: &str,
10204 spawn_events: &SpawnEventFeed,
10205) -> Result<(), SuperviseError> {
10206 let mut state = snapshot.lock().map_err(|_| SuperviseError::StatePoisoned {
10207 module_id: Some(module_id.to_string()),
10208 })?;
10209 state.spawn_generation = spawn_events.emit_spawned(module_id, child.pid, child.spawned_at_ms);
10210 if std::mem::take(&mut state.coalesced_restart_pending) {
10211 let generation = state.spawn_generation;
10212 info!(module_id, "restart already satisfied by generation {generation}; coalesced pending request completed");
10213 }
10214 state.drain_disposition_detail = None;
10215 state.spawn_failure = None;
10216 state.waiting_for_previous_pid = None;
10217 state.in_alternate_slot = false;
10220 state.configuration_updated_since_spawn = false;
10221 state.spawned_protocol = Some(child.protocol);
10222 state.state = ModuleState::Running;
10223 state.enabled = true;
10224 state.process_alive = true;
10225 state.pid = child.id();
10226 #[cfg(windows)]
10227 {
10228 state.pipe_consumed = child
10229 .nonce_delivery
10230 .as_ref()
10231 .map(|delivery| delivery.consumed());
10232 }
10233 #[cfg(target_os = "macos")]
10234 {
10235 state.report_ready = Some(Arc::clone(&child.report_ready));
10236 }
10237 state.spawned_at_ms = Some(child.spawned_at_ms);
10238 state.spawned_from = Some(child.spawned_from.clone());
10239 state.spawned_file_identity = child.spawned_file_identity.clone();
10240 state.process_start_time = child.process_start_time;
10241 Ok(())
10242}
10243
10244fn clear_current_process_facts(state: &mut SupervisorSnapshot) {
10245 state.waiting_for_previous_pid = None;
10246 state.process_alive = false;
10247 state.spawned_protocol = None;
10248 state.pid = None;
10249 #[cfg(windows)]
10250 {
10251 state.pipe_consumed = None;
10252 }
10253 #[cfg(target_os = "macos")]
10254 {
10255 state.report_ready = None;
10256 }
10257 state.spawned_at_ms = None;
10258 state.spawned_from = None;
10259 state.spawned_file_identity = None;
10260 state.process_start_time = None;
10261 state.deliberate_severance = None;
10262}
10263
10264#[cfg(test)]
10265fn record_deliberate_severance(
10266 snapshot: &SharedSnapshot,
10267 identity: ProcessIdentity,
10268) -> Result<(), SuperviseError> {
10269 update_snapshot(snapshot, None, |state| {
10270 state.deliberate_severance = Some(identity);
10271 })
10272}
10273
10274fn apply_deliberate_severance_marker(
10275 snapshot: &SharedSnapshot,
10276 exited_identity: Option<ProcessIdentity>,
10277 mut exit_report: ExitReport,
10278) -> ExitReport {
10279 let marker = lock_snapshot(snapshot)
10280 .ok()
10281 .and_then(|mut state| state.deliberate_severance.take());
10282 if marker.is_some() && marker == exited_identity {
10283 exit_report.kind = ExitKind::DeliberateSeverance;
10284 }
10285 exit_report
10286}
10287
10288fn classify_reaped_child_exit(
10289 snapshot: &SharedSnapshot,
10290 child: &SupervisedChild,
10291 status: &ExitStatus,
10292) -> ExitReport {
10293 let _ = update_snapshot(snapshot, None, |state| {
10294 state.reaped_pid = Some(child.pid);
10295 state.spawn_failure = child.spawn_failure.clone();
10296 });
10297 apply_deliberate_severance_marker(snapshot, child.process_identity(), classify_exit(status))
10298}
10299
10300fn fail_snapshot(
10301 snapshot: &SharedSnapshot,
10302 module_id: Option<&str>,
10303 last_exit: Option<ExitReport>,
10304) {
10305 if let Err(err) = update_snapshot(snapshot, module_id, |state| {
10306 state.state = ModuleState::Failed;
10307 clear_current_process_facts(state);
10308 if let Some(last_exit) = last_exit {
10309 state.last_exit = Some(last_exit);
10310 }
10311 }) {
10312 error!(error = %err, "failed to mark supervisor state failed");
10313 }
10314}
10315
10316fn update_snapshot(
10317 snapshot: &SharedSnapshot,
10318 module_id: Option<&str>,
10319 update: impl FnOnce(&mut SupervisorSnapshot),
10320) -> Result<(), SuperviseError> {
10321 let mut state = snapshot.lock().map_err(|_| SuperviseError::StatePoisoned {
10322 module_id: module_id.map(ToOwned::to_owned),
10323 })?;
10324 update(&mut state);
10325 Ok(())
10326}
10327
10328const SLOW_SNAPSHOT_LOCK_THRESHOLD: Duration = Duration::from_millis(250);
10329
10330fn lock_snapshot_for_control<'a>(
10331 snapshot: &'a SharedSnapshot,
10332 module_id: &str,
10333 caller: &'static str,
10334) -> Result<std::sync::MutexGuard<'a, SupervisorSnapshot>, SuperviseError> {
10335 let started_at = Instant::now();
10336 let guard = lock_snapshot(snapshot)?;
10337 let waited = started_at.elapsed();
10338 if waited >= SLOW_SNAPSHOT_LOCK_THRESHOLD {
10339 warn!(
10340 module_id = %module_id,
10341 waited_ms = waited.as_millis() as u64,
10342 caller = %caller,
10343 "slow snapshot lock"
10344 );
10345 }
10346 Ok(guard)
10347}
10348
10349fn lock_snapshot(
10350 snapshot: &SharedSnapshot,
10351) -> Result<std::sync::MutexGuard<'_, SupervisorSnapshot>, SuperviseError> {
10352 snapshot
10353 .lock()
10354 .map_err(|_| SuperviseError::StatePoisoned { module_id: None })
10355}
10356
10357#[cfg(test)]
10358mod terminal_history_tests {
10359 use std::{
10360 path::PathBuf,
10361 sync::Arc,
10362 time::{Duration, Instant},
10363 };
10364
10365 use tokio::{sync::mpsc, time::sleep};
10366
10367 use super::{
10368 apply_deliberate_severance_marker, daemon_will_restart, drain_child_to_state,
10369 drained_after_quiescence_wait, handle_reload_spawn_failure, health_restart_child,
10370 lock_snapshot, on_child_exit, record_deliberate_severance, record_wait_error_terminal,
10371 reset_restart_count, spawn_and_mark_running, update_snapshot, wait_error_exit_report,
10372 ExitKind, ExitReport, ModuleProtocol, ModuleSpec, ModuleState, NextAction, ProcessIdentity,
10373 RestartPolicy, SpawnEventKind, StopNotice, SuperviseError, SupervisedConfiguration,
10374 SupervisedModule, SupervisedModuleInner, Supervisor, SupervisorHandle,
10375 SupervisorHealthStatus, SupervisorSnapshot,
10376 };
10377 use super::Instant as ClockInstant;
10382 use crate::{
10383 registry::Registry,
10384 terminal_ring::{TerminalRing, TerminalRingConfig},
10385 };
10386 use std::sync::Mutex;
10387 use subc_control::TerminalDisposition;
10388
10389 pub(super) fn fake_aft_stub_path() -> PathBuf {
10394 let mut path = std::env::current_exe().expect("current_exe available in tests");
10395 path.pop();
10396 path.pop();
10397 path.push(if cfg!(windows) {
10398 "fake-aft-stub.exe"
10399 } else {
10400 "fake-aft-stub"
10401 });
10402 assert!(
10403 path.exists(),
10404 "fake-aft-stub not built at {}: run `cargo test -p subc-core` (which builds \
10405 [[bin]] targets) rather than `cargo test -p subc-core --lib` (which does not)",
10406 path.display()
10407 );
10408 path
10409 }
10410
10411 #[test]
10412 fn reserved_never_spawned_refuses_every_hello() {
10413 let supervisor = SupervisorHandle::default();
10418 supervisor.apply_identity_configuration(&ModuleSpec {
10419 module_id: "never-spawned".to_string(),
10420 program: PathBuf::from("/usr/bin/false"),
10421 args: Vec::new(),
10422 env: Vec::new(),
10423 reserved: true,
10424 reserved_prefixes: Vec::new(),
10425 protocol: ModuleProtocol::Subc,
10426 overlap: Default::default(),
10427 });
10428 assert!(
10429 supervisor
10430 .reserved_hello_rejection("never-spawned", Some("any-forged-nonce"))
10431 .is_some(),
10432 "forged nonce must refuse on a reserved never-spawned id"
10433 );
10434 assert!(
10435 supervisor
10436 .reserved_hello_rejection("never-spawned", None)
10437 .is_some(),
10438 "absent nonce must refuse on a reserved never-spawned id"
10439 );
10440 supervisor.set_spawn_nonce("never-spawned", "minted".to_string());
10442 supervisor.apply_identity_configuration(&ModuleSpec {
10443 module_id: "never-spawned".to_string(),
10444 program: PathBuf::from("/usr/bin/false"),
10445 args: Vec::new(),
10446 env: Vec::new(),
10447 reserved: true,
10448 reserved_prefixes: Vec::new(),
10449 protocol: ModuleProtocol::Subc,
10450 overlap: Default::default(),
10451 });
10452 assert!(supervisor
10453 .reserved_hello_rejection("never-spawned", Some("minted"))
10454 .is_none());
10455 assert!(supervisor
10456 .reserved_hello_rejection("never-spawned", Some("forged"))
10457 .is_some());
10458 }
10459
10460 fn seed_crash_restarts(state: &mut SupervisorSnapshot, count: u32) {
10463 let now = ClockInstant::now();
10464 for _ in 0..count {
10465 state.crash_restarts.push_back(now);
10466 }
10467 }
10468
10469 fn age_oldest_crash_restart_out_of_window(state: &mut SupervisorSnapshot, window: Duration) {
10473 let aged = state
10474 .crash_restarts
10475 .front()
10476 .expect("a crash restart must be recorded before it can be aged")
10477 .checked_sub(window + Duration::from_secs(1))
10478 .expect("the test clock is far enough from its origin to age an instant");
10479 state.crash_restarts[0] = aged;
10480 }
10481
10482 fn snapshot_with_restarts(enabled: bool, count: u32) -> SupervisorSnapshot {
10483 let mut state = SupervisorSnapshot::new(ModuleState::Running, enabled);
10484 seed_crash_restarts(&mut state, count);
10485 state
10486 }
10487
10488 #[test]
10489 fn daemon_owned_recovery_predicate_uses_the_pre_increment_budget() {
10490 let policy = RestartPolicy::new(3, Duration::ZERO);
10491 let now = ClockInstant::now();
10492 assert!(daemon_will_restart(
10493 &mut snapshot_with_restarts(true, 2),
10494 &policy,
10495 now
10496 ));
10497 assert!(!daemon_will_restart(
10498 &mut snapshot_with_restarts(true, 3),
10499 &policy,
10500 now
10501 ));
10502 assert!(!daemon_will_restart(
10503 &mut snapshot_with_restarts(false, 0),
10504 &policy,
10505 now
10506 ));
10507 }
10508
10509 #[test]
10510 fn crash_restart_backoff_escalates_with_in_window_count() {
10511 let policy = RestartPolicy::new(4, Duration::from_millis(100))
10512 .with_max_backoff(Duration::from_secs(30));
10513 let now = ClockInstant::now();
10514 let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
10515 let schedules = (0..4)
10516 .map(|_| {
10517 state
10518 .next_crash_restart(&policy, now)
10519 .expect("the test policy allows four crash restarts")
10520 })
10521 .collect::<Vec<_>>();
10522
10523 assert_eq!(
10524 schedules
10525 .iter()
10526 .map(|schedule| schedule.restart_in_window)
10527 .collect::<Vec<_>>(),
10528 vec![0, 1, 2, 3]
10529 );
10530 assert_eq!(
10531 schedules
10532 .iter()
10533 .map(|schedule| schedule.delay)
10534 .collect::<Vec<_>>(),
10535 vec![
10536 Duration::from_millis(100),
10537 Duration::from_secs(1),
10538 Duration::from_secs(10),
10539 Duration::from_secs(30),
10540 ]
10541 );
10542 }
10543
10544 #[test]
10545 fn crash_restart_backoff_resets_after_ring_clear() {
10546 let policy = RestartPolicy::new(3, Duration::from_millis(100));
10547 let now = ClockInstant::now();
10548 let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
10549 assert_eq!(
10550 state.next_crash_restart(&policy, now).unwrap().delay,
10551 Duration::from_millis(100)
10552 );
10553 assert_eq!(
10554 state.next_crash_restart(&policy, now).unwrap().delay,
10555 Duration::from_secs(1)
10556 );
10557
10558 state.clear_crash_restarts();
10559 let schedule = state
10560 .next_crash_restart(&policy, now)
10561 .expect("a cleared ring must allow another restart");
10562 assert_eq!(schedule.restart_in_window, 0);
10563 assert_eq!(schedule.delay, Duration::from_millis(100));
10564 }
10565
10566 #[test]
10567 fn crash_restart_backoff_ignores_aged_restarts() {
10568 let policy = RestartPolicy::new(3, Duration::from_millis(100));
10569 let now = ClockInstant::now();
10570 let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
10571 state
10572 .next_crash_restart(&policy, now)
10573 .expect("the first restart is allowed");
10574 state
10575 .next_crash_restart(&policy, now)
10576 .expect("the second restart is allowed");
10577 state.crash_restarts[0] = now
10578 .checked_sub(policy.window + Duration::from_secs(1))
10579 .expect("the fake clock can age a restart past the window");
10580
10581 let schedule = state
10582 .next_crash_restart(&policy, now)
10583 .expect("an aged restart must release its slot");
10584 assert_eq!(schedule.restart_in_window, 1);
10585 assert_eq!(schedule.delay, Duration::from_secs(1));
10586 assert_eq!(state.crash_restarts.len(), 2);
10587 }
10588
10589 #[test]
10593 fn a_budget_spent_before_the_window_no_longer_refuses() {
10594 let policy = RestartPolicy::new(3, Duration::ZERO);
10595 let mut state = snapshot_with_restarts(true, 3);
10596 let now = ClockInstant::now();
10597 assert!(!daemon_will_restart(&mut state, &policy, now));
10598
10599 assert!(daemon_will_restart(
10600 &mut state,
10601 &policy,
10602 now + policy.window + Duration::from_secs(1)
10603 ));
10604 assert!(
10605 state.crash_restarts.is_empty(),
10606 "reading the budget must drop the instants that left the window"
10607 );
10608 }
10609
10610 fn module_with_recovery_snapshot(
10611 state: ModuleState,
10612 enabled: bool,
10613 restart_count: u32,
10614 ) -> SupervisedModule {
10615 let registry = Arc::new(Registry::default());
10616 let supervisor =
10617 Supervisor::new_for_test(Arc::clone(®istry), RestartPolicy::new(3, Duration::ZERO));
10618 let runtime = supervisor.runtime_config();
10619 let spec = ModuleSpec {
10620 module_id: "recovery-snapshot".to_string(),
10621 program: fake_aft_stub_path(),
10622 args: Vec::new(),
10623 env: Vec::new(),
10624 reserved: false,
10625 reserved_prefixes: Vec::new(),
10626 protocol: ModuleProtocol::Subc,
10627 overlap: Default::default(),
10628 };
10629 let mut snapshot = SupervisorSnapshot::new(state, enabled);
10630 seed_crash_restarts(&mut snapshot, restart_count);
10631 let (commands, _rx) = mpsc::channel(4);
10634 SupervisedModule {
10635 inner: Arc::new(SupervisedModuleInner {
10636 module_id: spec.module_id.clone(),
10637 registry,
10638 snapshot: Arc::new(Mutex::new(snapshot)),
10639 configuration: Arc::new(Mutex::new(SupervisedConfiguration {
10640 spec,
10641 health: runtime.health,
10642 })),
10643 stderr_ring: runtime.stderr_ring,
10644 terminal_ring: runtime.terminal_ring,
10645 commands,
10646 monitor: Mutex::new(None),
10647 restart_policy: runtime.restart_policy,
10648 effective_drain_timeout: runtime.effective_drain_timeout,
10649 provenance_probe: supervisor.provenance_probe.clone(),
10650 }),
10651 }
10652 }
10653
10654 #[cfg(target_os = "linux")]
10655 #[tokio::test]
10656 async fn no_cgroup_placement_does_not_block_fake_aft_stub_spawn() {
10657 let supervisor =
10658 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
10659 .with_cgroup_placement(None);
10660 let result = supervisor.spawn(ModuleSpec {
10661 module_id: "no-cgroup-placement".to_string(),
10662 program: fake_aft_stub_path(),
10663 args: Vec::new(),
10664 env: Vec::new(),
10665 reserved: false,
10666 reserved_prefixes: Vec::new(),
10667 protocol: ModuleProtocol::Subc,
10668 overlap: Default::default(),
10669 });
10670
10671 assert!(
10672 result.is_ok(),
10673 "no delegation must not turn an otherwise valid spawn into a failure: {result:?}"
10674 );
10675 }
10676
10677 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10678 async fn undecided_snapshot_uses_shared_restart_predicate() {
10679 assert!(module_with_recovery_snapshot(ModuleState::Running, true, 2)
10680 .will_recover_after_connection_loss()
10681 .unwrap());
10682 assert!(
10683 !module_with_recovery_snapshot(ModuleState::Running, true, 3)
10684 .will_recover_after_connection_loss()
10685 .unwrap()
10686 );
10687 }
10688
10689 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10690 async fn restarting_snapshot_at_exhausted_budget_is_non_terminal() {
10691 assert!(
10692 module_with_recovery_snapshot(ModuleState::Restarting, true, 3)
10693 .will_recover_after_connection_loss()
10694 .unwrap()
10695 );
10696 }
10697
10698 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10699 async fn terminal_phase_snapshots_are_terminal_before_budget_exhaustion() {
10700 assert!(!module_with_recovery_snapshot(ModuleState::Failed, true, 0)
10701 .will_recover_after_connection_loss()
10702 .unwrap());
10703 assert!(
10704 !module_with_recovery_snapshot(ModuleState::Disabled, true, 0)
10705 .will_recover_after_connection_loss()
10706 .unwrap()
10707 );
10708 }
10709
10710 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10711 async fn warming_snapshot_is_limited_to_startup_phases() {
10712 for state in [
10713 ModuleState::Starting,
10714 ModuleState::Running,
10715 ModuleState::Restarting,
10716 ] {
10717 assert!(
10718 module_with_recovery_snapshot(state, true, 0)
10719 .is_warming()
10720 .unwrap(),
10721 "{state:?} should be warming"
10722 );
10723 }
10724 for state in [
10725 ModuleState::Unresponsive,
10726 ModuleState::Draining,
10727 ModuleState::Stopped,
10728 ModuleState::Failed,
10729 ModuleState::Disabled,
10730 ] {
10731 assert!(
10732 !module_with_recovery_snapshot(state, true, 0)
10733 .is_warming()
10734 .unwrap(),
10735 "{state:?} should not be warming"
10736 );
10737 }
10738 }
10739
10740 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10741 async fn terminal_history_survives_respawn_and_keeps_both_crashes_in_order() {
10742 let registry = Arc::new(Registry::default());
10743 let supervisor =
10744 Supervisor::new_for_test(Arc::clone(®istry), RestartPolicy::new(1, Duration::ZERO));
10745 let module = supervisor
10746 .spawn(ModuleSpec {
10747 module_id: "terminal-history".to_string(),
10748 program: fake_aft_stub_path(),
10749 args: Vec::new(),
10750 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
10751 reserved: false,
10752 reserved_prefixes: Vec::new(),
10753 protocol: ModuleProtocol::Subc,
10754 overlap: Default::default(),
10755 })
10756 .unwrap();
10757
10758 let deadline = Instant::now() + Duration::from_secs(5);
10759 loop {
10760 let history = module.terminal_history();
10761 if history.entries.len() == 2 {
10762 assert_eq!(module.status().unwrap().state, ModuleState::Failed);
10763 assert_eq!(history.dropped, 0);
10764 assert_eq!(
10765 history
10766 .entries
10767 .iter()
10768 .map(|entry| entry.exit_code)
10769 .collect::<Vec<_>>(),
10770 vec![Some(23), Some(23)]
10771 );
10772 assert!(history.entries[0].at_ms <= history.entries[1].at_ms);
10773 return;
10774 }
10775 assert!(
10776 Instant::now() < deadline,
10777 "module did not retain two terminal exits: {history:?}"
10778 );
10779 sleep(Duration::from_millis(10)).await;
10780 }
10781 }
10782
10783 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10787 async fn disable_during_crash_backoff_cancels_pending_respawn() {
10788 let backoff = Duration::from_secs(2);
10789 let supervisor = Supervisor::new_for_test(
10790 Arc::new(Registry::default()),
10791 RestartPolicy::new(10, backoff),
10792 );
10793 let module = supervisor
10794 .spawn(ModuleSpec {
10795 module_id: "disable-during-backoff".to_string(),
10796 program: fake_aft_stub_path(),
10797 args: Vec::new(),
10798 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
10799 reserved: false,
10800 reserved_prefixes: Vec::new(),
10801 protocol: ModuleProtocol::Subc,
10802 overlap: Default::default(),
10803 })
10804 .unwrap();
10805
10806 let deadline = Instant::now() + Duration::from_secs(5);
10808 loop {
10809 if module.status().unwrap().state == ModuleState::Restarting {
10810 break;
10811 }
10812 assert!(
10813 Instant::now() < deadline,
10814 "module never entered the crash backoff"
10815 );
10816 sleep(Duration::from_millis(10)).await;
10817 }
10818
10819 let started = Instant::now();
10820 module.set_enabled(false).await.unwrap();
10821 let waited = started.elapsed();
10822
10823 assert!(
10824 waited < backoff / 2,
10825 "disable waited {waited:?} behind the {backoff:?} crash backoff; the operator command must preempt the pending respawn"
10826 );
10827 assert_eq!(module.status().unwrap().state, ModuleState::Disabled);
10828
10829 sleep(backoff + Duration::from_millis(500)).await;
10831 let status = module.status().unwrap();
10832 assert_eq!(status.state, ModuleState::Disabled);
10833 assert_eq!(
10834 status.spawn_generation, 1,
10835 "module respawned after the operator disabled it"
10836 );
10837 }
10838
10839 #[cfg(unix)]
10842 fn protocol_none_sigterm_exits_clean_spec(
10843 module_id: &str,
10844 dir: &std::path::Path,
10845 ) -> (ModuleSpec, PathBuf, PathBuf) {
10846 let ready = dir.join("ready");
10847 let marker = dir.join("sigterm");
10848 let spec = ModuleSpec {
10849 module_id: module_id.to_string(),
10850 program: fake_aft_stub_path(),
10851 args: Vec::new(),
10852 env: vec![
10853 ("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string()),
10854 (
10855 "FAKE_AFT_SIGTERM_MARKER_PATH".to_string(),
10856 marker.display().to_string(),
10857 ),
10858 (
10859 "FAKE_AFT_NEVER_CONNECT_READY_PATH".to_string(),
10860 ready.display().to_string(),
10861 ),
10862 ],
10863 reserved: false,
10864 reserved_prefixes: Vec::new(),
10865 protocol: ModuleProtocol::None,
10866 overlap: Default::default(),
10867 };
10868 (spec, ready, marker)
10869 }
10870
10871 #[cfg(unix)]
10875 async fn wait_for_file(path: &std::path::Path) {
10876 let deadline = Instant::now() + Duration::from_secs(10);
10877 while !path.exists() {
10878 assert!(
10879 Instant::now() < deadline,
10880 "{} never appeared",
10881 path.display()
10882 );
10883 sleep(Duration::from_millis(10)).await;
10884 }
10885 }
10886
10887 #[cfg(unix)]
10891 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10892 async fn protocol_none_unrequested_clean_exit_restarts_as_a_crash() {
10893 let dir = cortexkit_test_support::ScratchDir::new("none-unrequested-clean-exit");
10894 let (spec, ready, marker) =
10895 protocol_none_sigterm_exits_clean_spec("none-unrequested-clean-exit", dir.path());
10896 let supervisor = Supervisor::new_for_test(
10897 Arc::new(Registry::default()),
10898 RestartPolicy::new(3, Duration::ZERO),
10899 );
10900 let module = supervisor.spawn(spec).unwrap();
10901 wait_for_file(&ready).await;
10902 let deadline = Instant::now() + Duration::from_secs(10);
10906 let first_pid = loop {
10907 let status = module.status().unwrap();
10908 if status.state == ModuleState::Running {
10909 if let Some(pid) = status.pid {
10910 break pid;
10911 }
10912 }
10913 assert!(
10914 Instant::now() < deadline,
10915 "a running module must report its pid after exec confirmation: {status:?}"
10916 );
10917 sleep(Duration::from_millis(10)).await;
10918 };
10919
10920 rustix::process::kill_process(
10921 rustix::process::Pid::from_raw(i32::try_from(first_pid).unwrap()).unwrap(),
10922 rustix::process::Signal::TERM,
10923 )
10924 .unwrap();
10925
10926 let deadline = Instant::now() + Duration::from_secs(10);
10927 let respawned = loop {
10928 let status = module.status().unwrap();
10929 if status.state == ModuleState::Running
10930 && status.pid.is_some_and(|pid| pid != first_pid)
10931 {
10932 break status;
10933 }
10934 assert!(
10935 Instant::now() < deadline,
10936 "protocol-none module was not respawned after an unrequested clean exit: {status:?}"
10937 );
10938 sleep(Duration::from_millis(10)).await;
10939 };
10940 assert_eq!(respawned.spawn_generation, 2);
10941 assert!(
10942 marker.exists(),
10943 "the child must have exited through its SIGTERM handler (exit 0), or this proves nothing about clean exits"
10944 );
10945
10946 let history = module.terminal_history();
10947 assert_eq!(history.entries.len(), 1, "{history:?}");
10948 let entry = &history.entries[0];
10949 assert_eq!(entry.exit_code, Some(0));
10950 assert_eq!(entry.exit_kind, subc_control::TerminalExitKind::Clean);
10951 assert_eq!(entry.disposition, TerminalDisposition::Restarting);
10952
10953 module.stop().await.unwrap();
10954 }
10955
10956 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10960 async fn protocol_none_repeated_clean_exits_exhaust_the_restart_budget() {
10961 let supervisor = Supervisor::new_for_test(
10962 Arc::new(Registry::default()),
10963 RestartPolicy::new(1, Duration::ZERO),
10964 );
10965 let module = supervisor
10966 .spawn(ModuleSpec {
10967 module_id: "none-clean-exit-budget".to_string(),
10968 program: fake_aft_stub_path(),
10969 args: Vec::new(),
10970 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "0".to_string())],
10971 reserved: false,
10972 reserved_prefixes: Vec::new(),
10973 protocol: ModuleProtocol::None,
10974 overlap: Default::default(),
10975 })
10976 .unwrap();
10977
10978 let deadline = Instant::now() + Duration::from_secs(10);
10981 loop {
10982 let status = module.status().unwrap();
10983 if status.state == ModuleState::Failed {
10984 break;
10985 }
10986 assert!(
10987 Instant::now() < deadline,
10988 "module never exhausted its budget: {status:?} {:?}",
10989 module.terminal_history()
10990 );
10991 sleep(Duration::from_millis(10)).await;
10992 }
10993 let history = module.terminal_history();
10994 assert_eq!(
10995 history
10996 .entries
10997 .iter()
10998 .map(|entry| (entry.exit_code, entry.disposition.clone()))
10999 .collect::<Vec<_>>(),
11000 vec![
11001 (Some(0), TerminalDisposition::Restarting),
11002 (Some(0), TerminalDisposition::Failed),
11003 ]
11004 );
11005 let detail = history.entries[1]
11006 .disposition_detail
11007 .as_deref()
11008 .expect("a budget failure names the budget");
11009 assert!(detail.contains("max_restarts=1"), "{detail}");
11010 assert_eq!(module.status().unwrap().spawn_generation, 2);
11011 }
11012
11013 #[test]
11014 fn restart_budget_failure_is_published_after_its_terminal_record() {
11015 use std::io::{Read, Write};
11016 let supervisor = Supervisor::new_for_test(
11017 Arc::new(Registry::default()),
11018 RestartPolicy::new(0, Duration::ZERO),
11019 );
11020 let runtime = supervisor.runtime_config();
11021 let spec = ModuleSpec {
11022 protocol: ModuleProtocol::None,
11023 ..windowed_crash_spec("budget-publication-order")
11024 };
11025 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(
11026 ModuleState::Running,
11027 true,
11028 )));
11029 lock_snapshot(&snapshot).unwrap().process_alive = true;
11030 let reaped_snapshot = snapshot.clone();
11031 let mut barrier = terminal_record_barrier(&runtime.spawn_events);
11032 let history = runtime.terminal_ring.clone();
11033 let reap = std::thread::spawn(move || {
11034 tokio::runtime::Builder::new_current_thread()
11035 .enable_all()
11036 .build()
11037 .unwrap()
11038 .block_on(on_child_exit(
11039 &spec,
11040 runtime.restart_policy,
11041 &supervisor.registry,
11042 &reaped_snapshot,
11043 &runtime.terminal_ring,
11044 &runtime.spawn_events,
11045 &runtime.child_roster,
11046 ExitReport {
11047 kind: ExitKind::Clean,
11048 code: Some(0),
11049 signal: None,
11050 at_ms: 1,
11051 },
11052 ))
11053 });
11054 let mut reached = [0];
11055 barrier.read_exact(&mut reached).unwrap();
11056 assert_eq!(&reached, b"R");
11057 let (before_state, before_alive) = {
11058 let state = lock_snapshot(&snapshot).unwrap();
11059 (state.state, state.process_alive)
11060 };
11061 let before_history = history.lock().unwrap().snapshot();
11062 barrier.write_all(b"X").unwrap();
11063 assert!(matches!(reap.join().unwrap(), NextAction::Stop { .. }));
11064 assert!(before_history.entries.is_empty());
11065 assert_ne!(
11066 before_state,
11067 ModuleState::Failed,
11068 "Failed was visible before its terminal record could be written"
11069 );
11070 assert!(before_alive);
11071 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
11072 let history = history.lock().unwrap().snapshot();
11073 assert_eq!(history.entries.len(), 1);
11074 assert_eq!(history.entries[0].exit_code, Some(0));
11075 assert_eq!(history.entries[0].disposition, TerminalDisposition::Failed);
11076 }
11077
11078 #[test]
11079 fn crash_restart_at_budget_limit_remains_recoverable_while_recording() {
11080 use std::io::{Read, Write};
11081
11082 let supervisor = Supervisor::new_for_test(
11083 Arc::new(Registry::default()),
11084 RestartPolicy::new(3, Duration::ZERO),
11085 );
11086 let mut runtime = supervisor.runtime_config();
11087 let module = module_with_recovery_snapshot(ModuleState::Running, true, 2);
11088 let snapshot = module.inner.snapshot.clone();
11089 lock_snapshot(&snapshot).unwrap().process_alive = true;
11090 runtime.terminal_ring = module.inner.terminal_ring.clone();
11091 let spec = windowed_crash_spec(module.module_id());
11092 let mut barrier = terminal_record_barrier(&runtime.spawn_events);
11093 let reaped_snapshot = snapshot.clone();
11094 let reap = std::thread::spawn(move || {
11095 tokio::runtime::Builder::new_current_thread()
11096 .enable_all()
11097 .build()
11098 .unwrap()
11099 .block_on(on_child_exit(
11100 &spec,
11101 runtime.restart_policy,
11102 &supervisor.registry,
11103 &reaped_snapshot,
11104 &runtime.terminal_ring,
11105 &runtime.spawn_events,
11106 &runtime.child_roster,
11107 ExitReport {
11108 kind: ExitKind::Crash,
11109 code: Some(2),
11110 signal: None,
11111 at_ms: 1,
11112 },
11113 ))
11114 });
11115
11116 let mut reached = [0];
11120 barrier.read_exact(&mut reached).unwrap();
11121 assert_eq!(&reached, b"R");
11122 let before_status = module.status().unwrap();
11123 let before_history = module.terminal_history();
11124 let before_recovers = module.will_recover_after_connection_loss().unwrap();
11125 barrier.write_all(b"X").unwrap();
11126 assert!(matches!(reap.join().unwrap(), NextAction::Restart { .. }));
11127
11128 assert_eq!(before_status.state, ModuleState::Running);
11129 assert!(before_status.process_alive);
11130 assert_eq!(before_status.restart_count, 3);
11131 assert!(before_history.entries.is_empty());
11132 assert!(
11133 before_recovers,
11134 "connection cleanup would report a terminal close for a scheduled crash restart"
11135 );
11136 let restarting = module.status().unwrap();
11137 assert_eq!(restarting.state, ModuleState::Restarting);
11138 assert_eq!(restarting.restart_count, 3);
11139 assert!(module.will_recover_after_connection_loss().unwrap());
11140 let history = module.terminal_history();
11141 assert_eq!(history.entries.len(), 1);
11142 assert_eq!(
11143 history.entries[0].disposition,
11144 TerminalDisposition::Restarting
11145 );
11146
11147 update_snapshot(&snapshot, Some(module.module_id()), |state| {
11150 state.state = ModuleState::Running;
11151 state.respawn_pending = false;
11152 })
11153 .unwrap();
11154 assert!(!module.will_recover_after_connection_loss().unwrap());
11155 update_snapshot(&snapshot, Some(module.module_id()), |state| {
11156 state.enabled = false;
11157 state.respawn_pending = true;
11158 })
11159 .unwrap();
11160 assert!(!module.will_recover_after_connection_loss().unwrap());
11161 }
11162
11163 fn terminal_record_barrier(events: &super::SpawnEventFeed) -> std::net::TcpStream {
11164 use std::net::{TcpListener, TcpStream};
11165 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
11166 let writer = TcpStream::connect(listener.local_addr().unwrap()).unwrap();
11167 let (barrier, _) = listener.accept().unwrap();
11168 for stream in [&writer, &barrier] {
11169 stream
11170 .set_read_timeout(Some(Duration::from_secs(10)))
11171 .unwrap();
11172 stream
11173 .set_write_timeout(Some(Duration::from_secs(10)))
11174 .unwrap();
11175 }
11176 *events.1.lock().unwrap() = Some(writer);
11177 barrier
11178 }
11179
11180 pub(super) fn before_terminal_record(events: &super::SpawnEventFeed) {
11181 use std::io::{Read, Write};
11182 let barrier = events.1.lock().unwrap().take();
11183 if let Some(mut barrier) = barrier {
11184 barrier.write_all(b"R").unwrap();
11185 let mut release = [0];
11186 barrier.read_exact(&mut release).unwrap();
11187 assert_eq!(&release, b"X");
11188 }
11189 }
11190
11191 #[test]
11192 fn health_restart_budget_failure_is_published_after_its_terminal_record() {
11193 use std::io::{Read, Write};
11194
11195 let supervisor = Supervisor::new_for_test(
11196 Arc::new(Registry::default()),
11197 RestartPolicy::new(0, Duration::ZERO),
11198 );
11199 let mut runtime = supervisor.runtime_config();
11200 let module = module_with_recovery_snapshot(ModuleState::Running, true, 0);
11201 let snapshot = module.inner.snapshot.clone();
11202 runtime.terminal_ring = module.inner.terminal_ring.clone();
11203 let spec = ModuleSpec {
11204 module_id: module.module_id().into(),
11205 program: fake_aft_stub_path(),
11206 protocol: ModuleProtocol::None,
11207 env: vec![("FAKE_AFT_EXIT_CODE".into(), "17".into())],
11208 ..windowed_crash_spec(module.module_id())
11209 };
11210 let mut barrier = terminal_record_barrier(&runtime.spawn_events);
11211 let reap = std::thread::spawn(move || {
11212 tokio::runtime::Builder::new_current_thread()
11213 .enable_all()
11214 .build()
11215 .unwrap()
11216 .block_on(async {
11217 let mut child =
11218 Some(spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap());
11219 health_restart_child(
11220 &spec,
11221 &runtime,
11222 &supervisor.registry,
11223 &supervisor.process_liveness,
11224 &snapshot,
11225 &mut child,
11226 SupervisorHealthStatus::Failing,
11227 Some("health probe did not answer"),
11228 1,
11229 )
11230 .await
11231 .unwrap();
11232 assert!(child.is_none());
11233 });
11234 });
11235 let mut reached = [0];
11240 barrier.read_exact(&mut reached).unwrap();
11241 assert_eq!(&reached, b"R");
11242 let before_status = module.status().unwrap();
11243 let before_history = module.terminal_history();
11244 barrier.write_all(b"X").unwrap();
11245 reap.join().unwrap();
11246 assert!(before_history.entries.is_empty());
11247 assert_ne!(
11248 before_status.state,
11249 ModuleState::Failed,
11250 "Failed was visible before its terminal record could be written"
11251 );
11252 assert!(
11253 before_status.process_alive,
11254 "process death was visible before its terminal record could be written"
11255 );
11256 let failed = module.status().unwrap();
11257 assert_eq!(failed.state, ModuleState::Failed);
11258 assert!(!failed.process_alive);
11259 let history = module.terminal_history();
11260 assert_eq!(history.entries.len(), 1);
11261 assert_eq!(history.entries[0].disposition, TerminalDisposition::Failed);
11262 assert_eq!(
11263 history.entries[0].disposition_detail.as_deref(),
11264 Some("crash budget exhausted: max_restarts=0 within window_secs=600")
11265 );
11266 }
11267
11268 #[test]
11269 fn observed_exits_publish_process_death_after_terminal_history() {
11270 use std::io::{Read, Write};
11271 for (kind, enabled, shutdown, wait_error, expected_state) in [
11272 (ExitKind::Clean, true, false, false, ModuleState::Stopped),
11273 (ExitKind::Crash, true, false, false, ModuleState::Restarting),
11274 (ExitKind::Crash, false, false, false, ModuleState::Disabled),
11275 (
11276 ExitKind::DeliberateSeverance,
11277 true,
11278 false,
11279 false,
11280 ModuleState::Restarting,
11281 ),
11282 #[cfg(unix)]
11283 (ExitKind::Crash, true, true, false, ModuleState::Stopped),
11284 (ExitKind::Crash, true, false, true, ModuleState::Failed),
11285 ] {
11286 let supervisor = Supervisor::new_for_test(
11287 Arc::new(Registry::default()),
11288 RestartPolicy::new(1, Duration::ZERO),
11289 );
11290 let runtime = supervisor.runtime_config();
11291 #[cfg(unix)]
11292 if shutdown {
11293 runtime.child_roster.close();
11294 }
11295 #[cfg(not(unix))]
11296 let _ = shutdown;
11297 let spec = windowed_crash_spec("exit-publication-order");
11298 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(
11299 ModuleState::Running,
11300 enabled,
11301 )));
11302 lock_snapshot(&snapshot).unwrap().process_alive = true;
11303 let reaped_snapshot = snapshot.clone();
11304 let history = runtime.terminal_ring.clone();
11305 let mut barrier = terminal_record_barrier(&runtime.spawn_events);
11306 let reap = std::thread::spawn(move || {
11307 if wait_error {
11308 super::fail_after_wait_error(
11309 &spec.module_id,
11310 &reaped_snapshot,
11311 &runtime.terminal_ring,
11312 &runtime.spawn_events,
11313 "wait failed".into(),
11314 );
11315 } else {
11316 tokio::runtime::Builder::new_current_thread()
11317 .enable_all()
11318 .build()
11319 .unwrap()
11320 .block_on(on_child_exit(
11321 &spec,
11322 runtime.restart_policy,
11323 &supervisor.registry,
11324 &reaped_snapshot,
11325 &runtime.terminal_ring,
11326 &runtime.spawn_events,
11327 &runtime.child_roster,
11328 ExitReport {
11329 kind,
11330 code: Some(0),
11331 signal: None,
11332 at_ms: 1,
11333 },
11334 ));
11335 }
11336 });
11337 let mut reached = [0];
11338 barrier.read_exact(&mut reached).unwrap();
11339 assert_eq!(&reached, b"R");
11340 let before_alive = lock_snapshot(&snapshot).unwrap().process_alive;
11341 let before_history = history.lock().unwrap().snapshot();
11342 barrier.write_all(b"X").unwrap();
11343 reap.join().unwrap();
11344 assert!(
11345 before_alive,
11346 "{kind:?}: death was published before recording"
11347 );
11348 assert!(before_history.entries.is_empty());
11349 assert_eq!(lock_snapshot(&snapshot).unwrap().state, expected_state);
11350 assert!(!lock_snapshot(&snapshot).unwrap().process_alive);
11351 assert_eq!(history.lock().unwrap().snapshot().entries.len(), 1);
11352 }
11353 }
11354
11355 #[tokio::test]
11356 async fn health_restart_budget_failure_remains_failed_when_journal_append_fails() {
11357 let dir = cortexkit_test_support::ScratchDir::new("health-budget-journal-failure");
11358 let path = dir.join("terminals.jsonl");
11359 std::fs::create_dir(&path).unwrap();
11360 let supervisor = Supervisor::new_for_test(
11361 Arc::new(Registry::default()),
11362 RestartPolicy::new(0, Duration::ZERO),
11363 )
11364 .with_terminal_journal(path, "health-budget-journal-failure".into());
11365 let runtime = supervisor.runtime_config();
11366 let spec = ModuleSpec {
11367 program: fake_aft_stub_path(),
11368 protocol: ModuleProtocol::None,
11369 env: vec![("FAKE_AFT_EXIT_CODE".into(), "17".into())],
11370 ..windowed_crash_spec("health-budget-journal-failure")
11371 };
11372 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11373 let mut child = Some(spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap());
11374 health_restart_child(
11375 &spec,
11376 &runtime,
11377 &supervisor.registry,
11378 &supervisor.process_liveness,
11379 &snapshot,
11380 &mut child,
11381 SupervisorHealthStatus::Failing,
11382 None,
11383 1,
11384 )
11385 .await
11386 .unwrap();
11387 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
11388 assert!(!lock_snapshot(&snapshot).unwrap().process_alive);
11389 let history = runtime
11390 .terminal_ring
11391 .lock()
11392 .unwrap()
11393 .durable_history(&spec.module_id);
11394 assert!(history.journal_write_failures > 0);
11395 assert_eq!(history.entries.len(), 1);
11396 assert_eq!(history.entries[0].disposition, TerminalDisposition::Failed);
11397 }
11398
11399 #[test]
11400 fn health_restart_budget_failure_remains_failed_when_exit_recording_panics() {
11401 let supervisor = Supervisor::new_for_test(
11402 Arc::new(Registry::default()),
11403 RestartPolicy::new(0, Duration::ZERO),
11404 );
11405 let runtime = supervisor.runtime_config();
11406 let spec = ModuleSpec {
11407 program: fake_aft_stub_path(),
11408 protocol: ModuleProtocol::None,
11409 env: vec![("FAKE_AFT_EXIT_CODE".into(), "17".into())],
11410 ..windowed_crash_spec("health-budget-recording-panic")
11411 };
11412 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11413 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
11414 tokio::runtime::Builder::new_current_thread()
11415 .enable_all()
11416 .build()
11417 .unwrap()
11418 .block_on(async {
11419 let mut child =
11420 Some(spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap());
11421 runtime.spawn_events.0.lock().unwrap().seq = u64::MAX;
11422 health_restart_child(
11423 &spec,
11424 &runtime,
11425 &supervisor.registry,
11426 &supervisor.process_liveness,
11427 &snapshot,
11428 &mut child,
11429 SupervisorHealthStatus::Failing,
11430 None,
11431 1,
11432 )
11433 .await
11434 .unwrap();
11435 });
11436 }));
11437 let panic = result.expect_err("recording must still unwind");
11438 assert_eq!(
11439 panic.downcast_ref::<String>().map(String::as_str),
11440 Some("spawn event sequence exhausted")
11441 );
11442 let state = lock_snapshot(&snapshot).unwrap();
11443 assert_eq!(state.state, ModuleState::Failed);
11444 assert!(!state.process_alive);
11445 assert!(state.last_exit.is_some());
11446 }
11447
11448 #[tokio::test]
11449 async fn restart_budget_failure_remains_failed_when_journal_append_fails() {
11450 let dir = cortexkit_test_support::ScratchDir::new("budget-journal-failure");
11451 let path = dir.join("terminals.jsonl");
11452 std::fs::create_dir(&path).unwrap();
11453 let supervisor = Supervisor::new_for_test(
11454 Arc::new(Registry::default()),
11455 RestartPolicy::new(0, Duration::ZERO),
11456 )
11457 .with_terminal_journal(path, "budget-journal-failure".into());
11458 let runtime = supervisor.runtime_config();
11459 let spec = windowed_crash_spec("budget-journal-failure");
11460 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11461 assert!(matches!(
11462 on_child_exit(
11463 &spec,
11464 runtime.restart_policy,
11465 &supervisor.registry,
11466 &snapshot,
11467 &runtime.terminal_ring,
11468 &runtime.spawn_events,
11469 &runtime.child_roster,
11470 crash_exit_report(1),
11471 )
11472 .await,
11473 NextAction::Stop { .. }
11474 ));
11475 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
11476 let history = runtime
11477 .terminal_ring
11478 .lock()
11479 .unwrap()
11480 .durable_history(&spec.module_id);
11481 assert!(history.journal_write_failures > 0);
11482 assert_eq!(history.entries.len(), 1);
11483 assert_eq!(history.entries[0].disposition, TerminalDisposition::Failed);
11484 }
11485
11486 #[test]
11487 fn restart_budget_failure_remains_failed_when_exit_recording_panics() {
11488 let supervisor = Supervisor::new_for_test(
11489 Arc::new(Registry::default()),
11490 RestartPolicy::new(0, Duration::ZERO),
11491 );
11492 let runtime = supervisor.runtime_config();
11493 let spec = windowed_crash_spec("budget-recording-panic");
11494 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11495 runtime.spawn_events.emit_spawned(&spec.module_id, 1, 1);
11496 runtime.spawn_events.0.lock().unwrap().seq = u64::MAX;
11499 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
11500 tokio::runtime::Builder::new_current_thread()
11501 .enable_all()
11502 .build()
11503 .unwrap()
11504 .block_on(on_child_exit(
11505 &spec,
11506 runtime.restart_policy,
11507 &supervisor.registry,
11508 &snapshot,
11509 &runtime.terminal_ring,
11510 &runtime.spawn_events,
11511 &runtime.child_roster,
11512 crash_exit_report(1),
11513 ))
11514 }));
11515 let panic = result.err().expect("recording must still unwind");
11516 assert_eq!(
11517 panic.downcast_ref::<String>().map(String::as_str),
11518 Some("spawn event sequence exhausted")
11519 );
11520 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
11521 }
11522
11523 #[cfg(unix)]
11526 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11527 async fn protocol_none_requested_stop_and_disable_do_not_respawn() {
11528 for disable in [false, true] {
11529 let label = if disable {
11530 "none-requested-disable"
11531 } else {
11532 "none-requested-stop"
11533 };
11534 let dir = cortexkit_test_support::ScratchDir::new(label);
11535 let (spec, ready, marker) = protocol_none_sigterm_exits_clean_spec(label, dir.path());
11536 let supervisor = Supervisor::new_for_test(
11537 Arc::new(Registry::default()),
11538 RestartPolicy::new(3, Duration::ZERO),
11539 );
11540 let module = supervisor.spawn(spec).unwrap();
11541 wait_for_file(&ready).await;
11542
11543 if disable {
11544 module.set_enabled(false).await.unwrap();
11545 } else {
11546 module.stop().await.unwrap();
11547 }
11548 assert!(
11549 marker.exists(),
11550 "{label}: the child must have left through its SIGTERM handler with exit 0"
11551 );
11552
11553 sleep(Duration::from_millis(500)).await;
11556 let status = module.status().unwrap();
11557 let expected = if disable {
11558 ModuleState::Disabled
11559 } else {
11560 ModuleState::Stopped
11561 };
11562 assert_eq!(status.state, expected, "{label}");
11563 assert_eq!(
11564 status.spawn_generation, 1,
11565 "{label}: respawned after a requested stop"
11566 );
11567 let history = module.terminal_history();
11568 assert_eq!(history.entries.len(), 1, "{label}: {history:?}");
11569 assert_eq!(history.entries[0].exit_code, Some(0), "{label}");
11570 assert_ne!(
11571 history.entries[0].disposition,
11572 TerminalDisposition::Restarting,
11573 "{label}"
11574 );
11575 }
11576 }
11577
11578 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11581 async fn subc_wire_clean_exit_is_still_a_stop() {
11582 let supervisor = Supervisor::new_for_test(
11583 Arc::new(Registry::default()),
11584 RestartPolicy::new(3, Duration::ZERO),
11585 );
11586 let module = supervisor
11587 .spawn(ModuleSpec {
11588 module_id: "wire-clean-exit".to_string(),
11589 program: fake_aft_stub_path(),
11590 args: Vec::new(),
11591 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "0".to_string())],
11592 reserved: false,
11593 reserved_prefixes: Vec::new(),
11594 protocol: ModuleProtocol::Subc,
11595 overlap: Default::default(),
11596 })
11597 .unwrap();
11598
11599 let deadline = Instant::now() + Duration::from_secs(10);
11600 while module.terminal_history().entries.is_empty() {
11601 assert!(Instant::now() < deadline, "module never exited");
11602 sleep(Duration::from_millis(10)).await;
11603 }
11604 sleep(Duration::from_millis(500)).await;
11606 let status = module.status().unwrap();
11607 assert_eq!(status.state, ModuleState::Stopped);
11608 assert_eq!(status.spawn_generation, 1);
11609 let history = module.terminal_history();
11610 assert_eq!(history.entries.len(), 1, "{history:?}");
11611 assert_eq!(history.entries[0].exit_code, Some(0));
11612 assert_eq!(history.entries[0].disposition, TerminalDisposition::Stopped);
11613 }
11614
11615 #[cfg(unix)]
11616 #[tokio::test]
11617 async fn reload_exit_keeps_roster_until_terminal_is_recorded_during_shutdown() {
11618 let dir = cortexkit_test_support::ScratchDir::new("reload-roster-terminal-order");
11619 let record = dir.join("live-children.json");
11620 let supervisor = Supervisor::new_for_test(
11621 Arc::new(Registry::default()),
11622 RestartPolicy::new(0, Duration::ZERO),
11623 );
11624 let mut runtime = supervisor.runtime_config();
11625 runtime.child_roster.record_to(record.clone());
11626 let gate = Arc::new(super::ReloadExitRecordGate::default());
11627 runtime.test_reload_exit_record_gate = Some(Arc::clone(&gate));
11628 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11629 let spec = ModuleSpec {
11630 module_id: "reload-exit-roster".into(),
11631 program: fake_aft_stub_path(),
11632 args: Vec::new(),
11633 env: vec![("FAKE_AFT_EXIT_CODE".into(), "23".into())],
11634 reserved: false,
11635 reserved_prefixes: Vec::new(),
11636 protocol: ModuleProtocol::Subc,
11637 overlap: Default::default(),
11638 };
11639 let mut child = None;
11640 let reload = super::finish_reload_child(
11641 &spec,
11642 &runtime,
11643 &supervisor.registry,
11644 &supervisor.process_liveness,
11645 &snapshot,
11646 &mut child,
11647 );
11648 tokio::pin!(reload);
11649 tokio::select! {
11650 result = &mut reload => panic!("reload missed the before-record gate: {result:?}"),
11651 _ = gate.reached.notified() => {}
11652 }
11653 assert!(runtime
11654 .terminal_ring
11655 .lock()
11656 .unwrap()
11657 .snapshot()
11658 .entries
11659 .is_empty());
11660 assert_eq!(
11661 crate::live_children::read_record(&record).unwrap().len(),
11662 1,
11663 "shutdown must still wait for the reaped child until its terminal record exists"
11664 );
11665 runtime.child_roster.close();
11666 gate.resume.notify_one();
11667 assert!(reload.await.is_err());
11668 assert!(crate::live_children::read_record(&record)
11669 .unwrap()
11670 .is_empty());
11671 let history = runtime.terminal_ring.lock().unwrap().snapshot();
11672 assert_eq!(history.entries.len(), 1);
11673 assert_eq!(
11674 history.entries[0].disposition,
11675 TerminalDisposition::DaemonShutdown
11676 );
11677 }
11678
11679 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11683 async fn every_restart_increment_path_advances_lifetime_count() {
11684 let supervisor = Supervisor::new_for_test(
11685 Arc::new(Registry::default()),
11686 RestartPolicy::new(1, Duration::ZERO),
11687 );
11688 let runtime = supervisor.runtime_config();
11689 let spec = ModuleSpec {
11690 module_id: "lifetime-increment-path".to_string(),
11691 program: PathBuf::from("/unused/lifetime-increment-path"),
11692 args: Vec::new(),
11693 env: Vec::new(),
11694 reserved: false,
11695 reserved_prefixes: Vec::new(),
11696 protocol: ModuleProtocol::Subc,
11697 overlap: Default::default(),
11698 };
11699
11700 let crash_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11701 assert!(matches!(
11702 on_child_exit(
11703 &spec,
11704 runtime.restart_policy,
11705 &supervisor.registry,
11706 &crash_snapshot,
11707 &runtime.terminal_ring,
11708 &runtime.spawn_events,
11709 &runtime.child_roster,
11710 ExitReport {
11711 kind: ExitKind::Crash,
11712 code: Some(1),
11713 signal: None,
11714 at_ms: 1,
11715 },
11716 )
11717 .await,
11718 NextAction::Restart { schedule: _ }
11719 ));
11720 let (crash_restarts, crash_lifetime) = {
11721 let state = lock_snapshot(&crash_snapshot).unwrap();
11722 (state.crash_restarts.len(), state.lifetime_restarts)
11723 };
11724 assert_eq!(crash_restarts, 1);
11725 assert_eq!(crash_lifetime, 1);
11726
11727 let health_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11728 let mut health_child = None;
11729 assert!(matches!(
11730 health_restart_child(
11731 &spec,
11732 &runtime,
11733 &supervisor.registry,
11734 &supervisor.process_liveness,
11735 &health_snapshot,
11736 &mut health_child,
11737 SupervisorHealthStatus::Failing,
11738 None,
11739 2,
11740 )
11741 .await,
11742 Ok(())
11743 ));
11744 assert!(health_child.is_none());
11745 assert!(lock_snapshot(&health_snapshot).unwrap().respawn_pending);
11746 assert!(runtime.scheduled_respawn.lock().unwrap().take().is_some());
11747 let (health_restarts, health_lifetime) = {
11748 let state = lock_snapshot(&health_snapshot).unwrap();
11749 (state.crash_restarts.len(), state.lifetime_restarts)
11750 };
11751 assert_eq!(health_restarts, 1);
11752 assert_eq!(health_lifetime, 1);
11753
11754 let reload_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11755 let mut reload_child = None;
11756 assert!(matches!(
11757 handle_reload_spawn_failure(
11758 &spec,
11759 &runtime,
11760 &supervisor.process_liveness,
11761 &reload_snapshot,
11762 &mut reload_child,
11763 "forced reload spawn failure".to_string(),
11764 )
11765 .await,
11766 Err(SuperviseError::ReloadFailed { .. })
11767 ));
11768 let (reload_restarts, reload_lifetime) = {
11769 let state = lock_snapshot(&reload_snapshot).unwrap();
11770 (state.crash_restarts.len(), state.lifetime_restarts)
11771 };
11772 assert_eq!(reload_restarts, 1);
11773 assert_eq!(reload_lifetime, 1);
11774 }
11775
11776 #[tokio::test]
11777 async fn deliberately_severed_live_child_records_lifetime_without_spending_restart_budget() {
11778 let supervisor = Supervisor::new_for_test(
11779 Arc::new(Registry::default()),
11780 RestartPolicy::new(3, Duration::ZERO),
11781 );
11782 let runtime = supervisor.runtime_config();
11783 let spec = ModuleSpec {
11784 module_id: "deliberately-severed".to_string(),
11785 program: PathBuf::from("/unused/deliberately-severed"),
11786 args: Vec::new(),
11787 env: Vec::new(),
11788 reserved: false,
11789 reserved_prefixes: Vec::new(),
11790 protocol: ModuleProtocol::Subc,
11791 overlap: Default::default(),
11792 };
11793 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11794 let process = ProcessIdentity {
11795 pid: 41,
11796 start_time: 101,
11797 };
11798 record_deliberate_severance(&snapshot, process).unwrap();
11799 let exit_report = apply_deliberate_severance_marker(
11800 &snapshot,
11801 Some(process),
11802 ExitReport {
11803 kind: ExitKind::Crash,
11804 code: Some(1),
11805 signal: None,
11806 at_ms: 1,
11807 },
11808 );
11809 assert_eq!(exit_report.kind, ExitKind::DeliberateSeverance);
11810
11811 assert!(matches!(
11812 on_child_exit(
11813 &spec,
11814 runtime.restart_policy,
11815 &supervisor.registry,
11816 &snapshot,
11817 &runtime.terminal_ring,
11818 &runtime.spawn_events,
11819 &runtime.child_roster,
11820 exit_report,
11821 )
11822 .await,
11823 NextAction::Restart { schedule: _ }
11824 ));
11825 let state = lock_snapshot(&snapshot).unwrap();
11826 assert_eq!(state.lifetime_restarts, 1);
11827 assert_eq!(state.crash_restarts.len(), 0);
11828 }
11829
11830 #[tokio::test]
11831 async fn genuine_crash_spends_restart_budget_and_records_lifetime() {
11832 let supervisor = Supervisor::new_for_test(
11833 Arc::new(Registry::default()),
11834 RestartPolicy::new(3, Duration::ZERO),
11835 );
11836 let runtime = supervisor.runtime_config();
11837 let spec = ModuleSpec {
11838 module_id: "genuine-crash".to_string(),
11839 program: PathBuf::from("/unused/genuine-crash"),
11840 args: Vec::new(),
11841 env: Vec::new(),
11842 reserved: false,
11843 reserved_prefixes: Vec::new(),
11844 protocol: ModuleProtocol::Subc,
11845 overlap: Default::default(),
11846 };
11847 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11848
11849 assert!(matches!(
11850 on_child_exit(
11851 &spec,
11852 runtime.restart_policy,
11853 &supervisor.registry,
11854 &snapshot,
11855 &runtime.terminal_ring,
11856 &runtime.spawn_events,
11857 &runtime.child_roster,
11858 ExitReport {
11859 kind: ExitKind::Crash,
11860 code: Some(1),
11861 signal: None,
11862 at_ms: 1,
11863 },
11864 )
11865 .await,
11866 NextAction::Restart { schedule: _ }
11867 ));
11868 let state = lock_snapshot(&snapshot).unwrap();
11869 assert_eq!(state.lifetime_restarts, 1);
11870 assert_eq!(state.crash_restarts.len(), 1);
11871 }
11872
11873 fn crash_exit_report(at_ms: u64) -> ExitReport {
11874 ExitReport {
11875 kind: ExitKind::Crash,
11876 code: Some(1),
11877 signal: None,
11878 at_ms,
11879 }
11880 }
11881
11882 fn windowed_crash_spec(module_id: &str) -> ModuleSpec {
11883 ModuleSpec {
11884 module_id: module_id.to_string(),
11885 program: PathBuf::from("/unused").join(module_id),
11886 args: Vec::new(),
11887 env: Vec::new(),
11888 reserved: false,
11889 reserved_prefixes: Vec::new(),
11890 protocol: ModuleProtocol::Subc,
11891 overlap: Default::default(),
11892 }
11893 }
11894
11895 #[tokio::test]
11901 async fn three_crashes_inside_the_window_stop_the_module_and_name_the_window() {
11902 let (logs, _guard) = crate::router::test_log::log_capture(tracing::Level::ERROR);
11903 let supervisor = Supervisor::new_for_test(
11904 Arc::new(Registry::default()),
11905 RestartPolicy::new(2, Duration::ZERO),
11906 );
11907 let runtime = supervisor.runtime_config();
11908 let spec = windowed_crash_spec("crash-loop-in-window");
11909 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11910
11911 for attempt in 1..=2 {
11912 assert!(
11913 matches!(
11914 on_child_exit(
11915 &spec,
11916 runtime.restart_policy,
11917 &supervisor.registry,
11918 &snapshot,
11919 &runtime.terminal_ring,
11920 &runtime.spawn_events,
11921 &runtime.child_roster,
11922 crash_exit_report(attempt),
11923 )
11924 .await,
11925 NextAction::Restart { schedule: _ }
11926 ),
11927 "crash {attempt} is inside the budget and must respawn"
11928 );
11929 }
11930
11931 assert!(matches!(
11932 on_child_exit(
11933 &spec,
11934 runtime.restart_policy,
11935 &supervisor.registry,
11936 &snapshot,
11937 &runtime.terminal_ring,
11938 &runtime.spawn_events,
11939 &runtime.child_roster,
11940 crash_exit_report(3),
11941 )
11942 .await,
11943 NextAction::Stop { .. }
11944 ));
11945
11946 {
11947 let state = lock_snapshot(&snapshot).unwrap();
11948 assert_eq!(state.state, ModuleState::Failed);
11949 assert_eq!(state.crash_restarts.len(), 2);
11950 assert_eq!(state.lifetime_restarts, 2);
11951 }
11952
11953 let history = runtime
11954 .terminal_ring
11955 .lock()
11956 .expect("terminal ring is not poisoned")
11957 .snapshot();
11958 let last = history
11959 .entries
11960 .last()
11961 .expect("the refused crash is retained");
11962 assert_eq!(last.disposition, TerminalDisposition::Failed);
11963 assert_eq!(
11964 last.disposition_detail.as_deref(),
11965 Some("crash budget exhausted: max_restarts=2 within window_secs=600")
11966 );
11967
11968 let captured = crate::router::test_log::captured_logs(&logs);
11969 assert!(
11970 captured.contains("crash budget exhausted: max_restarts=2 within window_secs=600"),
11971 "the stop must be logged with its window: {captured}"
11972 );
11973 }
11974
11975 #[tokio::test]
11983 async fn a_crash_older_than_the_window_frees_its_slot_for_a_later_crash() {
11984 let supervisor = Supervisor::new_for_test(
11985 Arc::new(Registry::default()),
11986 RestartPolicy::new(2, Duration::ZERO),
11987 );
11988 let runtime = supervisor.runtime_config();
11989 let spec = windowed_crash_spec("crash-across-windows");
11990 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11991
11992 for attempt in 1..=2 {
11993 assert!(matches!(
11994 on_child_exit(
11995 &spec,
11996 runtime.restart_policy,
11997 &supervisor.registry,
11998 &snapshot,
11999 &runtime.terminal_ring,
12000 &runtime.spawn_events,
12001 &runtime.child_roster,
12002 crash_exit_report(attempt),
12003 )
12004 .await,
12005 NextAction::Restart { schedule: _ }
12006 ));
12007 }
12008
12009 update_snapshot(&snapshot, Some(&spec.module_id), |state| {
12012 age_oldest_crash_restart_out_of_window(state, runtime.restart_policy.window);
12013 })
12014 .unwrap();
12015
12016 assert!(
12017 matches!(
12018 on_child_exit(
12019 &spec,
12020 runtime.restart_policy,
12021 &supervisor.registry,
12022 &snapshot,
12023 &runtime.terminal_ring,
12024 &runtime.spawn_events,
12025 &runtime.child_roster,
12026 crash_exit_report(3),
12027 )
12028 .await,
12029 NextAction::Restart { schedule: _ }
12030 ),
12031 "a crash older than the window must not hold a budget slot"
12032 );
12033
12034 let state = lock_snapshot(&snapshot).unwrap();
12035 assert_eq!(state.state, ModuleState::Restarting);
12036 assert_eq!(
12037 state.crash_restarts.len(),
12038 2,
12039 "the aged instant is dropped and the new one takes its place"
12040 );
12041 assert_eq!(
12042 state.lifetime_restarts, 3,
12043 "the ledger counts every restart, including the ones the window forgot"
12044 );
12045 }
12046
12047 #[tokio::test]
12052 async fn an_operator_restart_clears_the_ring_and_leaves_the_ledger_alone() {
12053 let supervisor = Supervisor::new_for_test(
12054 Arc::new(Registry::default()),
12055 RestartPolicy::new(2, Duration::ZERO),
12056 );
12057 let runtime = supervisor.runtime_config();
12058 let spec = windowed_crash_spec("operator-cleared-budget");
12059 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
12060
12061 for attempt in 1..=2 {
12062 assert!(matches!(
12063 on_child_exit(
12064 &spec,
12065 runtime.restart_policy,
12066 &supervisor.registry,
12067 &snapshot,
12068 &runtime.terminal_ring,
12069 &runtime.spawn_events,
12070 &runtime.child_roster,
12071 crash_exit_report(attempt),
12072 )
12073 .await,
12074 NextAction::Restart { schedule: _ }
12075 ));
12076 }
12077
12078 reset_restart_count(&snapshot, &spec.module_id).unwrap();
12079 {
12080 let state = lock_snapshot(&snapshot).unwrap();
12081 assert!(
12082 state.crash_restarts.is_empty(),
12083 "an operator restart returns the full budget"
12084 );
12085 assert_eq!(
12086 state.lifetime_restarts, 2,
12087 "clearing the budget must not unmake the crashes"
12088 );
12089 }
12090
12091 assert!(
12092 matches!(
12093 on_child_exit(
12094 &spec,
12095 runtime.restart_policy,
12096 &supervisor.registry,
12097 &snapshot,
12098 &runtime.terminal_ring,
12099 &runtime.spawn_events,
12100 &runtime.child_roster,
12101 crash_exit_report(3),
12102 )
12103 .await,
12104 NextAction::Restart { schedule: _ }
12105 ),
12106 "the cleared budget must be spendable again"
12107 );
12108 let state = lock_snapshot(&snapshot).unwrap();
12109 assert_eq!(state.crash_restarts.len(), 1);
12110 assert_eq!(state.lifetime_restarts, 3);
12111 }
12112
12113 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
12114 async fn severance_marker_for_a_dead_child_does_not_label_its_successor() {
12115 let severed = ProcessIdentity {
12116 pid: 41,
12117 start_time: 101,
12118 };
12119 let successor = ProcessIdentity {
12120 pid: 41,
12121 start_time: 202,
12122 };
12123 let module = module_with_recovery_snapshot(ModuleState::Running, true, 0);
12124 update_snapshot(&module.inner.snapshot, Some("recovery-snapshot"), |state| {
12125 state.pid = Some(successor.pid);
12126 state.process_start_time = Some(successor.start_time);
12127 })
12128 .unwrap();
12129 assert!(!module.record_deliberate_severance(severed).unwrap());
12130
12131 let exit_report = apply_deliberate_severance_marker(
12132 &module.inner.snapshot,
12133 Some(successor),
12134 ExitReport {
12135 kind: ExitKind::Crash,
12136 code: Some(1),
12137 signal: None,
12138 at_ms: 1,
12139 },
12140 );
12141
12142 assert_eq!(exit_report.kind, ExitKind::Crash);
12143 }
12144
12145 #[tokio::test]
12146 async fn drain_reap_marks_deliberate_severance_and_records_lifetime_without_budget() {
12147 let registry = Registry::default();
12148 let supervisor = Supervisor::new_for_test(
12149 Arc::new(Registry::default()),
12150 RestartPolicy::new(3, Duration::ZERO),
12151 );
12152 let runtime = supervisor.runtime_config();
12153 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
12154 let spec = ModuleSpec {
12155 module_id: "drain-deliberate-severance".to_string(),
12156 program: fake_aft_stub_path(),
12157 args: Vec::new(),
12158 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
12159 reserved: false,
12160 reserved_prefixes: Vec::new(),
12161 protocol: ModuleProtocol::Subc,
12162 overlap: Default::default(),
12163 };
12164 let mut child = spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap();
12165 let process = ProcessIdentity {
12166 pid: 41,
12167 start_time: 101,
12168 };
12169 child.process_identity = Some(process);
12170 update_snapshot(&snapshot, Some(&spec.module_id), |state| {
12171 state.pid = Some(process.pid);
12172 state.process_start_time = Some(process.start_time);
12173 })
12174 .unwrap();
12175 record_deliberate_severance(&snapshot, process).unwrap();
12176
12177 drain_child_to_state(
12178 &spec.module_id,
12179 spec.protocol,
12180 StopNotice::SentOverConnection,
12183 ®istry,
12184 None,
12185 &snapshot,
12186 &runtime.terminal_ring,
12187 &runtime.spawn_events,
12188 child,
12189 Duration::from_secs(1),
12190 ModuleState::Stopped,
12191 Some(false),
12192 )
12193 .await
12194 .unwrap();
12195
12196 let state = lock_snapshot(&snapshot).unwrap();
12197 assert_eq!(
12198 state.last_exit.as_ref().map(|exit| exit.kind),
12199 Some(ExitKind::DeliberateSeverance)
12200 );
12201 assert_eq!(state.lifetime_restarts, 1);
12202 assert_eq!(state.crash_restarts.len(), 0);
12203 drop(state);
12204 let history = runtime.terminal_ring.lock().unwrap().snapshot();
12205 assert_eq!(
12206 history.entries[0].exit_kind,
12207 subc_control::TerminalExitKind::DeliberateSeverance
12208 );
12209 }
12210
12211 #[tokio::test]
12212 async fn ordinary_drain_reap_does_not_record_a_lifetime_restart() {
12213 let registry = Registry::default();
12214 let supervisor = Supervisor::new_for_test(
12215 Arc::new(Registry::default()),
12216 RestartPolicy::new(3, Duration::ZERO),
12217 );
12218 let runtime = supervisor.runtime_config();
12219 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
12220 let spec = ModuleSpec {
12221 module_id: "ordinary-drain".to_string(),
12222 program: fake_aft_stub_path(),
12223 args: Vec::new(),
12224 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
12225 reserved: false,
12226 reserved_prefixes: Vec::new(),
12227 protocol: ModuleProtocol::Subc,
12228 overlap: Default::default(),
12229 };
12230 let child = spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap();
12231
12232 drain_child_to_state(
12233 &spec.module_id,
12234 spec.protocol,
12235 StopNotice::SentOverConnection,
12238 ®istry,
12239 None,
12240 &snapshot,
12241 &runtime.terminal_ring,
12242 &runtime.spawn_events,
12243 child,
12244 Duration::from_secs(1),
12245 ModuleState::Stopped,
12246 Some(false),
12247 )
12248 .await
12249 .unwrap();
12250
12251 let state = lock_snapshot(&snapshot).unwrap();
12252 assert_eq!(
12253 state.last_exit.as_ref().map(|exit| exit.kind),
12254 Some(ExitKind::Crash)
12255 );
12256 assert_eq!(state.lifetime_restarts, 0);
12257 assert_eq!(state.crash_restarts.len(), 0);
12258 }
12259
12260 #[test]
12261 fn fatal_connection_teardown_cannot_arm_a_marker_for_a_surviving_process() {
12262 assert!(!include_str!("server.rs")
12268 .contains("router.record_deliberate_connection_severance(ctx.connection_id)"));
12269 }
12270
12271 #[test]
12278 fn drained_after_quiescence_wait_passes_ok_through_and_forces_false_on_err() {
12279 assert!(drained_after_quiescence_wait(&Ok(true)));
12280 assert!(!drained_after_quiescence_wait(&Ok(false)));
12281 assert!(!drained_after_quiescence_wait(&Err(
12282 SuperviseError::StatePoisoned { module_id: None }
12283 )));
12284 }
12285
12286 #[test]
12295 fn wait_error_exit_report_records_a_failed_terminal_with_no_code_or_signal() {
12296 let ring = Arc::new(Mutex::new(TerminalRing::new(
12297 TerminalRingConfig::default(),
12298 0,
12299 )));
12300 record_wait_error_terminal("wait-error", &ring, &super::SpawnEventFeed::default());
12301
12302 let snapshot = ring.lock().unwrap().snapshot();
12303 assert_eq!(snapshot.entries.len(), 1);
12304 let entry = &snapshot.entries[0];
12305 assert_eq!(entry.exit_code, None);
12306 assert_eq!(entry.exit_signal, None);
12307 assert_eq!(entry.disposition, TerminalDisposition::Failed);
12308 }
12309
12310 #[test]
12311 fn wait_error_exit_path_preserves_spawn_event_density() {
12312 let feed = super::SpawnEventFeed::default();
12313 feed.configure_incarnation("wait-error-density".to_string());
12314 feed.emit_spawned("wait-error", 41, 1);
12315 let ring = Arc::new(Mutex::new(TerminalRing::new(
12316 TerminalRingConfig::default(),
12317 0,
12318 )));
12319
12320 record_wait_error_terminal("wait-error", &ring, &feed);
12321 feed.emit_spawned("after-wait-error", 42, 2);
12322
12323 let state = feed.0.lock().unwrap();
12324 let sequences = state
12325 .events
12326 .iter()
12327 .map(|event| event.cursor.seq)
12328 .collect::<Vec<_>>();
12329 assert_eq!(sequences, vec![1, 2, 3]);
12330 assert_eq!(state.events[1].kind, SpawnEventKind::Exited);
12331 assert_eq!(state.events[1].exit_code, None);
12332 assert_eq!(state.events[1].exit_signal, None);
12333 }
12334
12335 #[test]
12339 fn wait_error_exit_report_is_classified_as_a_crash() {
12340 assert_eq!(wait_error_exit_report().kind, ExitKind::Crash);
12341 }
12342}
12343
12344#[cfg(all(test, unix))]
12345mod health_event_tests {
12346 use super::*;
12347 use cortexkit_test_support::ScratchDir;
12348 use subc_protocol::{manifest::Concurrency, session::ModuleControlResponse};
12349
12350 struct Actor {
12351 module: SupervisedModule,
12352 registry: Arc<Registry>,
12353 forwarding: Arc<ForwardingTable>,
12354 spec: ModuleSpec,
12355 health: HealthConfig,
12356 rx: mpsc::Receiver<crate::router::OutboundFrame>,
12357 _home: ScratchDir,
12358 }
12359
12360 async fn wait_for<T>(reason: &str, mut observe: impl FnMut() -> Option<T>) -> T {
12361 let deadline = std::time::Instant::now() + Duration::from_secs(10);
12365 let tick = Instant::now();
12366 loop {
12367 if let Some(value) = observe() {
12368 assert_eq!(Instant::now(), tick, "{reason} must not wait for a timer");
12369 return value;
12370 }
12371 assert!(
12372 std::time::Instant::now() < deadline,
12373 "timed out waiting for {reason}"
12374 );
12375 tokio::task::yield_now().await;
12376 }
12377 }
12378
12379 impl Actor {
12380 async fn start(protocol: ModuleProtocol, cadence: Duration) -> Self {
12381 let home = ScratchDir::new("health-events");
12382 let registry = Arc::new(Registry::default());
12383 let forwarding = Arc::new(ForwardingTable::default());
12384 let health = HealthConfig {
12385 cadence,
12386 deadline: Duration::from_secs(3600),
12387 ..HealthConfig::default()
12388 };
12389 let supervisor =
12390 Supervisor::new_for_test(registry.clone(), RestartPolicy::new(3, Duration::ZERO))
12391 .with_forwarding(forwarding.clone())
12392 .with_health_config(health.clone());
12393 let spec = ModuleSpec {
12394 module_id: "event-health-child".into(),
12395 program: PathBuf::from("/bin/sh"),
12396 args: vec!["-c".into(), "exec sleep 600".into()],
12399 env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
12400 .into_iter()
12401 .map(|key| (key.into(), home.path().display().to_string()))
12402 .collect(),
12403 reserved: false,
12404 reserved_prefixes: vec![],
12405 protocol,
12406 overlap: Default::default(),
12407 };
12408 let module = supervisor.spawn(spec.clone()).unwrap();
12409 let (_, rx) = mpsc::channel(8);
12410 let actor = Self {
12411 module,
12412 registry,
12413 forwarding,
12414 spec,
12415 health,
12416 rx,
12417 _home: home,
12418 };
12419 actor
12420 .wait_for_select(|checkpoint| checkpoint.generation > 0)
12421 .await;
12422 actor
12423 }
12424
12425 fn turns(&self) -> u64 {
12426 lock_snapshot(&self.module.inner.snapshot)
12427 .unwrap()
12428 .actor_turns
12429 }
12430
12431 async fn wait_for_select(
12432 &self,
12433 expected: impl Fn(&ActorSelectCheckpoint) -> bool,
12434 ) -> ActorSelectCheckpoint {
12435 wait_for("actor select checkpoint", || {
12436 let state = lock_snapshot(&self.module.inner.snapshot).unwrap();
12437 state.actor_select.filter(|checkpoint| {
12438 checkpoint.generation == state.spawn_generation
12439 && state.state == ModuleState::Running
12440 && state.process_alive
12441 && state.reported_pid().is_some()
12442 && expected(checkpoint)
12443 })
12444 })
12445 .await
12446 }
12447
12448 fn hello(&mut self, connection: u64, health: bool) {
12449 let manifest =
12450 subc_protocol::manifest::ModuleManifest::builder(&self.spec.module_id, "1.0.0")
12451 .protocol_ver(subc_protocol::PROTOCOL_VERSION)
12452 .build();
12453 let connection = ConnectionId::new(connection);
12454 let (tx, rx) = mpsc::channel(8);
12455 self.rx = rx;
12456 self.forwarding
12457 .register_module_connection(
12458 connection,
12459 self.spec.module_id.clone(),
12460 subc_protocol::PROTOCOL_VERSION,
12461 Concurrency::ModuleManaged,
12462 FrameSink::new(tx),
12463 )
12464 .unwrap();
12465 self.registry
12466 .register_with_control_ops(
12467 manifest,
12468 subc_protocol::PROTOCOL_VERSION,
12469 connection,
12470 if health {
12471 vec![MODULE_CONTROL_OP_HEALTH_CHECK.into()]
12472 } else {
12473 vec![]
12474 },
12475 )
12476 .unwrap();
12477 }
12478
12479 async fn probe(&mut self) -> crate::router::OutboundFrame {
12480 let frame = wait_for("outbound health probe", || match self.rx.try_recv() {
12481 Ok(frame) => Some(frame),
12482 Err(mpsc::error::TryRecvError::Empty) => None,
12483 Err(mpsc::error::TryRecvError::Disconnected) => panic!("health peer disconnected"),
12484 })
12485 .await;
12486 let body: Value = serde_json::from_slice(&frame.body).unwrap();
12487 assert_eq!(body["op"], MODULE_CONTROL_OP_HEALTH_CHECK);
12488 frame
12489 }
12490
12491 fn no_probe(&mut self) {
12492 assert!(matches!(
12493 self.rx.try_recv(),
12494 Err(mpsc::error::TryRecvError::Empty)
12495 ));
12496 }
12497
12498 fn answer(&self, connection: u64, frame: crate::router::OutboundFrame) {
12499 self.forwarding
12500 .complete_module_control_rpc(
12501 ConnectionId::new(connection),
12502 frame.header.corr,
12503 Some(MODULE_CONTROL_OP_HEALTH_CHECK),
12504 ModuleControlRpcOutcome::Response(ModuleControlResponse::HealthCheck {
12505 status: HealthStatus::Ok,
12506 detail: None,
12507 metrics: None,
12508 }),
12509 )
12510 .unwrap();
12511 }
12512 }
12513
12514 #[tokio::test(start_paused = true)]
12515 async fn no_health_children_park_for_sixty_seconds() {
12516 let none = Actor::start(ModuleProtocol::None, Duration::from_secs(30)).await;
12517 let unregistered = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12518 let mut unadvertised = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12519 unadvertised.hello(1, false);
12520 for _ in 0..6000 {
12525 tokio::time::advance(Duration::from_millis(10)).await;
12526 tokio::task::yield_now().await;
12527 }
12528 for actor in [&none, &unregistered, &unadvertised] {
12529 assert!(
12530 actor.turns() <= 5,
12531 "no-health actor woke {} times",
12532 actor.turns()
12533 );
12534 assert_eq!(actor.module.state().unwrap(), ModuleState::Running);
12535 }
12536 }
12537
12538 #[tokio::test(start_paused = true)]
12539 async fn late_hello_starts_a_due_probe_in_the_notification_tick() {
12540 let mut actor = Actor::start(ModuleProtocol::Subc, Duration::ZERO).await;
12544 let tick = Instant::now();
12545 actor.hello(1, true);
12546 actor.probe().await;
12547 assert_eq!(Instant::now(), tick, "HELLO must not wait for a poll");
12548 }
12549
12550 #[tokio::test(start_paused = true)]
12551 async fn hello_keeps_the_jittered_cadence_and_catalog_updates_do_not_reset_it() {
12552 let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12553 let hello_tick = Instant::now();
12554 actor.hello(1, true);
12555 let armed = 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 let delay = jittered_health_delay(&actor.spec.module_id, 0, actor.health.cadence);
12562 assert!((Duration::from_secs(30)..Duration::from_secs(33)).contains(&delay));
12563 let first_deadline = hello_tick + delay;
12564 assert_eq!(armed.next_probe_at, Some(first_deadline));
12565 tokio::time::advance(Duration::from_secs(29)).await;
12566 let turns = actor.turns();
12567 actor
12568 .registry
12569 .replace_catalog_for_connection(ConnectionId::new(1), vec![], None, Some(true))
12570 .unwrap();
12571 let updated = actor
12572 .wait_for_select(|checkpoint| checkpoint.turn > turns)
12573 .await;
12574 assert_eq!(
12575 updated.next_probe_at,
12576 Some(first_deadline),
12577 "catalog update must not reset cadence"
12578 );
12579 actor.no_probe();
12580 tokio::time::advance(first_deadline - Instant::now()).await;
12581 let frame = actor.probe().await;
12582 actor.answer(1, frame);
12583 let next = jittered_health_delay(&actor.spec.module_id, 1, actor.health.cadence);
12584 let next_deadline = Instant::now() + next;
12585 let rearmed = actor
12586 .wait_for_select(|checkpoint| {
12587 checkpoint
12588 .next_probe_at
12589 .is_some_and(|deadline| deadline > first_deadline)
12590 })
12591 .await;
12592 assert_eq!(rearmed.next_probe_at, Some(next_deadline));
12593 tokio::time::advance(next - Duration::from_nanos(1)).await;
12594 actor.no_probe();
12595 tokio::time::advance(Duration::from_nanos(1)).await;
12596 actor.probe().await;
12597 }
12598
12599 #[tokio::test(start_paused = true)]
12600 async fn disconnect_disarms_and_reconnect_rearms_health() {
12601 let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12602 actor.hello(1, true);
12603 actor
12604 .wait_for_select(|checkpoint| {
12605 checkpoint.registered_connection == Some(ConnectionId::new(1))
12606 && checkpoint.next_probe_at.is_some()
12607 })
12608 .await;
12609 let turns = actor.turns();
12610 let tick = Instant::now();
12611 actor
12612 .registry
12613 .deregister_connection(ConnectionId::new(1))
12614 .unwrap();
12615 let disarmed = actor
12616 .wait_for_select(|checkpoint| {
12617 checkpoint.turn > turns && checkpoint.registered_connection.is_none()
12618 })
12619 .await;
12620 assert_eq!(disarmed.next_probe_at, None);
12621 assert_eq!(disarmed.wake_after, None);
12622 assert_eq!(
12623 Instant::now(),
12624 tick,
12625 "disconnect must disarm in the notification tick"
12626 );
12627 tokio::time::advance(Duration::from_secs(60)).await;
12628 actor.no_probe();
12629 let reconnect_tick = Instant::now();
12630 actor.hello(2, true);
12631 let rearmed = actor
12632 .wait_for_select(|checkpoint| {
12633 checkpoint.registered_connection == Some(ConnectionId::new(2))
12634 && checkpoint.next_probe_at.is_some()
12635 })
12636 .await;
12637 let delay = jittered_health_delay(&actor.spec.module_id, 0, actor.health.cadence);
12638 assert_eq!(rearmed.next_probe_at, Some(reconnect_tick + delay));
12639 tokio::time::advance(delay).await;
12640 actor.probe().await;
12641 }
12642
12643 #[tokio::test(start_paused = true)]
12644 async fn reregistration_without_health_disarms_the_old_schedule() {
12645 let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12646 actor.hello(1, true);
12647 actor
12648 .wait_for_select(|checkpoint| {
12649 checkpoint.registered_connection == Some(ConnectionId::new(1))
12650 && checkpoint.next_probe_at.is_some()
12651 })
12652 .await;
12653 actor
12654 .registry
12655 .deregister_connection(ConnectionId::new(1))
12656 .unwrap();
12657 actor.hello(2, false);
12658 let disarmed = actor
12659 .wait_for_select(|checkpoint| {
12660 checkpoint.registered_connection == Some(ConnectionId::new(2))
12661 })
12662 .await;
12663 assert_eq!(disarmed.next_probe_at, None);
12664 assert_eq!(disarmed.wake_after, None);
12665 tokio::time::advance(Duration::from_secs(60)).await;
12666 actor.no_probe();
12667 }
12668
12669 #[tokio::test(start_paused = true)]
12670 async fn restart_waits_for_its_own_hello_before_rearming_health() {
12671 let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12672 actor.hello(1, true);
12673 let armed = actor
12674 .wait_for_select(|checkpoint| {
12675 checkpoint.registered_connection == Some(ConnectionId::new(1))
12676 && checkpoint.next_probe_at.is_some()
12677 })
12678 .await;
12679 actor
12683 .registry
12684 .deregister_connection(ConnectionId::new(1))
12685 .unwrap();
12686 actor.module.restart(Some(0)).await.unwrap();
12687 let parked = actor
12688 .wait_for_select(|checkpoint| {
12689 checkpoint.generation > armed.generation
12690 && checkpoint.registered_connection.is_none()
12691 && checkpoint.next_probe_at.is_none()
12692 })
12693 .await;
12694 assert_eq!(parked.wake_after, None);
12695 actor.rx.close();
12696 while actor.rx.try_recv().is_ok() {}
12699 let turns = actor.turns();
12700 tokio::time::advance(Duration::from_secs(60)).await;
12701 let after = actor.turns();
12702 eprintln!(
12703 "restart park turns: before={turns} after={after} delta={}",
12704 after - turns
12705 );
12706 assert_eq!(after, turns, "replacement must park before HELLO");
12707 let hello_tick = Instant::now();
12708 actor.hello(2, true);
12709 let rearmed = actor
12710 .wait_for_select(|checkpoint| {
12711 checkpoint.registered_connection == Some(ConnectionId::new(2))
12712 && checkpoint.next_probe_at.is_some()
12713 })
12714 .await;
12715 let delay = jittered_health_delay(&actor.spec.module_id, 0, actor.health.cadence);
12716 assert_eq!(rearmed.next_probe_at, Some(hello_tick + delay));
12717 tokio::time::advance(delay).await;
12718 actor.probe().await;
12719 }
12720
12721 #[tokio::test(start_paused = true)]
12722 async fn rescan_adds_and_removes_http_health_without_polling() {
12723 use tokio::{
12724 io::{AsyncReadExt, AsyncWriteExt},
12725 net::TcpListener,
12726 };
12727 let mut actor = Actor::start(ModuleProtocol::None, Duration::from_secs(30)).await;
12728 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
12729 actor.health.http = Some(format!("http://{}/health", listener.local_addr().unwrap()));
12730 actor.health.cadence = Duration::from_millis(1);
12732 let (started_tx, mut started_rx) = mpsc::channel(8);
12733 let peer = tokio::spawn(async move {
12734 loop {
12735 let (mut socket, _) = listener.accept().await.unwrap();
12736 let mut request = [0; 1024];
12737 assert!(socket.read(&mut request).await.unwrap() > 0);
12738 started_tx.send(()).await.unwrap();
12739 socket
12740 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")
12741 .await
12742 .unwrap();
12743 }
12744 });
12745 let tick = Instant::now();
12746 let turns = actor.turns();
12747 actor
12748 .module
12749 .update_configuration(actor.spec.clone(), actor.health.clone(), None)
12750 .await
12751 .unwrap();
12752 let armed = actor
12753 .wait_for_select(|checkpoint| {
12754 checkpoint.turn > turns && checkpoint.next_probe_at.is_some()
12755 })
12756 .await;
12757 assert_eq!(armed.next_probe_at, Some(tick + Duration::from_millis(1)));
12758 assert_eq!(Instant::now(), tick);
12759 tokio::time::advance(Duration::from_millis(1)).await;
12760 let mut started = false;
12761 wait_for("successful HTTP probe", || {
12762 started |= started_rx.try_recv().is_ok();
12763 (started && actor.module.status().unwrap().health.status == SupervisorHealthStatus::Ok)
12764 .then_some(())
12765 })
12766 .await;
12767 assert!(started, "added HTTP check must start at its first deadline");
12768 assert_eq!(
12769 actor.module.status().unwrap().health.status,
12770 SupervisorHealthStatus::Ok
12771 );
12772 assert_eq!(Instant::now(), tick + Duration::from_millis(1));
12773 actor.health.http = None;
12774 let turns = actor.turns();
12775 actor
12776 .module
12777 .update_configuration(actor.spec.clone(), actor.health.clone(), None)
12778 .await
12779 .unwrap();
12780 let parked = actor
12781 .wait_for_select(|checkpoint| {
12782 checkpoint.turn > turns && checkpoint.next_probe_at.is_none()
12783 })
12784 .await;
12785 assert_eq!(parked.wake_after, None);
12786 let turns = actor.turns();
12787 tokio::time::advance(Duration::from_secs(60)).await;
12788 assert_eq!(
12789 actor.turns(),
12790 turns,
12791 "removed HTTP check must park the actor"
12792 );
12793 assert!(
12794 started_rx.try_recv().is_err(),
12795 "removed HTTP check must stay disarmed"
12796 );
12797 assert_eq!(
12798 actor.module.status().unwrap().health,
12799 ModuleHealthStatus::default()
12800 );
12801 peer.abort();
12802 }
12803}
12804
12805#[cfg(test)]
12806mod health_evidence_tests {
12807 use super::{HealthProbeError, HealthProbeEvidence};
12808 use std::collections::HashSet;
12809
12810 #[test]
12818 fn only_a_dead_lane_is_proof_of_death() {
12819 assert!(HealthProbeError::lane_dead("gone").is_proof_of_death());
12820 assert!(!HealthProbeError::no_answer("timed out").is_proof_of_death());
12824 assert!(!HealthProbeError::bad_answer("garbage").is_proof_of_death());
12825 assert!(!HealthProbeError::misconfigured("no table").is_proof_of_death());
12826 }
12827
12828 #[test]
12834 fn every_evidence_class_has_a_distinct_label() {
12835 let labels = [
12836 HealthProbeError::lane_dead("").label(),
12837 HealthProbeError::no_answer("").label(),
12838 HealthProbeError::bad_answer("").label(),
12839 HealthProbeError::misconfigured("").label(),
12840 ];
12841 let unique: HashSet<_> = labels.iter().collect();
12842 assert_eq!(unique.len(), labels.len(), "labels collided: {labels:?}");
12843 }
12844
12845 #[test]
12851 fn classification_preserves_the_original_message() {
12852 let err = HealthProbeError::no_answer("module did not answer within 5s");
12853 assert_eq!(err.to_string(), "module did not answer within 5s");
12854 assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
12855 }
12856}
12857
12858#[cfg(test)]
12859mod health_tombstone_tests {
12860 use std::{path::PathBuf, sync::Arc, time::Duration};
12861
12862 use subc_protocol::{
12863 manifest::Concurrency,
12864 session::{HealthStatus, ModuleControlResponse},
12865 };
12866 use tokio::sync::mpsc;
12867
12868 use super::{
12869 probe_module_health, HealthAction, HealthConfig, HealthProbeEvidence, ModuleProtocol,
12870 ModuleSpec, RestartPolicy, Supervisor, SupervisorRuntimeConfig,
12871 };
12872 use crate::{
12873 control::ControlHandler,
12874 forwarding::{ForwardingTable, ModuleControlRpcCompletion, ModuleControlRpcOutcome},
12875 registry::{ConnectionId, Registry},
12876 router::FrameSink,
12877 };
12878
12879 struct ProbeHarness {
12880 spec: ModuleSpec,
12881 runtime: SupervisorRuntimeConfig,
12882 forwarding: Arc<ForwardingTable>,
12883 module_connection: ConnectionId,
12884 module_rx: mpsc::Receiver<crate::router::OutboundFrame>,
12885 handler: ControlHandler,
12886 module: super::SupervisedModule,
12887 }
12888
12889 fn probe_harness() -> ProbeHarness {
12890 let registry = Arc::new(Registry::default());
12891 let forwarding = Arc::new(ForwardingTable::default());
12892 let supervisor_handle = super::SupervisorHandle::new();
12893 let health = HealthConfig {
12894 http: None,
12895 cadence: Duration::from_secs(30),
12896 deadline: Duration::from_secs(5),
12897 failure_threshold: 3,
12898 on_degraded: HealthAction::Report,
12899 on_failing: HealthAction::Report,
12900 critical: false,
12901 };
12902 let supervisor = Supervisor::new_for_test(Arc::clone(®istry), RestartPolicy::default())
12903 .with_forwarding(Arc::clone(&forwarding))
12904 .with_handle(supervisor_handle.clone())
12905 .with_health_config(health);
12906 let spec = ModuleSpec {
12907 module_id: "late-health-module".to_string(),
12908 program: PathBuf::from("disabled-module"),
12909 args: Vec::new(),
12910 env: Vec::new(),
12911 reserved: false,
12912 reserved_prefixes: Vec::new(),
12913 protocol: ModuleProtocol::Subc,
12914 overlap: Default::default(),
12915 };
12916 let module = supervisor
12917 .supervise_configured(spec.clone(), false)
12918 .unwrap();
12919 let runtime = supervisor.runtime_config();
12920 let handler = ControlHandler::with_forwarding(registry, Arc::clone(&forwarding))
12921 .with_supervisor(supervisor_handle);
12922 let module_connection = ConnectionId::new(700);
12923 let (module_tx, module_rx) = mpsc::channel(8);
12924 forwarding
12925 .register_module_connection(
12926 module_connection,
12927 spec.module_id.clone(),
12928 subc_protocol::PROTOCOL_VERSION,
12929 Concurrency::ModuleManaged,
12930 FrameSink::new(module_tx),
12931 )
12932 .unwrap();
12933
12934 ProbeHarness {
12935 spec,
12936 runtime,
12937 forwarding,
12938 module_connection,
12939 module_rx,
12940 handler,
12941 module,
12942 }
12943 }
12944
12945 async fn finish_after(
12946 harness: &mut ProbeHarness,
12947 stall: Duration,
12948 ) -> ModuleControlRpcCompletion {
12949 assert!(stall > harness.runtime.health.deadline);
12950 let deadline = harness.runtime.health.deadline;
12951 let probe = probe_module_health(&harness.spec.module_id, &harness.runtime, None);
12952 let answer = async {
12953 let frame = harness.module_rx.recv().await.expect("health.check frame");
12954 tokio::time::advance(deadline).await;
12955 tokio::task::yield_now().await;
12956 tokio::time::advance(stall - deadline).await;
12957 harness
12958 .forwarding
12959 .complete_module_control_rpc(
12960 harness.module_connection,
12961 frame.header.corr,
12962 Some("health.check"),
12963 ModuleControlRpcOutcome::Response(ModuleControlResponse::HealthCheck {
12964 status: HealthStatus::Ok,
12965 detail: None,
12966 metrics: None,
12967 }),
12968 )
12969 .unwrap()
12970 };
12971 let (probe_result, completion) = tokio::join!(probe, answer);
12972 let err = probe_result.expect_err("probe must miss its deadline");
12973 assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
12974 completion
12975 }
12976
12977 async fn time_out_without_answer(harness: &mut ProbeHarness) {
12978 let deadline = harness.runtime.health.deadline;
12979 let probe = probe_module_health(&harness.spec.module_id, &harness.runtime, None);
12980 let exhaust_deadline = async {
12981 let _frame = harness.module_rx.recv().await.expect("health.check frame");
12982 tokio::time::advance(deadline).await;
12983 tokio::task::yield_now().await;
12984 };
12985 let (probe_result, ()) = tokio::join!(probe, exhaust_deadline);
12986 let err = probe_result.expect_err("probe must miss its deadline");
12987 assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
12988 }
12989
12990 async fn run_probe_cycle(harness: &mut ProbeHarness, answer: bool) {
12991 let registry = Arc::clone(&harness.module.inner.registry);
12992 let snapshot = Arc::clone(&harness.module.inner.snapshot);
12993 let process_liveness = super::SupervisorProcessLiveness::default();
12994 let mut child = None;
12995 let cycle = super::run_health_probe_cycle(
12996 &harness.spec,
12997 &harness.runtime,
12998 ®istry,
12999 &process_liveness,
13000 &snapshot,
13001 &mut child,
13002 );
13003 let peer = async {
13004 let frame = harness.module_rx.recv().await.expect("health.check frame");
13005 if answer {
13006 harness
13007 .forwarding
13008 .complete_module_control_rpc(
13009 harness.module_connection,
13010 frame.header.corr,
13011 Some("health.check"),
13012 ModuleControlRpcOutcome::Response(ModuleControlResponse::HealthCheck {
13013 status: HealthStatus::Ok,
13014 detail: None,
13015 metrics: Some(serde_json::json!({"ready": true})),
13016 }),
13017 )
13018 .unwrap();
13019 } else {
13020 tokio::time::advance(harness.runtime.health.deadline).await;
13021 tokio::task::yield_now().await;
13022 }
13023 };
13024 tokio::join!(cycle, peer);
13025 }
13026
13027 #[tokio::test(start_paused = true)]
13028 async fn unanswered_probe_is_unknown_until_threshold_and_ok_report_recovers() {
13029 let mut harness = probe_harness();
13030 let monitor = harness.module.inner.monitor.lock().unwrap().take().unwrap();
13034 monitor.abort();
13035 let _ = monitor.await;
13036 super::update_snapshot(&harness.module.inner.snapshot, None, |state| {
13037 state.enabled = true;
13038 state.state = super::ModuleState::Running;
13039 state.process_alive = true;
13040 })
13041 .unwrap();
13042
13043 run_probe_cycle(&mut harness, true).await;
13044 assert_eq!(
13045 harness.module.status().unwrap().health.status,
13046 super::SupervisorHealthStatus::Ok
13047 );
13048
13049 for failures in 1..harness.runtime.health.failure_threshold {
13050 run_probe_cycle(&mut harness, false).await;
13051 let status = harness.module.status().unwrap();
13052 assert_eq!(status.health.status, super::SupervisorHealthStatus::Unknown);
13053 assert_eq!(status.health.consecutive_failures, failures);
13054 assert!(status.health.last_probe_ms.is_some());
13055 assert!(status.health.detail.unwrap().starts_with("[no-answer]"));
13056 assert!(status.health.metrics.is_none());
13057 assert_eq!(status.state, super::ModuleState::Running);
13058 assert!(status.process_alive);
13059 assert_eq!(status.restart_count, 0);
13060 assert_eq!(status.lifetime_restarts, 0);
13061 assert!(status.health.last_action.is_none());
13062 assert!(harness.runtime.scheduled_respawn.lock().unwrap().is_none());
13063 }
13064
13065 run_probe_cycle(&mut harness, true).await;
13066 let recovered = harness.module.status().unwrap();
13067 assert_eq!(recovered.health.status, super::SupervisorHealthStatus::Ok);
13068 assert_eq!(recovered.health.consecutive_failures, 0);
13069 assert!(recovered.health.detail.is_none());
13070 assert_eq!(
13071 recovered.health.metrics,
13072 Some(serde_json::json!({"ready": true}))
13073 );
13074 assert_eq!(recovered.lifetime_restarts, 0);
13075
13076 for failures in 1..=harness.runtime.health.failure_threshold {
13077 run_probe_cycle(&mut harness, false).await;
13078 let status = harness.module.status().unwrap();
13079 assert_eq!(status.health.consecutive_failures, failures);
13080 if failures < harness.runtime.health.failure_threshold {
13081 assert_eq!(status.health.status, super::SupervisorHealthStatus::Unknown);
13082 assert_eq!(status.state, super::ModuleState::Running);
13083 assert_eq!(status.lifetime_restarts, 0);
13084 assert!(harness.runtime.scheduled_respawn.lock().unwrap().is_none());
13085 } else {
13086 assert_eq!(
13087 status.health.status,
13088 super::SupervisorHealthStatus::Unresponsive
13089 );
13090 assert_eq!(status.state, super::ModuleState::Restarting);
13091 assert_eq!(status.restart_count, 1);
13092 assert_eq!(status.lifetime_restarts, 1);
13093 assert_eq!(status.health.last_action.as_deref(), Some("restart"));
13094 assert!(harness.runtime.scheduled_respawn.lock().unwrap().is_some());
13095 }
13096 }
13097 }
13098
13099 #[tokio::test(start_paused = true)]
13100 async fn late_health_answers_record_start_anchored_latency_for_two_stalls() {
13101 let mut harness = probe_harness();
13102
13103 let first = finish_after(&mut harness, Duration::from_secs(8)).await;
13104 let first_latency = match &first {
13105 ModuleControlRpcCompletion::LateHealthAnswer { latency, .. } => *latency,
13106 other => panic!("late answer was not retained: {other:?}"),
13107 };
13108 assert!(harness.handler.observe_module_control_completion(first));
13109
13110 let second = finish_after(&mut harness, Duration::from_secs(11)).await;
13111 let second_latency = match &second {
13112 ModuleControlRpcCompletion::LateHealthAnswer { latency, .. } => *latency,
13113 other => panic!("late answer was not retained: {other:?}"),
13114 };
13115 assert!(harness.handler.observe_module_control_completion(second));
13116
13117 assert_eq!(first_latency, Duration::from_secs(8));
13118 assert_eq!(
13119 second_latency - first_latency,
13120 Duration::from_secs(3),
13121 "latency must grow linearly with the additional stall"
13122 );
13123 let health = harness.module.status().unwrap().health;
13124 assert_eq!(health.late_answer_count, 2);
13125 assert_eq!(health.last_late_answer_latency_ms, Some(11_000));
13126 }
13127
13128 #[tokio::test(start_paused = true)]
13136 async fn late_answer_clears_the_consecutive_failure_streak() {
13137 let mut harness = probe_harness();
13138
13139 time_out_without_answer(&mut harness).await;
13141 harness
13142 .module
13143 .record_health_probe_failure_for_test("[no-answer] test miss")
13144 .unwrap();
13145 assert_eq!(
13146 harness.module.status().unwrap().health.consecutive_failures,
13147 1,
13148 "precondition: the miss must be on the streak before the late answer"
13149 );
13150
13151 let late = finish_after(&mut harness, Duration::from_secs(9)).await;
13153 assert!(matches!(
13154 late,
13155 ModuleControlRpcCompletion::LateHealthAnswer { .. }
13156 ));
13157 assert!(harness.handler.observe_module_control_completion(late));
13158
13159 let health = harness.module.status().unwrap().health;
13160 assert_eq!(
13161 health.consecutive_failures, 0,
13162 "a late answer is an answer: the streak must reset"
13163 );
13164 assert_eq!(health.late_answer_count, 1);
13165 }
13166
13167 #[tokio::test(start_paused = true)]
13168 async fn repeated_serial_probe_cycles_keep_one_tombstone_per_endpoint() {
13169 let mut harness = probe_harness();
13170
13171 for _ in 0..20 {
13172 time_out_without_answer(&mut harness).await;
13173 assert_eq!(
13174 harness.forwarding.health_probe_tombstone_count().unwrap(),
13175 1
13176 );
13177 }
13178 }
13179}
13180
13181#[cfg(test)]
13182mod child_env_tests {
13183 #[cfg(windows)]
13184 use super::{
13185 clear_current_process_facts, observed_launch_nonce_source, ModuleState, SupervisorSnapshot,
13186 };
13187 #[cfg(windows)]
13188 use std::sync::{Arc, OnceLock};
13189 #[cfg(windows)]
13190 #[test]
13191 fn windows_wire_spawn_prepares_pipe_and_keeps_phase_one_env() {
13192 let spec = spec(Vec::new());
13193 let mut command = Command::new("unused");
13194 let handoff = apply_wire_spawn_args(&mut command, &spec, None, None)
13195 .unwrap()
13196 .unwrap();
13197 let name = command
13198 .as_std()
13199 .get_envs()
13200 .find(|(key, _)| *key == OsStr::new(subc_os::launch_nonce::LAUNCH_NONCE_PIPE_ENV))
13201 .and_then(|(_, value)| value)
13202 .unwrap();
13203 assert_eq!(name, OsStr::new(handoff.name()));
13204 assert!(handoff.name().starts_with(r"\\.\pipe\subc-launch-"));
13205 assert!(command.as_std().get_envs().any(|(key, value)| key
13206 == OsStr::new(subc_os::LAUNCH_NONCE_PIPE_FALLBACK_ENV)
13207 && value == Some(OsStr::new("env"))));
13208 assert!(command
13209 .as_std()
13210 .get_envs()
13211 .any(|(key, value)| key == OsStr::new(SUBC_LAUNCH_NONCE_ENV) && value.is_some()));
13212 }
13213
13214 #[cfg(windows)]
13215 #[test]
13216 fn windows_source_reporting_tracks_consumption_and_resets_per_spawn() {
13217 let mut snapshot = SupervisorSnapshot::new(ModuleState::Running, true);
13218 snapshot.process_alive = true;
13219 snapshot.spawned_protocol = Some(ModuleProtocol::Subc);
13220 assert_eq!(
13221 observed_launch_nonce_source(&snapshot).as_deref(),
13222 Some("env")
13223 );
13224 let consumed = Arc::new(OnceLock::new());
13225 snapshot.pipe_consumed = Some(consumed.clone());
13226 assert_eq!(
13227 observed_launch_nonce_source(&snapshot).as_deref(),
13228 Some("env")
13229 );
13230 consumed.set(()).unwrap();
13231 assert_eq!(
13232 observed_launch_nonce_source(&snapshot).as_deref(),
13233 Some("pipe")
13234 );
13235 snapshot.pipe_consumed = Some(Arc::new(OnceLock::new()));
13236 assert_eq!(
13237 observed_launch_nonce_source(&snapshot).as_deref(),
13238 Some("env")
13239 );
13240 clear_current_process_facts(&mut snapshot);
13241 assert_eq!(observed_launch_nonce_source(&snapshot), None);
13242 }
13243 use super::{
13244 apply_child_env, apply_spawn_role, apply_wire_spawn_args, ModuleProtocol, ModuleSpec,
13245 SpawnRole, SupervisorHandle, SPAWN_ROLE_SWAP_CANDIDATE, SUBC_ARG, SUBC_LAUNCH_NONCE_ENV,
13246 SUBC_MODULE_ID_ENV, SUBC_SPAWN_ROLE_ENV,
13247 };
13248 use std::{ffi::OsStr, path::PathBuf};
13249 use tokio::process::Command;
13250
13251 fn spec(env: Vec<(String, String)>) -> ModuleSpec {
13252 ModuleSpec {
13253 module_id: "env-plan".to_string(),
13254 program: PathBuf::from("/nonexistent"),
13255 args: Vec::new(),
13256 env,
13257 reserved: false,
13258 reserved_prefixes: Vec::new(),
13259 protocol: ModuleProtocol::Subc,
13260 overlap: Default::default(),
13261 }
13262 }
13263
13264 #[test]
13278 fn ambient_ck_log_is_removed_and_a_configured_one_survives() {
13279 let mut command = Command::new("/nonexistent");
13280 apply_child_env(&mut command, &spec(Vec::new()));
13281 let removed = command
13282 .as_std()
13283 .get_envs()
13284 .any(|(key, value)| key == OsStr::new("CK_LOG") && value.is_none());
13285 assert!(
13286 removed,
13287 "ambient CK_LOG must be explicitly removed for an unconfigured module"
13288 );
13289
13290 let mut configured = Command::new("/nonexistent");
13291 apply_child_env(
13292 &mut configured,
13293 &spec(vec![("CK_LOG".to_string(), "debug".to_string())]),
13294 );
13295 let effective = configured
13296 .as_std()
13297 .get_envs()
13298 .filter(|(key, _)| *key == OsStr::new("CK_LOG"))
13299 .last()
13300 .map(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()));
13301 assert_eq!(
13302 effective,
13303 Some(Some("debug".to_string())),
13304 "a module's configured CK_LOG must survive the ambient removal"
13305 );
13306 }
13307
13308 #[test]
13317 fn protocol_none_spawn_carries_no_subc_argument_and_no_nonce() {
13318 let connection_file = std::path::Path::new("/run/subc-connection.json");
13319 let handle = SupervisorHandle::new();
13320
13321 let mut none_spec = spec(Vec::new());
13322 none_spec.protocol = ModuleProtocol::None;
13323 let mut none = Command::new("/nonexistent");
13324 let none_handoff =
13325 apply_wire_spawn_args(&mut none, &none_spec, Some(connection_file), Some(&handle))
13326 .expect("protocol-none spawn args apply");
13327 assert!(
13328 none_handoff.is_none(),
13329 "protocol:none spawn must not receive a nonce descriptor"
13330 );
13331 #[cfg(windows)]
13332 assert!(!none.as_std().get_envs().any(|(key, value)| key
13333 == OsStr::new(subc_os::LAUNCH_NONCE_PIPE_FALLBACK_ENV)
13334 && value.is_some()));
13335 assert!(
13336 !none.as_std().get_envs().any(|(key, value)| key
13337 == OsStr::new(subc_os::LAUNCH_NONCE_FD_ENV)
13338 && value.is_some()),
13339 "protocol:none spawn must not name a nonce descriptor"
13340 );
13341 let none_args: Vec<String> = none
13342 .as_std()
13343 .get_args()
13344 .map(|a| a.to_string_lossy().into_owned())
13345 .collect();
13346 assert!(
13347 !none_args.iter().any(|a| a == SUBC_ARG),
13348 "protocol:none argv must not carry --subc; got {none_args:?}"
13349 );
13350 let none_has_nonce = none
13351 .as_std()
13352 .get_envs()
13353 .any(|(key, value)| key == OsStr::new(SUBC_LAUNCH_NONCE_ENV) && value.is_some());
13354 assert!(
13355 !none_has_nonce,
13356 "protocol:none spawn must not receive a launch nonce"
13357 );
13358 let none_has_module_id = none
13359 .as_std()
13360 .get_envs()
13361 .any(|(key, value)| key == OsStr::new(SUBC_MODULE_ID_ENV) && value.is_some());
13362 assert!(
13363 none_has_module_id,
13364 "SUBC_MODULE_ID is inert and stays on every path"
13365 );
13366 assert!(
13367 handle.spawn_nonce(&none_spec.module_id).is_none(),
13368 "no nonce record for a process that will never present one"
13369 );
13370
13371 let wire_spec = spec(Vec::new());
13373 let mut wire = Command::new("/nonexistent");
13374 let wire_handoff =
13375 apply_wire_spawn_args(&mut wire, &wire_spec, Some(connection_file), Some(&handle))
13376 .expect("subc-wire spawn args apply");
13377 let wire_fd_env = wire
13378 .as_std()
13379 .get_envs()
13380 .find(|(key, _)| *key == OsStr::new(subc_os::LAUNCH_NONCE_FD_ENV))
13381 .and_then(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()));
13382 #[cfg(unix)]
13383 assert_eq!(
13384 wire_fd_env,
13385 Some(wire_handoff.expect("a descriptor handoff").fd_env_value()),
13386 "a subc-wire spawn names the pipe it will receive at descriptor 3"
13387 );
13388 #[cfg(windows)]
13389 assert!(wire_handoff.is_some() && wire_fd_env.is_none());
13390 #[cfg(not(any(unix, windows)))]
13391 assert!(wire_handoff.is_none() && wire_fd_env.is_none());
13392 let wire_args: Vec<String> = wire
13393 .as_std()
13394 .get_args()
13395 .map(|a| a.to_string_lossy().into_owned())
13396 .collect();
13397 assert_eq!(
13398 wire_args,
13399 vec![
13400 SUBC_ARG.to_string(),
13401 connection_file.to_string_lossy().into_owned()
13402 ],
13403 "a subc-wire spawn still carries --subc <path>"
13404 );
13405 assert_eq!(
13406 wire.as_std()
13407 .get_envs()
13408 .any(|(key, value)| key == OsStr::new(SUBC_LAUNCH_NONCE_ENV) && value.is_some()),
13409 !cfg!(unix),
13410 "only Windows supplies the environment nonce"
13411 );
13412 assert!(handle.spawn_nonce(&wire_spec.module_id).is_some());
13413 }
13414
13415 #[test]
13425 fn plain_spawn_removes_the_spawn_role_even_when_the_spec_sets_it() {
13426 let role = |command: &Command| {
13427 command
13428 .as_std()
13429 .get_envs()
13430 .filter(|(key, _)| *key == OsStr::new(SUBC_SPAWN_ROLE_ENV))
13431 .last()
13432 .map(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()))
13433 };
13434 let forged = spec(vec![(
13435 SUBC_SPAWN_ROLE_ENV.to_string(),
13436 SPAWN_ROLE_SWAP_CANDIDATE.to_string(),
13437 )]);
13438
13439 let mut plain = Command::new("/nonexistent");
13440 apply_child_env(&mut plain, &forged);
13441 apply_spawn_role(&mut plain, SpawnRole::Plain);
13442 assert_eq!(
13443 role(&plain),
13444 Some(None),
13445 "a plain spawn must remove SUBC_SPAWN_ROLE, whatever the spec says"
13446 );
13447
13448 let mut candidate = Command::new("/nonexistent");
13449 apply_child_env(&mut candidate, &spec(Vec::new()));
13450 apply_spawn_role(&mut candidate, SpawnRole::SwapCandidate);
13451 assert_eq!(
13452 role(&candidate),
13453 Some(Some(SPAWN_ROLE_SWAP_CANDIDATE.to_string()))
13454 );
13455 }
13456
13457 #[test]
13463 fn daemon_private_capture_keys_are_not_passed_to_the_child() {
13464 let mut command = Command::new("/nonexistent");
13465 apply_child_env(
13466 &mut command,
13467 &spec(vec![
13468 (super::CAPTURE_KEEP_ENV.to_string(), "5".to_string()),
13469 ("KEPT".to_string(), "yes".to_string()),
13470 ]),
13471 );
13472 let keys: Vec<String> = command
13473 .as_std()
13474 .get_envs()
13475 .filter(|(_, value)| value.is_some())
13476 .map(|(key, _)| key.to_string_lossy().into_owned())
13477 .collect();
13478 assert!(keys.contains(&"KEPT".to_string()), "got {keys:?}");
13479 assert!(
13480 !keys.contains(&super::CAPTURE_KEEP_ENV.to_string()),
13481 "daemon-private capture key leaked to the child: {keys:?}"
13482 );
13483 }
13484}
13485
13486#[cfg(test)]
13487mod jitter_tests {
13488 use super::jittered_health_delay;
13489 use std::{collections::HashSet, time::Duration};
13490
13491 const FLEET: [&str; 14] = [
13500 "aft",
13501 "alfonso-core",
13502 "magic-context",
13503 "broca",
13504 "thalamus",
13505 "quota",
13506 "engram",
13507 "plexus",
13508 "cerebellum",
13509 "astrocyte",
13510 "synapse",
13511 "subc-mcp",
13512 "cortexkit-credentials",
13513 "subc-federation",
13514 ];
13515
13516 #[test]
13524 fn probe_delays_disperse_across_the_fleet() {
13525 let cadence = Duration::from_secs(30);
13526 let delays: HashSet<Duration> = FLEET
13527 .iter()
13528 .map(|id| jittered_health_delay(id, 0, cadence))
13529 .collect();
13530 assert_eq!(
13531 delays.len(),
13532 FLEET.len(),
13533 "every supervised module must land on its own probe offset"
13534 );
13535 }
13536
13537 #[test]
13543 fn jitter_only_delays_and_stays_within_one_tenth_of_cadence() {
13544 let cadence = Duration::from_secs(30);
13545 let span = cadence / 10;
13546 for id in FLEET {
13547 for probe_index in 0..8 {
13548 let delay = jittered_health_delay(id, probe_index, cadence);
13549 assert!(
13550 delay >= cadence,
13551 "{id}#{probe_index}: jitter must not shorten the cadence"
13552 );
13553 assert!(
13554 delay < cadence + span,
13555 "{id}#{probe_index}: jitter must stay inside one tenth of the cadence"
13556 );
13557 }
13558 }
13559 }
13560
13561 #[test]
13567 fn a_module_offset_is_stable_across_restarts() {
13568 let cadence = Duration::from_secs(30);
13569 for id in FLEET {
13570 assert_eq!(
13571 jittered_health_delay(id, 0, cadence),
13572 jittered_health_delay(id, 0, cadence),
13573 "{id}: the same module and probe index must produce the same offset"
13574 );
13575 }
13576 }
13577
13578 #[test]
13580 fn zero_cadence_yields_zero_delay() {
13581 assert_eq!(
13582 jittered_health_delay("aft", 0, Duration::ZERO),
13583 Duration::ZERO
13584 );
13585 }
13586}
13587
13588#[cfg(all(test, target_os = "linux"))]
13589mod cgroup_placement_tests {
13590 use super::{
13591 apply_cgroup_placement, remove_module_cgroup, ModuleProtocol, ModuleSpec, SuperviseError,
13592 SupervisedChild,
13593 };
13594 use crate::stderr_tail::{StderrRing, StderrTailConfig};
13595 use cortexkit_test_support::ScratchDir;
13596 use std::{
13597 fs, io,
13598 path::{Path, PathBuf},
13599 sync::{Arc, Mutex},
13600 };
13601 use tokio::process::Command;
13602
13603 #[tokio::test]
13604 async fn unique_spawn_cgroups_clean_up_across_restarts_and_shutdown() {
13605 use super::*;
13606 let dir = ScratchDir::new("unique-spawn-cgroups");
13607 let root = PathBuf::from(format!(
13608 "/sys/fs/cgroup/subc-unique-test-{}-{}",
13609 std::process::id(),
13610 unix_ms_now()
13611 ));
13612 if let Err(error) = fs::create_dir(&root) {
13613 assert!(
13614 std::env::var_os("SUBC_REQUIRE_CGROUP_TEST").is_none(),
13615 "required cgroup test cannot execute: {error}"
13616 );
13617 eprintln!("SKIP unique_spawn_cgroups_clean_up_across_restarts_and_shutdown: {error}");
13618 return;
13619 }
13620 let placement = subc_cgroup::prepare_at(&root).unwrap().unwrap();
13621 let group_count = || {
13622 fs::read_dir(root.join("subc-modules"))
13623 .unwrap()
13624 .map(|entry| entry.unwrap().file_type().unwrap())
13625 .filter(|kind| kind.is_dir())
13626 .count()
13627 };
13628 let supervisor =
13629 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
13630 .with_cgroup_placement(Some(placement.clone()));
13631 let runtime = supervisor.runtime_config();
13632 let mut spec = ModuleSpec {
13633 module_id: "unique-spawn".into(),
13634 program: PathBuf::from("/bin/sleep"),
13635 args: vec!["60".into()],
13636 env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
13637 .into_iter()
13638 .map(|key| (key.into(), dir.to_string_lossy().into_owned()))
13639 .collect(),
13640 reserved: false,
13641 reserved_prefixes: vec![],
13642 protocol: ModuleProtocol::None,
13643 overlap: Default::default(),
13644 };
13645 let spawn = |spec: &ModuleSpec| {
13646 spawn_child(
13647 spec,
13648 None,
13649 None,
13650 &runtime.stderr_ring,
13651 None,
13652 &runtime.child_roster,
13653 Some(&placement),
13654 )
13655 .unwrap()
13656 };
13657 let mut live = spawn(&spec);
13658 for _ in 0..3 {
13659 let next = spawn(&spec);
13661 assert_ne!(live.module_id, next.module_id);
13662 live.start_kill().unwrap();
13663 live.wait().await.unwrap();
13664 live = next;
13665 assert!(
13666 live.child.try_wait().unwrap().is_none(),
13667 "retiring the old slot must not kill the replacement"
13668 );
13669 assert_eq!(
13670 group_count(),
13671 1,
13672 "only the live spawn's cgroup should remain"
13673 );
13674 }
13675 supervisor.begin_daemon_shutdown();
13676 let reap = tokio::spawn(async move {
13677 live.wait().await.unwrap();
13678 });
13679 supervisor
13680 .end_children_for_daemon_shutdown(false, std::future::pending())
13681 .await;
13682 reap.await.unwrap();
13683 assert_eq!(group_count(), 0);
13684 spec.program = PathBuf::from("/bin/true");
13686 spec.args.clear();
13687 let fresh_roster = ChildRoster::default();
13688 let mut short = spawn_child(
13689 &spec,
13690 None,
13691 None,
13692 &runtime.stderr_ring,
13693 None,
13694 &fresh_roster,
13695 Some(&placement),
13696 )
13697 .unwrap();
13698 short.wait().await.unwrap();
13699 assert_eq!(group_count(), 0);
13700 spec.module_id = "_".repeat(255);
13701 let mut long_id = spawn_child(
13702 &spec,
13703 None,
13704 None,
13705 &runtime.stderr_ring,
13706 None,
13707 &fresh_roster,
13708 Some(&placement),
13709 )
13710 .unwrap();
13711 long_id.wait().await.unwrap();
13712 assert_eq!(
13713 group_count(),
13714 0,
13715 "valid long module IDs must not exceed cgroup NAME_MAX"
13716 );
13717 fs::remove_dir(root.join("subc-modules")).unwrap();
13718 fs::remove_dir(root).unwrap();
13719 eprintln!("EXECUTED unique_spawn_cgroups_clean_up_across_restarts_and_shutdown");
13720 }
13721
13722 #[test]
13723 fn failed_parent_cgroup_open_is_a_cgroup_supervision_error() {
13724 let path = Path::new("/definitely-missing-subc-cgroup");
13725 let mut command = Command::new("true");
13726 let error = apply_cgroup_placement(
13727 &mut command,
13728 &ModuleSpec {
13729 module_id: "broken-cgroup".to_string(),
13730 program: PathBuf::from("true"),
13731 args: Vec::new(),
13732 env: Vec::new(),
13733 reserved: false,
13734 reserved_prefixes: Vec::new(),
13735 protocol: ModuleProtocol::Subc,
13736 overlap: Default::default(),
13737 },
13738 path,
13739 )
13740 .expect_err("a parent cgroup open failure must reject the supervised spawn");
13741 let reason = error.to_string();
13742
13743 assert!(
13744 matches!(error, SuperviseError::Cgroup { .. }),
13745 "parent cgroup open must be reported as a cgroup supervision error: {reason}"
13746 );
13747 assert!(
13748 reason.contains("/definitely-missing-subc-cgroup/cgroup.procs"),
13749 "parent cgroup open failure must name cgroup.procs: {reason}"
13750 );
13751 }
13752
13753 #[tokio::test]
13754 async fn reaping_a_child_removes_its_empty_module_cgroup() {
13755 let root = ScratchDir::new("supervisor-reap-cgroup");
13756 fs::write(root.join("cgroup.procs"), b"").expect("write scratch cgroup marker");
13757 let placement = subc_cgroup::prepare_at(&root)
13758 .expect("prepare scratch cgroup root")
13759 .expect("scratch root has a cgroup.procs marker");
13760 let module_id = "reaped-module";
13761 let module = placement
13762 .module_path(module_id)
13763 .expect("create scratch module cgroup");
13764 let child = Command::new("true")
13765 .env("XDG_DATA_HOME", root.path())
13766 .env("XDG_RUNTIME_DIR", root.path())
13767 .env("XDG_CONFIG_HOME", root.path())
13768 .spawn()
13769 .expect("spawn short-lived child");
13770 let pid = child.id().expect("spawned child has pid");
13771 let mut child = SupervisedChild {
13772 child,
13773 protocol: ModuleProtocol::Subc,
13774 module_id: module_id.to_string(),
13775 cgroup_placement: Some(placement),
13776 stdout_pump: None,
13777 stderr_pump: None,
13778 stderr_ring: Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default()))),
13779 spawned_at_ms: 0,
13780 spawned_from: PathBuf::from("true"),
13781 spawned_file_identity: None,
13782 process_start_time: None,
13783 process_identity: None,
13784 pid,
13785 roster_guard: None,
13786 #[cfg(target_os = "macos")]
13787 privacy_exec: None,
13788 spawn_failure: None,
13789 };
13790
13791 child.wait().await.expect("reap short-lived child");
13792
13793 assert!(
13794 !module.exists(),
13795 "reaping the supervised child must remove its empty cgroup"
13796 );
13797 }
13798
13799 #[test]
13800 fn non_empty_cgroup_removal_is_reported_without_blocking_teardown() {
13801 let root = ScratchDir::new("supervisor-non-empty-cgroup");
13802 fs::write(root.join("cgroup.procs"), b"").expect("write scratch cgroup marker");
13803 let placement = subc_cgroup::prepare_at(&root)
13804 .expect("prepare scratch cgroup root")
13805 .expect("scratch root has a cgroup.procs marker");
13806 let module = placement
13807 .module_path("surviving-module")
13808 .expect("create scratch module cgroup");
13809 fs::write(module.join("surviving-process"), b"still present")
13810 .expect("make scratch cgroup non-empty");
13811 let (logs, _guard) = crate::router::test_log::log_capture(tracing::Level::WARN);
13812
13813 remove_module_cgroup(&placement, "surviving-module");
13814
13815 let logs = crate::router::test_log::captured_logs(&logs);
13816 assert!(
13817 module.exists(),
13818 "failed removal must leave the cgroup intact"
13819 );
13820 assert!(
13821 logs.contains("could not remove module cgroup after process exit; continuing teardown")
13822 && logs.contains("surviving-module"),
13823 "best-effort removal must report the failure without returning it: {logs}"
13824 );
13825 }
13826
13827 #[test]
13828 fn cgroup_pre_exec_spawn_failure_names_the_cgroup_path() {
13829 let cgroup_path = PathBuf::from("/sys/fs/cgroup/subc-modules/broken-module");
13830 let reason = SuperviseError::Spawn {
13831 program: PathBuf::from("/bin/true"),
13832 source: io::Error::from_raw_os_error(13),
13833 cgroup_path: Some(cgroup_path.clone()),
13834 }
13835 .to_string();
13836
13837 assert!(
13838 reason.contains(&cgroup_path.display().to_string()),
13839 "a pre_exec spawn failure must name the cgroup path: {reason}"
13840 );
13841 }
13842}
13843
13844#[cfg(test)]
13845mod spawn_subscriber_lag_tests {
13846 use super::*;
13847
13848 #[tokio::test]
13853 async fn lagged_spawn_subscriber_receives_a_terminal_lagged_error_after_its_queued_frames() {
13854 let feed = SpawnEventFeed::default();
13855 feed.configure_incarnation("lag-incarnation".to_string());
13856 let (tx, mut rx) = mpsc::channel(1);
13859 feed.subscribe(ConnectionId::new(1), 7, 1, None, FrameSink::new(tx))
13860 .expect("subscribe");
13861 let emitted = SPAWN_SUBSCRIBER_BUFFER + 16;
13862 for index in 0..emitted {
13863 feed.emit_spawned(&format!("lag-module-{index}"), 1000, 0);
13864 tokio::task::yield_now().await;
13867 }
13868 assert_eq!(
13869 feed.subscriber_count(),
13870 0,
13871 "the lagged subscriber must be removed"
13872 );
13873
13874 let mut data = Vec::new();
13875 let mut last = None;
13876 loop {
13877 let next = tokio::time::timeout(Duration::from_secs(5), rx.recv())
13878 .await
13879 .expect("the forwarder must finish once the subscriber is dropped");
13880 let Some(outbound) = next else { break };
13881 let frame = outbound.frame;
13882 if frame.header.ty == FrameType::StreamData {
13883 assert!(last.is_none(), "no data may follow the terminal frame");
13884 let event: SpawnEvent = serde_json::from_slice(&frame.body).unwrap();
13885 data.push(event.cursor.seq);
13886 } else {
13887 assert!(last.is_none(), "exactly one terminal frame");
13888 last = Some(frame);
13889 }
13890 }
13891 assert!(!data.is_empty(), "queued frames drain before the terminal");
13892 for pair in data.windows(2) {
13893 assert_eq!(
13894 pair[1],
13895 pair[0] + 1,
13896 "queued frames arrive dense and in order"
13897 );
13898 }
13899 let terminal = last.expect("a lagged subscriber must receive a terminal frame");
13900 assert_eq!(terminal.header.ty, FrameType::Error);
13901 assert_eq!(terminal.header.corr, 7);
13902 let body: subc_protocol::ErrorBody = serde_json::from_slice(&terminal.body).unwrap();
13903 assert_eq!(body.code, SPAWN_SUBSCRIBER_LAGGED_CODE);
13904 let detail = body.detail.expect("lagged error carries detail");
13905 assert_eq!(
13906 detail["first_undelivered_cursor"]["seq"],
13907 data.last().unwrap() + 1,
13908 "the named cursor is the first event the subscriber did not receive"
13909 );
13910 assert_eq!(
13911 detail["first_undelivered_cursor"]["daemon_incarnation"],
13912 "lag-incarnation"
13913 );
13914 }
13915}
13916
13917#[cfg(test)]
13918mod terminal_history_read_concurrency_tests {
13919 use super::*;
13920 use crate::terminal_journal::read_pause;
13921 use cortexkit_test_support::ScratchDir;
13922 use std::sync::mpsc as std_mpsc;
13923
13924 fn journaled_ring(
13925 journal: &Arc<crate::terminal_journal::TerminalJournal>,
13926 ) -> Arc<Mutex<TerminalRing>> {
13927 Arc::new(Mutex::new(
13928 TerminalRing::new(TerminalRingConfig::default(), 1)
13929 .with_journal(Some(Arc::clone(journal))),
13930 ))
13931 }
13932
13933 fn crash(at_ms: u64) -> ExitReport {
13934 ExitReport {
13935 kind: ExitKind::Crash,
13936 code: Some(1),
13937 signal: None,
13938 at_ms,
13939 }
13940 }
13941
13942 fn record_within(
13945 module_id: &'static str,
13946 ring: &Arc<Mutex<TerminalRing>>,
13947 at_ms: u64,
13948 bound: Duration,
13949 ) -> bool {
13950 let ring = Arc::clone(ring);
13951 let (done, done_rx) = std_mpsc::channel();
13952 std::thread::spawn(move || {
13953 record_terminal(
13954 module_id,
13955 &ring,
13956 &SpawnEventFeed::default(),
13957 &crash(at_ms),
13958 TerminalDisposition::Restarting,
13959 );
13960 let _ = done.send(());
13961 });
13962 done_rx.recv_timeout(bound).is_ok()
13963 }
13964
13965 #[test]
13970 fn exits_recorded_during_a_paused_history_read_are_not_blocked_or_half_merged() {
13971 let dir = ScratchDir::new("terminal-history-concurrent-read");
13972 let path = dir.join("terminals.jsonl");
13973 let journal = Arc::new(crate::terminal_journal::TerminalJournal::open(
13974 path.clone(),
13975 "daemon".into(),
13976 ));
13977 let reader_ring = journaled_ring(&journal);
13978 let other_ring = journaled_ring(&journal);
13979 assert!(record_within(
13980 "reader-module",
13981 &reader_ring,
13982 10,
13983 Duration::from_secs(5)
13984 ));
13985
13986 let (started, release) = read_pause::install(&path);
13987 let reading = {
13988 let ring = Arc::clone(&reader_ring);
13989 std::thread::spawn(move || durable_terminal_history_of(&ring, "reader-module"))
13990 };
13991 started
13992 .recv_timeout(Duration::from_secs(5))
13993 .expect("the history read reached its pause");
13994
13995 let bound = Duration::from_secs(1);
13996 assert!(
13997 record_within("other-module", &other_ring, 20, bound),
13998 "another module's exit waited on a history read (journal writer held)"
13999 );
14000 assert!(
14001 record_within("reader-module", &reader_ring, 30, bound),
14002 "the read module's own exit waited on its history read (ring held)"
14003 );
14004
14005 drop(release);
14006 let paused = reading.join().unwrap();
14007 assert_eq!(
14008 paused.entries.iter().map(|e| e.at_ms).collect::<Vec<_>>(),
14009 vec![10],
14010 "an exit recorded after the read began lands in neither half of it"
14011 );
14012 assert_eq!(paused.journal_skipped_lines, 0);
14013 assert_eq!(paused.journal_read_errors, 0);
14014
14015 let after = durable_terminal_history_of(&reader_ring, "reader-module");
14016 assert_eq!(
14017 after.entries.iter().map(|e| e.at_ms).collect::<Vec<_>>(),
14018 vec![10, 30],
14019 "the next read merges ring and journal with no duplicate"
14020 );
14021 assert_eq!(after.journal_skipped_lines, 0);
14022 }
14023}
14024
14025#[cfg(test)]
14030mod stderr_settle_tests {
14031 use std::{
14032 future::Future,
14033 io,
14034 pin::Pin,
14035 sync::{Arc, Mutex},
14036 task::{Context, Poll},
14037 time::Duration,
14038 };
14039
14040 use tokio::{
14041 io::{AsyncRead, ReadBuf},
14042 sync::oneshot,
14043 time::Instant,
14044 };
14045
14046 use super::{settle_stderr_pump, settle_stdout_pump, StderrPump};
14047 use crate::stderr_tail::{
14048 pump_stderr_to, pump_stdout_to, split_capture_stamp, untimed, CaptureState,
14049 ChildOutputSink, OutputSink, StderrRing, StderrTailConfig, TailEntry,
14050 };
14051
14052 const BOUND: Duration = Duration::from_millis(250);
14053
14054 struct HeldReader {
14058 before: Option<Vec<u8>>,
14059 gate: Option<oneshot::Receiver<()>>,
14060 after: io::Cursor<Vec<u8>>,
14061 }
14062
14063 impl AsyncRead for HeldReader {
14064 fn poll_read(
14065 mut self: Pin<&mut Self>,
14066 cx: &mut Context<'_>,
14067 buf: &mut ReadBuf<'_>,
14068 ) -> Poll<io::Result<()>> {
14069 if let Some(bytes) = self.before.take() {
14070 buf.put_slice(&bytes);
14071 return Poll::Ready(Ok(()));
14072 }
14073 if let Some(gate) = self.gate.as_mut() {
14074 match Pin::new(gate).poll(cx) {
14075 Poll::Pending => return Poll::Pending,
14076 Poll::Ready(_) => self.gate = None,
14077 }
14078 }
14079 Pin::new(&mut self.after).poll_read(cx, buf)
14080 }
14081 }
14082
14083 struct DiscardSink;
14084
14085 impl OutputSink for DiscardSink {
14086 fn write_line(&mut self, _line: &[u8]) {}
14087 }
14088
14089 fn line(text: &str) -> TailEntry {
14090 TailEntry::Line {
14091 text: text.to_string(),
14092 truncated: false,
14093 at_ms: None,
14094 }
14095 }
14096
14097 fn lock(ring: &Arc<Mutex<StderrRing>>) -> std::sync::MutexGuard<'_, StderrRing> {
14098 ring.lock().unwrap()
14099 }
14100
14101 fn held_pump(
14105 ring: &Arc<Mutex<StderrRing>>,
14106 before: &str,
14107 after: &str,
14108 ) -> (StderrPump, oneshot::Sender<()>) {
14109 let generation = lock(ring).begin_process();
14110 let (release, gate) = oneshot::channel();
14111 let reader = HeldReader {
14112 before: Some(before.as_bytes().to_vec()),
14113 gate: Some(gate),
14114 after: io::Cursor::new(after.as_bytes().to_vec()),
14115 };
14116 let task = tokio::spawn(pump_stderr_to(
14117 reader,
14118 Arc::clone(ring),
14119 generation,
14120 DiscardSink,
14121 ));
14122 (StderrPump { task, generation }, release)
14123 }
14124
14125 async fn wait_until(ring: &Arc<Mutex<StderrRing>>, done: impl Fn(&StderrRing) -> bool) {
14126 for _ in 0..1000 {
14127 if done(&lock(ring)) {
14128 return;
14129 }
14130 tokio::time::sleep(Duration::from_millis(1)).await;
14131 }
14132 panic!(
14133 "ring never reached the expected state: {:?}",
14134 lock(ring).snapshot(None, None)
14135 );
14136 }
14137
14138 #[tokio::test(start_paused = true)]
14139 async fn a_crash_line_the_reader_had_not_reached_by_the_bound_is_kept_before_the_restart() {
14140 let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
14141 let (pump, release) = held_pump(&ring, "booting\n", "config error: missing storage\n");
14142
14143 settle_stderr_pump("crasher", &ring, pump, BOUND).await;
14144 let before_release = lock(&ring).snapshot(None, None);
14145 assert!(
14146 matches!(before_release.capture, CaptureState::Incomplete { .. }),
14147 "a reader that has not reached EOF cannot claim a whole tail: {before_release:?}"
14148 );
14149
14150 let next = lock(&ring).begin_process();
14153 lock(&ring).push_line_from(next, "next process booting");
14154 release.send(()).unwrap();
14155 wait_until(&ring, |ring| {
14156 ring.snapshot(None, None).capture == CaptureState::Captured
14157 })
14158 .await;
14159
14160 assert_eq!(
14161 untimed(lock(&ring).snapshot(None, None).entries),
14162 vec![
14163 line("booting"),
14164 line("config error: missing storage"),
14165 TailEntry::ProcessStart,
14166 line("next process booting"),
14167 ],
14168 "the crash's last line must survive a slow reader and stay in the crashed process's section"
14169 );
14170 }
14171
14172 #[tokio::test(start_paused = true)]
14173 async fn a_pipe_held_open_by_a_descendant_reads_incomplete_without_delaying_the_restart_past_the_bound(
14174 ) {
14175 let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
14176 let (pump, _held) = held_pump(&ring, "parent exiting\n", "");
14179
14180 let started = Instant::now();
14181 settle_stderr_pump("orphaning", &ring, pump, BOUND).await;
14182 assert_eq!(
14183 started.elapsed(),
14184 BOUND,
14185 "the restart must wait exactly the bound for a pipe that stays open, no longer"
14186 );
14187
14188 let next = lock(&ring).begin_process();
14189 lock(&ring).push_line_from(next, "next process booting");
14190 tokio::time::sleep(Duration::from_secs(60)).await;
14191
14192 let snapshot = lock(&ring).snapshot(None, None);
14193 match &snapshot.capture {
14194 CaptureState::Incomplete { reason } => assert!(
14195 reason.contains("had not reached EOF") && reason.contains("250ms"),
14196 "the reason must say what is missing and after how long: {reason}"
14197 ),
14198 other => panic!("expected Incomplete while the pipe is held open, got {other:?}"),
14199 }
14200 assert_eq!(
14201 untimed(snapshot.entries),
14202 vec![
14203 line("parent exiting"),
14204 TailEntry::ProcessStart,
14205 line("next process booting"),
14206 ]
14207 );
14208 }
14209
14210 #[tokio::test(start_paused = true)]
14211 async fn a_reader_that_reaches_eof_within_the_bound_leaves_the_tail_captured() {
14212 let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
14213 let (pump, release) = held_pump(&ring, "one\n", "two\n");
14214 release.send(()).unwrap();
14215
14216 settle_stderr_pump("clean", &ring, pump, BOUND).await;
14217
14218 let snapshot = lock(&ring).snapshot(None, None);
14219 assert_eq!(snapshot.capture, CaptureState::Captured);
14220 assert_eq!(untimed(snapshot.entries), vec![line("one"), line("two")]);
14221 }
14222
14223 #[tokio::test(start_paused = true)]
14224 async fn stdout_buffered_past_the_exit_drain_bound_reaches_the_capture_file() {
14225 const BURST: usize = 150;
14226 let capture = cortexkit_test_support::ScratchDir::new("stdout-exit-drain");
14227 let path = capture.join("two-pipe.stderr.log");
14228 let sink = ChildOutputSink::open(&path, cortexkit_log::Retention::default()).unwrap();
14229 let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
14230 let generation = lock(&ring).begin_process();
14231 let stdout = (0..BURST)
14232 .map(|i| format!("out-{i:04}-{}\n", "o".repeat(64)))
14233 .collect::<String>();
14234 let stderr = (0..BURST)
14235 .map(|i| format!("err-{i:04}-{}\n", "e".repeat(64)))
14236 .collect::<String>();
14237 let first_chunk = stdout.find('\n').unwrap() + 1 + "out-0001-".len();
14242 let (release, gate) = oneshot::channel();
14243 let reader = HeldReader {
14244 before: Some(stdout.as_bytes()[..first_chunk].to_vec()),
14245 gate: Some(gate),
14246 after: io::Cursor::new(stdout.as_bytes()[first_chunk..stdout.len() - 1].to_vec()),
14247 };
14248 let (finished, completion) = oneshot::channel();
14249 let stdout_sink = sink.clone();
14250 let pump = tokio::spawn(async move {
14251 pump_stdout_to(reader, stdout_sink).await;
14252 finished.send(()).unwrap();
14253 });
14254 pump_stderr_to(io::Cursor::new(stderr.into_bytes()), ring, generation, sink).await;
14255
14256 let started = Instant::now();
14257 settle_stdout_pump("two-pipe", pump, BOUND).await;
14258 assert_eq!(
14259 started.elapsed(),
14260 BOUND,
14261 "a late reader must not hold up restart"
14262 );
14263 assert_eq!(
14264 std::fs::read_to_string(&path).unwrap().lines().count(),
14265 BURST + 1
14266 );
14267
14268 release
14269 .send(())
14270 .expect("stdout reader must survive the exit drain bound");
14271 tokio::time::timeout(Duration::from_secs(1), completion)
14272 .await
14273 .expect("released stdout reader must reach EOF")
14274 .expect("stdout capture must finish at EOF, not be cancelled");
14275 let contents = std::fs::read_to_string(&path).unwrap();
14276 let mut lines = contents
14277 .lines()
14278 .map(|line| split_capture_stamp(line).expect("capture stamp").1)
14279 .collect::<Vec<_>>();
14280 let mut expected = (0..BURST)
14281 .flat_map(|i| {
14282 [
14283 format!("out-{i:04}-{}", "o".repeat(64)),
14284 format!("err-{i:04}-{}", "e".repeat(64)),
14285 ]
14286 })
14287 .collect::<Vec<_>>();
14288 lines.sort_unstable();
14289 expected.sort_unstable();
14290 assert_eq!(
14291 lines, expected,
14292 "both pipes must retain every complete line exactly once"
14293 );
14294 }
14295}
14296
14297#[cfg(all(test, windows))]
14311mod job_containment_tests {
14312 use super::*;
14313 use cortexkit_test_support::ScratchDir;
14314 use std::{
14315 path::{Path, PathBuf},
14316 sync::{Arc, Mutex},
14317 time::{Duration, Instant},
14318 };
14319
14320 fn stub_path() -> PathBuf {
14326 let mut path = std::env::current_exe().expect("current_exe available in tests");
14327 path.pop();
14328 path.pop();
14329 path.push("fake-aft-stub.exe");
14330 assert!(
14331 path.exists(),
14332 "fake-aft-stub not built at {}: run `cargo test -p subc-core` (which builds \
14333 [[bin]] targets) rather than `cargo test -p subc-core --lib` (which does not)",
14334 path.display()
14335 );
14336 path
14337 }
14338
14339 fn read_grandchild_pid(path: &Path) -> u32 {
14341 let deadline = Instant::now() + Duration::from_secs(10);
14342 loop {
14343 if let Ok(contents) = std::fs::read_to_string(path) {
14344 if let Ok(pid) = contents.trim().parse() {
14345 return pid;
14346 }
14347 }
14348 assert!(
14349 Instant::now() < deadline,
14350 "the stub never recorded a grandchild pid at {}",
14351 path.display()
14352 );
14353 std::thread::sleep(Duration::from_millis(10));
14354 }
14355 }
14356
14357 struct Fixture {
14360 _dir: ScratchDir,
14361 module_id: String,
14362 grandchild: u32,
14363 child: Option<SupervisedChild>,
14364 registry: Arc<Registry>,
14365 snapshot: Arc<Mutex<SupervisorSnapshot>>,
14366 terminal_ring: Arc<Mutex<TerminalRing>>,
14367 spawn_events: SpawnEventFeed,
14368 }
14369
14370 fn fixture(label: &str, module_id: &str) -> Fixture {
14371 let dir = ScratchDir::new(label);
14372 let pid_file = dir.join("grandchild.pid");
14373 let supervisor = Supervisor::new_for_test(
14374 Arc::new(Registry::default()),
14375 RestartPolicy::new(3, Duration::ZERO),
14376 );
14377 let runtime = supervisor.runtime_config();
14378 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
14379 let spec = ModuleSpec {
14380 module_id: module_id.to_string(),
14381 program: stub_path(),
14382 args: Vec::new(),
14386 env: vec![
14387 ("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string()),
14388 (
14389 "FAKE_AFT_GRANDCHILD_PID_FILE".to_string(),
14390 pid_file.display().to_string(),
14391 ),
14392 ],
14393 reserved: false,
14394 reserved_prefixes: Vec::new(),
14395 protocol: ModuleProtocol::Subc,
14396 overlap: Default::default(),
14397 };
14398 let child = spawn_and_mark_running(&spec, &runtime, &snapshot)
14399 .expect("spawn the supervised fixture");
14400 let grandchild = read_grandchild_pid(&pid_file);
14401 Fixture {
14402 _dir: dir,
14403 module_id: module_id.to_string(),
14404 grandchild,
14405 child: Some(child),
14406 registry: Arc::new(Registry::default()),
14407 snapshot,
14408 terminal_ring: Arc::clone(&runtime.terminal_ring),
14409 spawn_events: SpawnEventFeed::default(),
14410 }
14411 }
14412
14413 impl Fixture {
14414 async fn drain(&mut self) {
14416 let child = self
14417 .child
14418 .take()
14419 .expect("the fixture child is still present");
14420 drain_child_to_state(
14421 &self.module_id,
14422 ModuleProtocol::Subc,
14423 StopNotice::NotSent,
14426 &self.registry,
14427 None,
14428 &self.snapshot,
14429 &self.terminal_ring,
14430 &self.spawn_events,
14431 child,
14432 Duration::from_millis(500),
14433 ModuleState::Stopped,
14434 Some(false),
14435 )
14436 .await
14437 .expect("drain the supervised fixture");
14438 }
14439 }
14440
14441 #[tokio::test]
14447 async fn teardown_reaps_the_grandchild() {
14448 let mut fixture = fixture("teardown-grandchild", "tree-teardown");
14449 let grandchild = fixture.grandchild;
14450
14451 assert!(
14452 subc_jobobject::process_exists(grandchild),
14453 "grandchild {grandchild} must be alive before teardown, or this proves nothing"
14454 );
14455
14456 fixture.drain().await;
14457
14458 assert!(
14459 subc_jobobject::wait_for_process_exit(grandchild, Duration::from_secs(10)),
14460 "grandchild {grandchild} outlived module teardown: the tree was not contained"
14461 );
14462 }
14463
14464 #[test]
14477 fn an_uncontained_grandchild_survives_a_direct_child_kill() {
14478 let dir = ScratchDir::new("teardown-uncontained");
14479 let pid_file = dir.join("grandchild.pid");
14480 let mut child = std::process::Command::new(stub_path())
14481 .env("FAKE_AFT_NEVER_CONNECT", "1")
14482 .env(
14483 "FAKE_AFT_GRANDCHILD_PID_FILE",
14484 pid_file.display().to_string(),
14485 )
14486 .stdin(std::process::Stdio::null())
14487 .stdout(std::process::Stdio::null())
14488 .stderr(std::process::Stdio::null())
14489 .spawn()
14490 .expect("spawn the uncontained fixture");
14491 let grandchild = read_grandchild_pid(&pid_file);
14492
14493 child.kill().expect("kill the direct child");
14495 let _ = child.wait();
14496
14497 assert!(
14498 subc_jobobject::process_exists(grandchild),
14499 "grandchild {grandchild} died with the direct child, so this control no longer \
14500 distinguishes contained from uncontained teardown and the regression test is \
14501 passing vacuously"
14502 );
14503
14504 kill_tree(grandchild);
14507 }
14508
14509 #[tokio::test]
14518 async fn dropping_containment_reaps_the_grandchild() {
14519 let mut fixture = fixture("drop-containment", "tree-drop");
14520 let grandchild = fixture.grandchild;
14521
14522 assert!(subc_jobobject::process_exists(grandchild));
14523
14524 fixture.child.as_mut().expect("child present").job = None;
14526
14527 assert!(
14528 subc_jobobject::wait_for_process_exit(grandchild, Duration::from_secs(10)),
14529 "grandchild {grandchild} survived the containment handle closing, so a daemon \
14530 crash would leave the tree behind"
14531 );
14532 }
14533
14534 fn kill_tree(pid: u32) {
14536 let _ = std::process::Command::new("taskkill.exe")
14537 .args(["/PID", &pid.to_string(), "/T", "/F"])
14538 .stdin(std::process::Stdio::null())
14539 .stdout(std::process::Stdio::null())
14540 .stderr(std::process::Stdio::null())
14541 .status();
14542 assert!(
14543 subc_jobobject::wait_for_process_exit(pid, Duration::from_secs(10)),
14544 "could not clean up grandchild {pid}"
14545 );
14546 }
14547}
14548
14549#[cfg(test)]
14550mod privacy_trampoline_configuration_tests {
14551 #[tokio::test]
14552 #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
14553 async fn macos_spawn_without_a_configured_trampoline_refuses_by_name() {
14554 #[cfg(target_os = "macos")]
14555 {
14556 let supervisor = super::Supervisor::new(
14557 std::sync::Arc::new(crate::Registry::default()),
14558 super::RestartPolicy::default(),
14559 );
14560 let error = supervisor.spawn(spec()).unwrap_err();
14561 assert!(
14562 error
14563 .to_string()
14564 .contains("no privacy trampoline configured"),
14565 "{error}"
14566 );
14567 }
14568 }
14569
14570 #[tokio::test]
14571 #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
14572 async fn macos_wrong_trampoline_is_rejected_by_the_startup_probe() {
14573 #[cfg(target_os = "macos")]
14574 {
14575 let supervisor = super::Supervisor::new(
14576 std::sync::Arc::new(crate::Registry::default()),
14577 super::RestartPolicy::default(),
14578 )
14579 .with_privacy_trampoline(std::env::current_exe().unwrap());
14580 let error = supervisor.spawn(spec()).unwrap_err();
14581 assert!(
14582 error
14583 .to_string()
14584 .contains("binary does not implement the privacy trampoline protocol"),
14585 "{error}"
14586 );
14587 }
14588 }
14589
14590 #[cfg(target_os = "macos")]
14591 fn spec() -> super::ModuleSpec {
14592 super::ModuleSpec {
14593 module_id: "privacy-configuration".into(),
14594 program: "/bin/sleep".into(),
14595 args: vec!["30".into()],
14596 env: vec![],
14597 reserved: false,
14598 reserved_prefixes: vec![],
14599 protocol: subc_control::ModuleProtocol::None,
14600 overlap: super::ModuleOverlap::Exclusive,
14601 }
14602 }
14603}
14604
14605#[cfg(test)]
14606mod privacy_exec_boundary_tests {
14607 #[cfg(target_os = "macos")]
14608 use super::*;
14609 #[cfg(target_os = "macos")]
14610 use std::{
14611 io::{Read, Write},
14612 net::{TcpListener, TcpStream},
14613 };
14614
14615 #[cfg(target_os = "macos")]
14618 pub(super) fn before_image_sample(spec: &ModuleSpec, pid: u32) {
14619 if let Some((_, path)) = spec
14620 .env
14621 .iter()
14622 .find(|(key, _)| key == "SUBC_TEST_PRIVACY_SAMPLE_BARRIER")
14623 {
14624 let mut barrier = TcpStream::connect(path).unwrap();
14625 barrier
14626 .set_read_timeout(Some(Duration::from_secs(30)))
14627 .unwrap();
14628 barrier.write_all(&pid.to_ne_bytes()).unwrap();
14629 let mut release = [0];
14630 barrier.read_exact(&mut release).unwrap();
14631 assert_eq!(&release, b"X");
14632 }
14633 }
14634
14635 #[cfg(target_os = "macos")]
14636 fn spec(program: &str, args: &[&str]) -> ModuleSpec {
14637 ModuleSpec {
14638 module_id: "privacy-boundary".into(),
14639 program: program.into(),
14640 args: args.iter().map(|arg| (*arg).into()).collect(),
14641 env: vec![],
14642 reserved: false,
14643 reserved_prefixes: vec![],
14644 protocol: ModuleProtocol::None,
14645 overlap: ModuleOverlap::Exclusive,
14646 }
14647 }
14648
14649 #[cfg(target_os = "macos")]
14650 async fn accept(listener: TcpListener) -> TcpStream {
14651 let listener = tokio::net::TcpListener::from_std({
14653 listener.set_nonblocking(true).unwrap();
14654 listener
14655 })
14656 .unwrap();
14657 let (stream, _) = tokio::time::timeout(Duration::from_secs(30), listener.accept())
14658 .await
14659 .unwrap()
14660 .unwrap();
14661 let stream = stream.into_std().unwrap();
14662 stream.set_nonblocking(false).unwrap();
14663 stream
14664 .set_read_timeout(Some(Duration::from_secs(30)))
14665 .unwrap();
14666 stream
14667 }
14668
14669 #[tokio::test]
14670 #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
14671 async fn macos_roster_withholds_a_nonnull_trampoline_image_until_exec_confirmation() {
14672 #[cfg(target_os = "macos")]
14673 {
14674 let root = cortexkit_test_support::ScratchDir::new("privacy-roster-barrier");
14675 let exec_listener = TcpListener::bind("127.0.0.1:0").unwrap();
14678 let sample_listener = TcpListener::bind("127.0.0.1:0").unwrap();
14679 let record = root.join("live-children.json");
14680 let supervisor =
14681 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
14682 .with_live_children_record(&record);
14683 let runtime = supervisor.runtime_config();
14684 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
14685 let mut spec = spec("/bin/sleep", &["30"]);
14686 spec.env = vec![
14687 (
14688 "SUBC_TEST_PRIVACY_EXEC_BARRIER".into(),
14689 exec_listener.local_addr().unwrap().to_string(),
14690 ),
14691 (
14692 "SUBC_TEST_PRIVACY_SAMPLE_BARRIER".into(),
14693 sample_listener.local_addr().unwrap().to_string(),
14694 ),
14695 ];
14696 let spawn = tokio::task::spawn_blocking(move || {
14697 spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap()
14698 });
14699 let mut sample = accept(sample_listener).await;
14700 let mut pid = [0; 4];
14701 sample.read_exact(&mut pid).unwrap();
14702 let pid = u32::from_ne_bytes(pid);
14703 let mut exec = accept(exec_listener).await;
14704 let mut ready = [0];
14705 exec.read_exact(&mut ready).unwrap();
14706 assert_eq!(&ready, b"R");
14707 let trampoline = subc_os::file_identity(&test_privacy_trampoline()).unwrap();
14710 assert_eq!(
14711 observe_spawned_image(pid).unwrap().executable,
14712 Some(trampoline)
14713 );
14714 sample.write_all(b"X").unwrap();
14715 let mut child = spawn.await.unwrap();
14716 let early = crate::live_children::read_record(&record).unwrap();
14717 assert_eq!(early.len(), 1);
14718 assert_eq!(early[0].pid, pid);
14719 assert_eq!(
14720 early[0].executable, None,
14721 "unconfirmed trampoline image entered the roster"
14722 );
14723 assert!(child.report_ready.get().is_none());
14724 child.privacy_exec.as_mut().unwrap().deadline =
14727 tokio::time::Instant::now() + Duration::from_secs(30);
14728 exec.write_all(b"X").unwrap();
14729 child.confirm_privacy_exec().await;
14730 assert_eq!(child.spawn_failure, None);
14731 assert!(child.report_ready.get().is_some());
14732 let confirmed = crate::live_children::read_record(&record).unwrap();
14733 let module = subc_os::file_identity(std::path::Path::new("/bin/sleep")).unwrap();
14734 assert_ne!(module, trampoline);
14735 assert_eq!(confirmed[0].executable, Some(module.into()));
14736 child.start_kill().unwrap();
14737 child.wait().await.unwrap();
14738 child.release_roster();
14739 }
14740 }
14741
14742 #[tokio::test]
14743 #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
14744 async fn macos_already_exited_121_in_try_wait_is_a_module_exit_not_a_trampoline_refusal() {
14745 #[cfg(target_os = "macos")]
14746 {
14747 let registry = Arc::new(Registry::default());
14748 let policy = RestartPolicy::new(0, Duration::ZERO);
14749 let supervisor = Supervisor::new_for_test(Arc::clone(®istry), policy);
14750 let runtime = supervisor.runtime_config();
14751 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
14752 let spec = spec("/bin/sh", &["-c", "exit 121"]);
14753 let mut child = spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap();
14757 let pid = child.pid;
14758 tokio::task::spawn_blocking(move || {
14759 subc_os::fork_exec_test::wait_for_child_exit_without_reaping(pid)
14760 })
14761 .await
14762 .unwrap()
14763 .unwrap();
14764 child.privacy_exec.as_mut().unwrap().deadline =
14765 tokio::time::Instant::now() + Duration::from_secs(30);
14766 let status = child.wait().await.unwrap();
14767 assert_eq!(status.code(), Some(121));
14768 assert!(child.privacy_exec.is_none());
14769 assert!(
14770 child.report_ready.get().is_none(),
14771 "an exited module must not publish a live pid"
14772 );
14773 let report = classify_reaped_child_exit(&snapshot, &child, &status);
14774 on_child_exit(
14775 &spec,
14776 policy,
14777 ®istry,
14778 &snapshot,
14779 &runtime.terminal_ring,
14780 &runtime.spawn_events,
14781 &runtime.child_roster,
14782 report,
14783 )
14784 .await;
14785 let state = lock_snapshot(&snapshot).unwrap();
14786 assert_eq!(state.state, ModuleState::Failed);
14787 assert_eq!(state.last_exit.as_ref().unwrap().code, Some(121));
14788 assert_eq!(state.reported_pid(), None);
14789 drop(state);
14790 let history = runtime.terminal_ring.lock().unwrap().snapshot();
14791 assert_eq!(history.entries.len(), 1);
14792 let terminal = &history.entries[0];
14793 assert_eq!(terminal.exit_code, Some(121));
14794 assert_eq!(terminal.exit_kind, subc_control::TerminalExitKind::Crash);
14795 assert_eq!(terminal.disposition, TerminalDisposition::Failed);
14796 assert_eq!(
14797 terminal.disposition_detail.as_deref(),
14798 Some(policy.budget_exhausted_detail().as_str()),
14799 "module exit 121 was classified as a trampoline refusal: {terminal:?}"
14800 );
14801 assert_eq!(child.spawn_failure, None);
14802 child.release_roster();
14803 }
14804 }
14805}
14806
14807#[cfg(all(test, unix))]
14811mod launch_nonce_descriptor_tests {
14812 use super::{ChildRoster, ModuleProtocol, ModuleSpec, SupervisorHandle};
14813 use crate::stderr_tail::{StderrRing, StderrTailConfig};
14814 use cortexkit_test_support::ScratchDir;
14815 use std::{
14816 path::PathBuf,
14817 sync::{Arc, Mutex},
14818 time::{Duration, Instant},
14819 };
14820
14821 async fn probe(role: super::SpawnRole) {
14822 let scratch = ScratchDir::new("launch-nonce-descriptor");
14823 let fd_copy = scratch.join("from-descriptor");
14824 let env_copy = scratch.join("environment");
14825 let script = format!(
14826 "env > '{env}.tmp' && mv '{env}.tmp' '{env}'; cat <&3 > '{fd}.tmp' && mv '{fd}.tmp' '{fd}'; sleep 30",
14827 fd = fd_copy.display(), env = env_copy.display(),
14828 );
14829 let xdg = |name: &str| (name.to_string(), scratch.join(name).display().to_string());
14830 let spec = ModuleSpec {
14831 module_id: "nonce-descriptor-probe".to_string(),
14832 program: PathBuf::from("/bin/sh"),
14833 args: vec!["-c".to_string(), script],
14834 env: vec![
14835 xdg("XDG_DATA_HOME"),
14836 xdg("XDG_RUNTIME_DIR"),
14837 xdg("XDG_CONFIG_HOME"),
14838 ("SUBC_LAUNCH_NONCE".to_string(), "must-not-leak".to_string()),
14839 ],
14840 reserved: true,
14841 reserved_prefixes: Vec::new(),
14842 protocol: ModuleProtocol::Subc,
14843 overlap: Default::default(),
14844 };
14845 let handle = SupervisorHandle::new();
14846 let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
14847 let roster = ChildRoster::default();
14848 #[cfg(target_os = "macos")]
14849 {
14850 let path = super::test_privacy_trampoline();
14851 roster.set_privacy_trampoline(super::probe_privacy_trampoline(&path).map(|()| path));
14852 }
14853 let child = super::spawn_child_in_slot(
14854 &spec,
14855 None,
14856 Some(&handle),
14857 &ring,
14858 None,
14859 &roster,
14860 #[cfg(target_os = "linux")]
14861 None,
14862 role,
14863 matches!(role, super::SpawnRole::SwapCandidate),
14864 )
14865 .expect("spawn probe");
14866 let deadline = Instant::now() + Duration::from_secs(10);
14867 while !(fd_copy.exists() && env_copy.exists()) {
14868 assert!(Instant::now() < deadline, "probe never wrote its copies");
14869 tokio::time::sleep(Duration::from_millis(20)).await;
14870 }
14871 let nonce = std::fs::read_to_string(fd_copy).unwrap();
14872 assert!(!nonce.is_empty());
14873 let environment = std::fs::read_to_string(env_copy).unwrap();
14874 assert!(environment
14875 .lines()
14876 .any(|line| line.starts_with("SUBC_LAUNCH_NONCE_FD=3:")));
14877 let copy = environment
14878 .lines()
14879 .find_map(|line| line.strip_prefix("SUBC_LAUNCH_NONCE="));
14880 assert_eq!(
14881 copy, None,
14882 "Unix children must never receive the environment nonce"
14883 );
14884 if matches!(role, super::SpawnRole::Plain) {
14885 assert_eq!(
14886 handle.spawn_nonce(&spec.module_id).as_deref(),
14887 Some(nonce.as_str())
14888 );
14889 }
14890 drop(child);
14891 }
14892
14893 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
14894 async fn a_spawned_module_receives_its_nonce_only_on_descriptor_3() {
14895 probe(super::SpawnRole::Plain).await;
14896 }
14897
14898 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
14899 async fn swap_candidate_receives_its_nonce_only_on_descriptor_3() {
14900 probe(super::SpawnRole::SwapCandidate).await;
14901 }
14902}
14903
14904#[cfg(all(test, target_os = "linux"))]
14905mod cgroup_containment_tests {
14906 use super::*;
14907 use cortexkit_test_support::ScratchDir;
14908
14909 fn running(pid: u32) -> bool {
14910 std::fs::read_to_string(format!("/proc/{pid}/stat"))
14912 .ok()
14913 .and_then(|stat| {
14914 stat.rsplit_once(") ")
14915 .map(|(_, rest)| rest.starts_with('Z'))
14916 })
14917 .is_some_and(|zombie| !zombie)
14918 }
14919
14920 #[tokio::test]
14921 async fn linux_teardown_reaps_the_grandchild() {
14922 teardown_tree("linux_teardown_reaps_the_grandchild", false).await;
14923 }
14924
14925 #[tokio::test]
14926 async fn linux_shutdown_straggler_reaps_the_grandchild() {
14927 teardown_tree("linux_shutdown_straggler_reaps_the_grandchild", true).await;
14928 }
14929
14930 async fn teardown_tree(test_name: &str, shutdown: bool) {
14931 let dir = ScratchDir::new(test_name);
14932 let root = PathBuf::from(format!(
14933 "/sys/fs/cgroup/subc-tree-test-{test_name}-{}-{}",
14934 std::process::id(),
14935 unix_ms_now()
14936 ));
14937 if let Err(error) = std::fs::create_dir(&root) {
14938 assert!(
14939 std::env::var_os("SUBC_REQUIRE_CGROUP_TEST").is_none(),
14940 "required cgroup test cannot execute: {error}"
14941 );
14942 eprintln!(
14943 "SKIP {test_name}: no writable delegated cgroup at {}: {error}",
14944 root.display()
14945 );
14946 return;
14947 }
14948 let placement = subc_cgroup::prepare_at(&root)
14949 .expect("prepare isolated kernel cgroup")
14950 .expect("isolated cgroup is delegated");
14951 let module_id = "tree-teardown";
14952 let module = placement
14953 .module_path(module_id)
14954 .expect("create isolated module cgroup");
14955 if !module.join("cgroup.kill").exists() {
14956 std::fs::remove_dir(&module).unwrap();
14957 std::fs::remove_dir(root.join("subc-modules")).unwrap();
14958 std::fs::remove_dir(&root).unwrap();
14959 assert!(
14960 std::env::var_os("SUBC_REQUIRE_CGROUP_TEST").is_none(),
14961 "required cgroup.kill interface unavailable"
14962 );
14963 eprintln!("SKIP {test_name}: cgroup.kill unavailable (kernel < 5.14)");
14964 return;
14965 }
14966 let supervisor = Supervisor::new_for_test(
14967 Arc::new(Registry::default()),
14968 RestartPolicy::new(3, Duration::ZERO),
14969 )
14970 .with_cgroup_placement(Some(placement));
14971 let mut runtime = supervisor.runtime_config();
14972 runtime.child_roster = runtime
14973 .child_roster
14974 .for_module(Arc::new(Mutex::new(Duration::from_millis(100))));
14975 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
14976 let pid_file = dir.join("grandchild.pid");
14977 let spec = ModuleSpec {
14978 module_id: module_id.to_string(),
14979 program: PathBuf::from("/bin/sh"),
14980 args: vec![
14981 "-c".into(),
14982 "trap '' TERM; sleep 600 & echo $! > \"$1\"; wait".into(),
14983 "fixture".into(),
14984 pid_file.display().to_string(),
14985 ],
14986 env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
14987 .into_iter()
14988 .map(|key| (key.to_string(), dir.display().to_string()))
14989 .collect(),
14990 reserved: false,
14991 reserved_prefixes: Vec::new(),
14992 protocol: ModuleProtocol::None,
14993 overlap: Default::default(),
14994 };
14995 let child =
14996 spawn_and_mark_running(&spec, &runtime, &snapshot).expect("spawn supervised tree");
14997 let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
14998 let grandchild: u32 = loop {
14999 if let Ok(contents) = std::fs::read_to_string(&pid_file) {
15000 if let Ok(pid) = contents.trim().parse() {
15001 break pid;
15002 }
15003 }
15004 assert!(
15005 tokio::time::Instant::now() < deadline,
15006 "grandchild pid was not recorded"
15007 );
15008 tokio::time::sleep(Duration::from_millis(10)).await;
15009 };
15010 assert!(
15011 running(grandchild),
15012 "grandchild must be alive before teardown"
15013 );
15014 if shutdown {
15015 let mut child = child;
15016 crate::child_roster::end_children_for_daemon_shutdown(
15017 &runtime.child_roster,
15018 false,
15019 std::future::pending(),
15020 )
15021 .await;
15022 child.wait().await.expect("reap shutdown straggler");
15023 } else {
15024 drain_child_to_state(
15025 module_id,
15026 ModuleProtocol::None,
15027 StopNotice::NotSent,
15028 &Registry::default(),
15029 None,
15030 &snapshot,
15031 &runtime.terminal_ring,
15032 &SpawnEventFeed::default(),
15033 child,
15034 Duration::from_millis(100),
15035 ModuleState::Stopped,
15036 Some(false),
15037 )
15038 .await
15039 .expect("real supervisor teardown");
15040 }
15041 let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
15042 while running(grandchild) && tokio::time::Instant::now() < deadline {
15043 tokio::time::sleep(Duration::from_millis(10)).await;
15044 }
15045 let survived = running(grandchild);
15046 if survived {
15048 let pid = rustix::process::Pid::from_raw(grandchild as i32).unwrap();
15049 let _ = rustix::process::kill_process(pid, rustix::process::Signal::KILL);
15050 tokio::time::sleep(Duration::from_millis(100)).await;
15051 }
15052 if module.exists() {
15053 std::fs::remove_dir(&module).expect("remove empty module cgroup");
15054 }
15055 std::fs::remove_dir(root.join("subc-modules")).unwrap();
15056 std::fs::remove_dir(&root).unwrap();
15057 assert!(
15058 !survived,
15059 "grandchild {grandchild} outlived module teardown"
15060 );
15061 eprintln!("EXECUTED {test_name}: grandchild {grandchild} killed in isolated cgroup");
15062 }
15063}