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 crash_restarts_in_window(&mut self, window: Duration, now: Instant) -> u32 {
1182 while let Some(oldest) = self.crash_restarts.front() {
1183 if now.duration_since(*oldest) > window {
1184 self.crash_restarts.pop_front();
1185 } else {
1186 break;
1187 }
1188 }
1189 u32::try_from(self.crash_restarts.len()).unwrap_or(u32::MAX)
1190 }
1191
1192 fn record_crash_restart(&mut self, policy: &RestartPolicy, now: Instant) {
1198 self.crash_restarts.push_back(now);
1199 while self.crash_restarts.len() > policy.max_restarts as usize {
1200 self.crash_restarts.pop_front();
1201 }
1202 self.lifetime_restarts += 1;
1203 }
1204
1205 fn next_crash_restart(
1209 &mut self,
1210 policy: &RestartPolicy,
1211 now: Instant,
1212 ) -> Option<CrashRestartSchedule> {
1213 let restart_in_window = self.crash_restarts_in_window(policy.window, now);
1214 if restart_in_window >= policy.max_restarts {
1215 return None;
1216 }
1217 self.record_crash_restart(policy, now);
1218 Some(CrashRestartSchedule {
1219 restart_in_window,
1220 delay: policy.delay_for_restart(restart_in_window),
1221 })
1222 }
1223
1224 fn clear_crash_restarts(&mut self) {
1229 self.crash_restarts.clear();
1230 }
1231
1232 fn new(state: ModuleState, enabled: bool) -> Self {
1233 Self {
1234 #[cfg(test)]
1235 actor_turns: 0,
1236 #[cfg(test)]
1237 actor_select: None,
1238 state,
1239 enabled,
1240 process_alive: false,
1241 #[cfg(windows)]
1242 pipe_consumed: None,
1243 spawned_protocol: None,
1244 spawn_failure: None,
1245 waiting_for_previous_pid: None,
1246 crash_restarts: VecDeque::new(),
1247 lifetime_restarts: 0,
1248 spawn_generation: 0,
1249 pid: None,
1250 #[cfg(target_os = "macos")]
1251 report_ready: None,
1252 reaped_pid: None,
1253 respawn_pending: false,
1254 coalesced_restart_pending: false,
1255 spawned_at_ms: None,
1256 spawned_from: None,
1257 spawned_file_identity: None,
1258 process_start_time: None,
1259 deliberate_severance: None,
1260 last_exit: None,
1261 drain_disposition_detail: None,
1262 health: ModuleHealthStatus::default(),
1263 in_alternate_slot: false,
1264 draining_to_replace: false,
1265 configuration_updated_since_spawn: false,
1266 }
1267 }
1268}
1269
1270type SharedSnapshot = Arc<Mutex<SupervisorSnapshot>>;
1271
1272type SpawnSubscriberKey = (ConnectionId, u64);
1273
1274#[derive(Debug)]
1275struct SpawnSubscriber {
1276 version: u8,
1277 frames: mpsc::Sender<Frame>,
1278 lagged: Option<oneshot::Sender<SpawnCursor>>,
1282}
1283
1284#[derive(Debug)]
1285struct SpawnEventState {
1286 daemon_incarnation: String,
1287 seq: u64,
1288 capacity: usize,
1289 live: HashMap<String, LiveSpawn>,
1290 generations: HashMap<String, u64>,
1291 events: VecDeque<SpawnEvent>,
1292 subscribers: HashMap<SpawnSubscriberKey, SpawnSubscriber>,
1293}
1294
1295impl Default for SpawnEventState {
1296 fn default() -> Self {
1297 Self {
1298 daemon_incarnation: "unconfigured".to_string(),
1299 seq: 0,
1300 capacity: SPAWN_EVENT_RING_CAPACITY,
1301 live: HashMap::new(),
1302 generations: HashMap::new(),
1303 events: VecDeque::new(),
1304 subscribers: HashMap::new(),
1305 }
1306 }
1307}
1308
1309#[derive(Debug, Clone, Default)]
1310struct SpawnEventFeed(
1311 Arc<Mutex<SpawnEventState>>,
1312 #[cfg(test)] Arc<Mutex<Option<std::net::TcpStream>>>,
1313);
1314
1315#[derive(Debug, Clone, PartialEq, Eq)]
1316pub(crate) enum SpawnSubscribeRefusal {
1317 ForeignIncarnation { current: String },
1318 TooOld { oldest: SpawnCursor },
1319 Frame(String),
1320}
1321
1322impl SpawnEventFeed {
1323 fn configure_incarnation(&self, daemon_incarnation: String) {
1324 let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1325 state.daemon_incarnation = daemon_incarnation;
1326 state.seq = 0;
1327 state.live.clear();
1328 state.generations.clear();
1329 state.events.clear();
1330 state.subscribers.clear();
1331 }
1332
1333 fn cursor(state: &SpawnEventState) -> SpawnCursor {
1334 SpawnCursor {
1335 daemon_incarnation: state.daemon_incarnation.clone(),
1336 seq: state.seq,
1337 }
1338 }
1339
1340 fn snapshot(&self) -> SpawnSnapshot {
1341 let state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1342 let mut live = state.live.values().cloned().collect::<Vec<_>>();
1343 live.sort_by(|left, right| left.module_id.cmp(&right.module_id));
1344 SpawnSnapshot {
1345 cursor: Self::cursor(&state),
1346 ring_bound: state.capacity as u64,
1347 live,
1348 }
1349 }
1350
1351 fn emit_spawned(&self, module_id: &str, pid: u32, spawned_at_ms: u64) -> u64 {
1352 let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1353 let generation = state
1354 .generations
1355 .get(module_id)
1356 .copied()
1357 .unwrap_or(0)
1358 .checked_add(1)
1359 .expect("spawn generation exhausted");
1360 state.generations.insert(module_id.to_string(), generation);
1361 let live = LiveSpawn {
1362 module_id: module_id.to_string(),
1363 spawn_generation: generation,
1364 pid,
1365 spawned_at_ms,
1366 };
1367 state.live.insert(module_id.to_string(), live);
1368 Self::emit_locked(
1369 &mut state,
1370 SpawnEventKind::Spawned,
1371 module_id.to_string(),
1372 generation,
1373 pid,
1374 None,
1375 None,
1376 );
1377 generation
1378 }
1379
1380 fn emit_exited(&self, module_id: &str, exit_code: Option<i32>, exit_signal: Option<i32>) {
1381 let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1382 let Some(live) = state.live.remove(module_id) else {
1383 warn!(
1384 module_id,
1385 "terminal record had no live spawn event identity"
1386 );
1387 return;
1388 };
1389 Self::emit_locked(
1390 &mut state,
1391 SpawnEventKind::Exited,
1392 module_id.to_string(),
1393 live.spawn_generation,
1394 live.pid,
1395 exit_code,
1396 exit_signal,
1397 );
1398 }
1399
1400 fn emit_superseded_exited(
1407 &self,
1408 module_id: &str,
1409 spawn_generation: u64,
1410 pid: u32,
1411 exit_code: Option<i32>,
1412 exit_signal: Option<i32>,
1413 ) {
1414 let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1415 if state
1416 .live
1417 .get(module_id)
1418 .is_some_and(|live| live.spawn_generation == spawn_generation)
1419 {
1420 state.live.remove(module_id);
1421 }
1422 Self::emit_locked(
1423 &mut state,
1424 SpawnEventKind::Exited,
1425 module_id.to_string(),
1426 spawn_generation,
1427 pid,
1428 exit_code,
1429 exit_signal,
1430 );
1431 }
1432
1433 #[allow(clippy::too_many_arguments)]
1434 fn emit_locked(
1435 state: &mut SpawnEventState,
1436 kind: SpawnEventKind,
1437 module_id: String,
1438 spawn_generation: u64,
1439 pid: u32,
1440 exit_code: Option<i32>,
1441 exit_signal: Option<i32>,
1442 ) {
1443 state.seq = state
1444 .seq
1445 .checked_add(1)
1446 .expect("spawn event sequence exhausted");
1447 let event = SpawnEvent {
1448 cursor: Self::cursor(state),
1449 kind,
1450 module_id,
1451 spawn_generation,
1452 pid,
1453 exit_code,
1454 exit_signal,
1455 };
1456 state.events.push_back(event.clone());
1457 while state.events.len() > state.capacity {
1458 state.events.pop_front();
1459 }
1460 let body = match serde_json::to_vec(&event) {
1461 Ok(body) => body,
1462 Err(error) => {
1463 error!(%error, "failed to serialize supervisor spawn event");
1464 return;
1465 }
1466 };
1467 state.subscribers.retain(|(connection_id, corr), subscriber| {
1468 let frame = Frame::build_with_version(
1469 subscriber.version,
1470 FrameType::StreamData,
1471 control_flags(),
1472 0,
1473 0,
1474 *corr,
1475 body.clone(),
1476 );
1477 match frame {
1478 Ok(frame) => {
1479 if subscriber.frames.try_send(frame).is_ok() {
1480 true
1481 } else {
1482 warn!(connection_id = connection_id.get(), corr, "dropping lagged supervisor spawn subscriber");
1483 if let Some(lagged) = subscriber.lagged.take() {
1484 let _ = lagged.send(event.cursor.clone());
1485 }
1486 false
1487 }
1488 }
1489 Err(error) => {
1490 warn!(connection_id = connection_id.get(), corr, %error, "dropping supervisor spawn subscriber after frame build failure");
1491 false
1492 }
1493 }
1494 });
1495 }
1496
1497 fn subscribe(
1498 &self,
1499 connection_id: ConnectionId,
1500 corr: u64,
1501 version: u8,
1502 since: Option<SpawnCursor>,
1503 sink: FrameSink,
1504 ) -> Result<(), SpawnSubscribeRefusal> {
1505 let (frames, mut receiver) = mpsc::channel(SPAWN_SUBSCRIBER_BUFFER);
1506 let (lagged, mut lagged_rx) = oneshot::channel::<SpawnCursor>();
1507 {
1508 let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1509 let replay = if let Some(since) = since {
1510 if since.daemon_incarnation != state.daemon_incarnation {
1511 return Err(SpawnSubscribeRefusal::ForeignIncarnation {
1512 current: state.daemon_incarnation.clone(),
1513 });
1514 }
1515 if let Some(oldest) = state.events.front().map(|event| event.cursor.clone()) {
1516 if since.seq < oldest.seq.saturating_sub(1) {
1517 return Err(SpawnSubscribeRefusal::TooOld { oldest });
1518 }
1519 }
1520 state
1521 .events
1522 .iter()
1523 .filter(|event| event.cursor.seq > since.seq)
1524 .cloned()
1525 .collect::<Vec<_>>()
1526 } else {
1527 Vec::new()
1528 };
1529 for event in replay {
1530 let body = serde_json::to_vec(&event)
1531 .map_err(|error| SpawnSubscribeRefusal::Frame(error.to_string()))?;
1532 let frame = Frame::build_with_version(
1533 version,
1534 FrameType::StreamData,
1535 control_flags(),
1536 0,
1537 0,
1538 corr,
1539 body,
1540 )
1541 .map_err(|error| SpawnSubscribeRefusal::Frame(error.to_string()))?;
1542 frames
1543 .try_send(frame)
1544 .map_err(|error| SpawnSubscribeRefusal::Frame(error.to_string()))?;
1545 }
1546 state.subscribers.insert(
1547 (connection_id, corr),
1548 SpawnSubscriber {
1549 version,
1550 frames,
1551 lagged: Some(lagged),
1552 },
1553 );
1554 }
1555 tokio::spawn(async move {
1566 while let Some(frame) = receiver.recv().await {
1567 if sink.send(frame).await.is_err() {
1568 return;
1569 }
1570 }
1571 let Ok(first_undelivered) = lagged_rx.try_recv() else {
1572 return;
1573 };
1574 match spawn_subscriber_lagged_frame(version, corr, first_undelivered) {
1575 Ok(frame) => {
1576 let _ = sink.send(frame).await;
1577 }
1578 Err(error) => {
1579 error!(%error, corr, "failed to build lagged spawn subscriber terminal frame");
1580 }
1581 }
1582 });
1583 Ok(())
1584 }
1585
1586 fn cancel(&self, connection_id: ConnectionId, corr: u64) -> bool {
1587 let Some(subscriber) = self
1588 .0
1589 .lock()
1590 .unwrap_or_else(|p| p.into_inner())
1591 .subscribers
1592 .remove(&(connection_id, corr))
1593 else {
1594 return false;
1595 };
1596 if let Ok(frame) = Frame::build_with_version(
1597 subscriber.version,
1598 FrameType::StreamEnd,
1599 control_flags(),
1600 0,
1601 0,
1602 corr,
1603 Vec::new(),
1604 ) {
1605 tokio::spawn(async move {
1606 let _ = subscriber.frames.send(frame).await;
1607 });
1608 }
1609 true
1610 }
1611
1612 fn remove_connection(&self, connection_id: ConnectionId) {
1613 self.0
1614 .lock()
1615 .unwrap_or_else(|p| p.into_inner())
1616 .subscribers
1617 .retain(|(subscriber_connection, _), _| *subscriber_connection != connection_id);
1618 }
1619
1620 #[cfg(any(test, feature = "test-support"))]
1621 fn set_capacity(&self, capacity: usize) {
1622 self.0.lock().unwrap_or_else(|p| p.into_inner()).capacity = capacity;
1623 }
1624
1625 #[cfg(any(test, feature = "test-support"))]
1626 fn subscriber_count(&self) -> usize {
1627 self.0
1628 .lock()
1629 .unwrap_or_else(|p| p.into_inner())
1630 .subscribers
1631 .len()
1632 }
1633}
1634
1635fn spawn_subscriber_lagged_frame(
1638 version: u8,
1639 corr: u64,
1640 first_undelivered: SpawnCursor,
1641) -> Result<Frame, String> {
1642 let body = serde_json::to_vec(&subc_protocol::ErrorBody {
1643 code: SPAWN_SUBSCRIBER_LAGGED_CODE.to_string(),
1644 message: "spawn subscriber fell behind and was dropped; resubscribe from the last cursor received"
1645 .to_string(),
1646 detail: Some(serde_json::json!({
1647 "first_undelivered_cursor": first_undelivered
1648 })),
1649 })
1650 .map_err(|error| error.to_string())?;
1651 Frame::build_with_version(version, FrameType::Error, control_flags(), 0, 0, corr, body)
1652 .map_err(|error| error.to_string())
1653}
1654
1655pub trait ModuleProcessLiveness: Send + Sync {
1656 fn process_live(&self, module_id: &str) -> Option<bool>;
1657
1658 fn process_replacing(&self, _module_id: &str) -> bool {
1664 false
1665 }
1666}
1667
1668#[derive(Debug, Clone, Default)]
1670pub struct SupervisorProcessLiveness {
1671 snapshots: Arc<Mutex<HashMap<String, SharedSnapshot>>>,
1672}
1673
1674impl SupervisorProcessLiveness {
1675 pub fn new() -> Self {
1676 Self::default()
1677 }
1678
1679 fn track(&self, module_id: String, snapshot: SharedSnapshot) {
1680 let mut snapshots = self
1681 .snapshots
1682 .lock()
1683 .unwrap_or_else(|poisoned| poisoned.into_inner());
1684 snapshots.insert(module_id, snapshot);
1685 }
1686
1687 fn untrack_if_current(&self, module_id: &str, snapshot: &SharedSnapshot) {
1688 let mut snapshots = self
1689 .snapshots
1690 .lock()
1691 .unwrap_or_else(|poisoned| poisoned.into_inner());
1692 let is_current = snapshots
1693 .get(module_id)
1694 .map(|tracked| Arc::ptr_eq(tracked, snapshot))
1695 .unwrap_or(false);
1696 if is_current {
1697 snapshots.remove(module_id);
1698 }
1699 }
1700}
1701
1702impl ModuleProcessLiveness for SupervisorProcessLiveness {
1703 fn process_live(&self, module_id: &str) -> Option<bool> {
1704 let snapshot = {
1705 let snapshots = self
1706 .snapshots
1707 .lock()
1708 .unwrap_or_else(|poisoned| poisoned.into_inner());
1709 snapshots.get(module_id).cloned()
1710 }?;
1711 let snapshot = snapshot
1712 .lock()
1713 .unwrap_or_else(|poisoned| poisoned.into_inner());
1714 Some(snapshot.state == ModuleState::Running && snapshot.process_alive)
1715 }
1716
1717 fn process_replacing(&self, module_id: &str) -> bool {
1718 let Some(snapshot) = self
1719 .snapshots
1720 .lock()
1721 .unwrap_or_else(|poisoned| poisoned.into_inner())
1722 .get(module_id)
1723 .cloned()
1724 else {
1725 return false;
1726 };
1727 let snapshot = snapshot
1728 .lock()
1729 .unwrap_or_else(|poisoned| poisoned.into_inner());
1730 snapshot.enabled
1731 && match snapshot.state {
1732 ModuleState::Restarting => true,
1733 ModuleState::Draining => snapshot.draining_to_replace,
1734 ModuleState::Starting
1735 | ModuleState::Running
1736 | ModuleState::Unresponsive
1737 | ModuleState::Stopped
1738 | ModuleState::Failed
1739 | ModuleState::Disabled => false,
1740 }
1741 }
1742}
1743
1744#[cfg(test)]
1745#[derive(Debug, Default)]
1746struct ReloadExitRecordGate {
1747 reached: tokio::sync::Notify,
1748 resume: tokio::sync::Notify,
1749}
1750
1751#[derive(Debug, Clone, Copy)]
1752enum RespawnKind {
1753 Spawn,
1754 Reload,
1755}
1756
1757#[derive(Debug, Clone, Copy)]
1758struct PendingRespawn {
1759 deadline: Instant,
1760 kind: RespawnKind,
1761}
1762
1763type ReloadReply = oneshot::Sender<Result<(), SuperviseError>>;
1764
1765#[derive(Debug, Clone)]
1766struct SupervisorRuntimeConfig {
1767 start_requester: Option<Principal>,
1768 pending_activity_restart: bool,
1769 provenance_probe: ExecutableIdentityProbe,
1770 scheduled_respawn: Arc<Mutex<Option<PendingRespawn>>>,
1772 deferred_reload_reply: Arc<Mutex<Option<ReloadReply>>>,
1774 restart_policy: RestartPolicy,
1775 drain_timeout: Duration,
1778 effective_drain_timeout: Arc<Mutex<Duration>>,
1781 default_drain_timeout: Duration,
1784 health: HealthConfig,
1785 connection_file_path: Option<PathBuf>,
1786 capture_logs_dir: Option<PathBuf>,
1787 forwarding: Option<Arc<ForwardingTable>>,
1788 supervisor_handle: Option<SupervisorHandle>,
1791 stderr_ring: Arc<Mutex<StderrRing>>,
1798 terminal_ring: Arc<Mutex<TerminalRing>>,
1799 spawn_events: SpawnEventFeed,
1800 child_roster: ChildRoster,
1801 #[cfg(target_os = "linux")]
1802 cgroup_placement: Option<subc_cgroup::Placement>,
1803 #[cfg(test)]
1804 test_seed_stale_facts_before_enable_spawn: bool,
1805 #[cfg(test)]
1806 test_reload_exit_record_gate: Option<Arc<ReloadExitRecordGate>>,
1807}
1808
1809#[derive(Debug, Clone, PartialEq, Eq)]
1810struct SupervisedConfiguration {
1811 spec: ModuleSpec,
1812 health: HealthConfig,
1813}
1814
1815#[derive(Debug, Clone, Default)]
1821pub struct SupervisorHandle {
1822 activity: Arc<Mutex<Option<crate::activity::ActivityLedger>>>,
1823 activity_images: Arc<Mutex<HashMap<String, activity::ActivityImage>>>,
1824 modules: Arc<Mutex<HashMap<String, SupervisedModule>>>,
1825 configured_ids: Arc<Mutex<HashSet<String>>>,
1837 spawn_events: SpawnEventFeed,
1838 reserved_nonces: Arc<Mutex<HashMap<String, Option<String>>>>,
1849 removal_tombstones: Arc<Mutex<HashMap<String, u64>>>,
1855 spawn_nonces: Arc<Mutex<HashMap<String, String>>>,
1859 reserved_prefix_owners: Arc<Mutex<HashMap<String, String>>>,
1867 swaps: Arc<Mutex<HashMap<String, OpenSwap>>>,
1873 promotion_observer: PromotionObserverSlot,
1875 operation_lock: Arc<AsyncMutex<()>>,
1879}
1880
1881pub(crate) trait SwapPromotionObserver: Send + Sync {
1890 fn swap_promoted(&self, registration: &crate::registry::ModuleRegistration);
1891}
1892
1893#[derive(Clone, Default)]
1897struct PromotionObserverSlot(Arc<Mutex<Option<std::sync::Weak<dyn SwapPromotionObserver>>>>);
1898
1899impl fmt::Debug for PromotionObserverSlot {
1900 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1901 f.write_str("PromotionObserverSlot")
1902 }
1903}
1904
1905#[derive(Debug, Clone)]
1907struct OpenSwap {
1908 candidate_nonce: String,
1911 incumbent_nonce: Option<String>,
1916 candidate_admitted: bool,
1920}
1921
1922#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1925pub(crate) enum SwapHelloAdmission {
1926 NotSwapping,
1929 Candidate,
1931 Refused,
1934}
1935
1936#[derive(Debug, Clone, PartialEq, Eq)]
1937pub(crate) enum ReservedHelloRejection {
1938 Exact {
1939 module_id: String,
1940 },
1941 Prefix {
1942 prefix: String,
1943 owner_module_id: String,
1944 },
1945}
1946
1947impl SupervisorHandle {
1948 pub fn new() -> Self {
1949 Self::default()
1950 }
1951
1952 pub(crate) fn spawn_snapshot(&self) -> SpawnSnapshot {
1953 self.spawn_events.snapshot()
1954 }
1955
1956 pub(crate) fn subscribe_spawns(
1957 &self,
1958 connection_id: ConnectionId,
1959 corr: u64,
1960 version: u8,
1961 since: Option<SpawnCursor>,
1962 sink: FrameSink,
1963 ) -> Result<(), SpawnSubscribeRefusal> {
1964 self.spawn_events
1965 .subscribe(connection_id, corr, version, since, sink)
1966 }
1967
1968 pub(crate) fn cancel_spawn_subscription(&self, connection_id: ConnectionId, corr: u64) -> bool {
1969 self.spawn_events.cancel(connection_id, corr)
1970 }
1971
1972 pub(crate) fn remove_spawn_subscribers(&self, connection_id: ConnectionId) {
1973 self.spawn_events.remove_connection(connection_id);
1974 }
1975
1976 #[cfg(any(test, feature = "test-support"))]
1977 pub fn set_spawn_event_capacity_for_test(&self, capacity: usize) {
1978 assert!(capacity > 0, "spawn event capacity must be non-zero");
1979 self.spawn_events.set_capacity(capacity);
1980 }
1981
1982 #[cfg(any(test, feature = "test-support"))]
1983 pub fn spawn_subscriber_count_for_test(&self) -> usize {
1984 self.spawn_events.subscriber_count()
1985 }
1986
1987 pub fn set_spawn_nonce(&self, module_id: &str, nonce: String) {
1990 self.spawn_nonces
1991 .lock()
1992 .unwrap_or_else(|poisoned| poisoned.into_inner())
1993 .insert(module_id.to_string(), nonce);
1994 }
1995
1996 pub fn set_reserved_nonce(&self, module_id: &str, nonce: String) {
1999 self.reserved_nonces
2000 .lock()
2001 .unwrap_or_else(|poisoned| poisoned.into_inner())
2002 .insert(module_id.to_string(), Some(nonce));
2003 }
2004
2005 pub fn set_reserved_prefixes(&self, owner_module_id: &str, prefixes: &[String]) {
2007 let mut owners = self
2008 .reserved_prefix_owners
2009 .lock()
2010 .unwrap_or_else(|poisoned| poisoned.into_inner());
2011 owners.retain(|_, owner| owner != owner_module_id);
2012 for prefix in prefixes {
2013 owners.insert(prefix.clone(), owner_module_id.to_string());
2014 }
2015 }
2016
2017 #[cfg(test)]
2019 pub(crate) fn spawn_nonce(&self, module_id: &str) -> Option<String> {
2020 self.spawn_nonces
2021 .lock()
2022 .unwrap_or_else(|poisoned| poisoned.into_inner())
2023 .get(module_id)
2024 .cloned()
2025 }
2026
2027 fn apply_identity_configuration(&self, spec: &ModuleSpec) {
2028 self.set_reserved_prefixes(&spec.module_id, &spec.reserved_prefixes);
2029 let spawn_nonce = self
2030 .spawn_nonces
2031 .lock()
2032 .unwrap_or_else(|poisoned| poisoned.into_inner())
2033 .get(&spec.module_id)
2034 .cloned();
2035 let mut reserved_nonces = self
2036 .reserved_nonces
2037 .lock()
2038 .unwrap_or_else(|poisoned| poisoned.into_inner());
2039 if spec.reserved {
2040 reserved_nonces.insert(spec.module_id.clone(), spawn_nonce);
2045 }
2046 drop(reserved_nonces);
2047 self.removal_tombstones
2051 .lock()
2052 .unwrap_or_else(|poisoned| poisoned.into_inner())
2053 .remove(&spec.module_id);
2054 }
2055
2056 pub fn reserved_hello_authorized(&self, module_id: &str, presented: Option<&str>) -> bool {
2061 self.reserved_hello_rejection(module_id, presented)
2062 .is_none()
2063 }
2064
2065 pub(crate) fn reserved_hello_rejection(
2066 &self,
2067 module_id: &str,
2068 presented: Option<&str>,
2069 ) -> Option<ReservedHelloRejection> {
2070 let nonces = self
2071 .reserved_nonces
2072 .lock()
2073 .unwrap_or_else(|poisoned| poisoned.into_inner());
2074 if let Some(expected) = nonces.get(module_id) {
2075 let authorized = match expected {
2079 Some(expected) => {
2080 presented.is_some_and(|p| constant_time_eq(expected.as_bytes(), p.as_bytes()))
2081 }
2082 None => false,
2083 };
2084 if authorized {
2085 return None;
2086 }
2087 return Some(ReservedHelloRejection::Exact {
2088 module_id: module_id.to_string(),
2089 });
2090 }
2091 drop(nonces);
2092
2093 let matched_prefix = self
2094 .reserved_prefix_owners
2095 .lock()
2096 .unwrap_or_else(|poisoned| poisoned.into_inner())
2097 .iter()
2098 .filter(|(prefix, _)| module_id.starts_with(prefix.as_str()))
2099 .max_by_key(|(prefix, _)| prefix.len())
2100 .map(|(prefix, owner)| (prefix.clone(), owner.clone()));
2101 let (prefix, owner_module_id) = matched_prefix?;
2102
2103 let authorized = presented.is_some_and(|presented| {
2104 self.spawn_nonces
2105 .lock()
2106 .unwrap_or_else(|poisoned| poisoned.into_inner())
2107 .get(&owner_module_id)
2108 .is_some_and(|expected| constant_time_eq(expected.as_bytes(), presented.as_bytes()))
2109 || self.swap_nonce_matches(&owner_module_id, presented)
2112 });
2113 if authorized {
2114 None
2115 } else {
2116 Some(ReservedHelloRejection::Prefix {
2117 prefix,
2118 owner_module_id,
2119 })
2120 }
2121 }
2122
2123 pub fn spawned_consumer_authorized(&self, module_id: &str, presented: &str) -> bool {
2128 if presented.is_empty() {
2129 return false;
2130 }
2131 let nonces = self
2132 .spawn_nonces
2133 .lock()
2134 .unwrap_or_else(|poisoned| poisoned.into_inner());
2135 let current = nonces
2136 .get(module_id)
2137 .is_some_and(|expected| constant_time_eq(expected.as_bytes(), presented.as_bytes()));
2138 drop(nonces);
2139 current || self.swap_nonce_matches(module_id, presented)
2144 }
2145
2146 fn swap_nonce_matches(&self, module_id: &str, presented: &str) -> bool {
2148 let swaps = self
2149 .swaps
2150 .lock()
2151 .unwrap_or_else(|poisoned| poisoned.into_inner());
2152 swaps.get(module_id).is_some_and(|swap| {
2153 constant_time_eq(swap.candidate_nonce.as_bytes(), presented.as_bytes())
2154 || swap.incumbent_nonce.as_deref().is_some_and(|incumbent| {
2155 constant_time_eq(incumbent.as_bytes(), presented.as_bytes())
2156 })
2157 })
2158 }
2159
2160 pub(crate) fn open_swap(&self, module_id: &str, candidate_nonce: String) {
2163 let incumbent_nonce = self
2164 .spawn_nonces
2165 .lock()
2166 .unwrap_or_else(|poisoned| poisoned.into_inner())
2167 .get(module_id)
2168 .cloned();
2169 self.swaps
2170 .lock()
2171 .unwrap_or_else(|poisoned| poisoned.into_inner())
2172 .insert(
2173 module_id.to_string(),
2174 OpenSwap {
2175 candidate_nonce,
2176 incumbent_nonce,
2177 candidate_admitted: false,
2178 },
2179 );
2180 }
2181
2182 pub(crate) fn close_swap(&self, module_id: &str) {
2185 self.swaps
2186 .lock()
2187 .unwrap_or_else(|poisoned| poisoned.into_inner())
2188 .remove(module_id);
2189 }
2190
2191 pub(crate) fn set_swap_promotion_observer(
2194 &self,
2195 observer: std::sync::Weak<dyn SwapPromotionObserver>,
2196 ) {
2197 *self
2198 .promotion_observer
2199 .0
2200 .lock()
2201 .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(observer);
2202 }
2203
2204 fn notify_swap_promoted(&self, registration: &crate::registry::ModuleRegistration) {
2207 let observer = self
2208 .promotion_observer
2209 .0
2210 .lock()
2211 .unwrap_or_else(|poisoned| poisoned.into_inner())
2212 .as_ref()
2213 .and_then(std::sync::Weak::upgrade);
2214 if let Some(observer) = observer {
2215 observer.swap_promoted(registration);
2216 }
2217 }
2218
2219 pub(crate) fn swap_open(&self, module_id: &str) -> bool {
2221 self.swaps
2222 .lock()
2223 .unwrap_or_else(|poisoned| poisoned.into_inner())
2224 .contains_key(module_id)
2225 }
2226
2227 fn promote_swap_nonce(&self, module_id: &str, reserved: bool) {
2232 let candidate_nonce = self
2233 .swaps
2234 .lock()
2235 .unwrap_or_else(|poisoned| poisoned.into_inner())
2236 .get(module_id)
2237 .map(|swap| swap.candidate_nonce.clone());
2238 let Some(nonce) = candidate_nonce else {
2239 return;
2240 };
2241 self.set_spawn_nonce(module_id, nonce.clone());
2242 if reserved {
2243 self.set_reserved_nonce(module_id, nonce);
2244 }
2245 }
2246
2247 pub(crate) fn swap_hello_admission(
2262 &self,
2263 module_id: &str,
2264 presented: Option<&str>,
2265 ) -> SwapHelloAdmission {
2266 let swaps = self
2267 .swaps
2268 .lock()
2269 .unwrap_or_else(|poisoned| poisoned.into_inner());
2270 let Some(swap) = swaps.get(module_id) else {
2271 return SwapHelloAdmission::NotSwapping;
2272 };
2273 let Some(presented) = presented else {
2274 return SwapHelloAdmission::Refused;
2275 };
2276 if constant_time_eq(swap.candidate_nonce.as_bytes(), presented.as_bytes()) {
2277 return if swap.candidate_admitted {
2278 SwapHelloAdmission::Refused
2279 } else {
2280 SwapHelloAdmission::Candidate
2281 };
2282 }
2283 if swap
2284 .incumbent_nonce
2285 .as_deref()
2286 .is_some_and(|incumbent| constant_time_eq(incumbent.as_bytes(), presented.as_bytes()))
2287 {
2288 return SwapHelloAdmission::NotSwapping;
2289 }
2290 SwapHelloAdmission::Refused
2291 }
2292
2293 pub(crate) fn mark_swap_candidate_admitted(&self, module_id: &str) {
2296 if let Some(swap) = self
2297 .swaps
2298 .lock()
2299 .unwrap_or_else(|poisoned| poisoned.into_inner())
2300 .get_mut(module_id)
2301 {
2302 swap.candidate_admitted = true;
2303 }
2304 }
2305
2306 pub fn spawn_launch_nonce_for(&self, module_id: &str) -> Option<String> {
2308 self.spawn_nonces
2309 .lock()
2310 .unwrap_or_else(|poisoned| poisoned.into_inner())
2311 .get(module_id)
2312 .cloned()
2313 }
2314
2315 pub fn reserved_launch_nonce_for(&self, module_id: &str) -> Option<String> {
2317 self.reserved_nonces
2318 .lock()
2319 .unwrap_or_else(|poisoned| poisoned.into_inner())
2320 .get(module_id)
2321 .cloned()
2322 .flatten()
2323 }
2324
2325 pub fn insert(&self, module: SupervisedModule) -> Option<SupervisedModule> {
2326 self.mark_configured(module.module_id());
2330 let mut modules = self
2331 .modules
2332 .lock()
2333 .unwrap_or_else(|poisoned| poisoned.into_inner());
2334 modules.insert(module.module_id().to_string(), module)
2335 }
2336
2337 fn mark_configured(&self, module_id: &str) {
2341 self.configured_ids
2342 .lock()
2343 .unwrap_or_else(|poisoned| poisoned.into_inner())
2344 .insert(module_id.to_string());
2345 }
2346
2347 fn unmark_configured_unless_rostered(&self, module_id: &str) {
2351 let modules = self
2352 .modules
2353 .lock()
2354 .unwrap_or_else(|poisoned| poisoned.into_inner());
2355 if !modules.contains_key(module_id) {
2356 self.configured_ids
2357 .lock()
2358 .unwrap_or_else(|poisoned| poisoned.into_inner())
2359 .remove(module_id);
2360 }
2361 }
2362
2363 pub(crate) fn is_configured(&self, module_id: &str) -> bool {
2371 self.configured_ids
2372 .lock()
2373 .unwrap_or_else(|poisoned| poisoned.into_inner())
2374 .contains(module_id)
2375 }
2376
2377 pub fn get(&self, module_id: &str) -> Option<SupervisedModule> {
2378 let modules = self
2379 .modules
2380 .lock()
2381 .unwrap_or_else(|poisoned| poisoned.into_inner());
2382 modules.get(module_id).cloned()
2383 }
2384
2385 pub(crate) fn record_late_health_answer(
2386 &self,
2387 module_id: &str,
2388 latency_ms: u64,
2389 ) -> Result<bool, SuperviseError> {
2390 let Some(module) = self.get(module_id) else {
2391 return Ok(false);
2392 };
2393 update_snapshot(&module.inner.snapshot, Some(module_id), |state| {
2394 state.health.late_answer_count = state.health.late_answer_count.saturating_add(1);
2395 state.health.last_late_answer_latency_ms = Some(latency_ms);
2396 state.health.consecutive_failures = 0;
2404 })?;
2405 Ok(true)
2406 }
2407
2408 pub fn record_deliberate_severance(&self, module_id: &str) -> Result<bool, SuperviseError> {
2414 let Some(module) = self.get(module_id) else {
2415 return Ok(false);
2416 };
2417 let snapshot = lock_snapshot(&module.inner.snapshot)?;
2418 let Some((pid, start_time)) = snapshot.pid.zip(snapshot.process_start_time) else {
2419 return Ok(false);
2420 };
2421 drop(snapshot);
2422 module.record_deliberate_severance(ProcessIdentity { pid, start_time })
2423 }
2424
2425 pub fn list(&self) -> Vec<SupervisedModule> {
2426 let modules = self
2427 .modules
2428 .lock()
2429 .unwrap_or_else(|poisoned| poisoned.into_inner());
2430 let mut modules = modules.values().cloned().collect::<Vec<_>>();
2431 modules.sort_by(|left, right| left.module_id().cmp(right.module_id()));
2432 modules
2433 }
2434
2435 pub(crate) fn retire(&self, module_id: &str) -> Option<SupervisedModule> {
2436 self.activity_images
2437 .lock()
2438 .unwrap_or_else(|p| p.into_inner())
2439 .remove(module_id);
2440 self.spawn_nonces
2441 .lock()
2442 .unwrap_or_else(|poisoned| poisoned.into_inner())
2443 .remove(module_id);
2444 self.close_swap(module_id);
2445 let mut reserved_nonces = self
2446 .reserved_nonces
2447 .lock()
2448 .unwrap_or_else(|poisoned| poisoned.into_inner());
2449 if reserved_nonces.contains_key(module_id) {
2450 reserved_nonces.insert(module_id.to_string(), None);
2453 }
2454 drop(reserved_nonces);
2455 self.reserved_prefix_owners
2456 .lock()
2457 .unwrap_or_else(|poisoned| poisoned.into_inner())
2458 .retain(|_, owner| owner != module_id);
2459 let removed = self
2460 .modules
2461 .lock()
2462 .unwrap_or_else(|poisoned| poisoned.into_inner())
2463 .remove(module_id);
2464 self.configured_ids
2465 .lock()
2466 .unwrap_or_else(|poisoned| poisoned.into_inner())
2467 .remove(module_id);
2468 removed
2469 }
2470
2471 pub(crate) fn record_rescan_removal(&self, module_id: &str) {
2474 self.removal_tombstones
2475 .lock()
2476 .unwrap_or_else(|poisoned| poisoned.into_inner())
2477 .insert(module_id.to_string(), unix_ms_now());
2478 }
2479
2480 pub(crate) fn removal_tombstone_age_ms(&self, module_id: &str) -> Option<u64> {
2482 self.removal_tombstones
2483 .lock()
2484 .unwrap_or_else(|poisoned| poisoned.into_inner())
2485 .get(module_id)
2486 .copied()
2487 .map(|removed_at_ms| unix_ms_now().saturating_sub(removed_at_ms))
2488 }
2489
2490 pub(crate) fn release_retained_reserved_gate(&self, module_id: &str) -> bool {
2495 if self.get(module_id).is_some() {
2496 return false;
2497 }
2498 let mut reserved_nonces = self
2499 .reserved_nonces
2500 .lock()
2501 .unwrap_or_else(|poisoned| poisoned.into_inner());
2502 if !matches!(reserved_nonces.get(module_id), Some(None)) {
2503 return false;
2504 }
2505 reserved_nonces.remove(module_id);
2506 true
2507 }
2508
2509 pub(crate) fn operation_lock(&self) -> Arc<AsyncMutex<()>> {
2510 Arc::clone(&self.operation_lock)
2511 }
2512}
2513
2514#[derive(Debug, Clone)]
2516pub struct Supervisor {
2517 registry: Arc<Registry>,
2518 restart_policy: RestartPolicy,
2519 drain_timeout: Duration,
2520 connection_file_path: Option<PathBuf>,
2521 capture_logs_dir: Option<PathBuf>,
2522 forwarding: Option<Arc<ForwardingTable>>,
2523 process_liveness: Arc<SupervisorProcessLiveness>,
2524 supervisor_handle: Option<SupervisorHandle>,
2525 health: HealthConfig,
2526 daemon_start_clock: crate::clock::StartClock,
2527 terminal_journal: Option<Arc<crate::terminal_journal::TerminalJournal>>,
2528 spawn_events: SpawnEventFeed,
2529 provenance_probe: ExecutableIdentityProbe,
2530 child_roster: ChildRoster,
2533 #[cfg(target_os = "linux")]
2534 cgroup_placement: Option<subc_cgroup::Placement>,
2535 #[cfg(test)]
2536 test_after_first_spawn: AfterFirstSpawnHook,
2537}
2538
2539pub(crate) struct RegisteredConfiguredModule {
2543 spec: ModuleSpec,
2544 runtime: SupervisorRuntimeConfig,
2545 module: SupervisedModule,
2546 commands: mpsc::Receiver<SupervisorCommand>,
2547}
2548
2549#[cfg(test)]
2555#[derive(Clone, Default)]
2556struct AfterFirstSpawnHook(Option<AfterFirstSpawnFn>);
2557
2558#[cfg(test)]
2559type AfterFirstSpawnFn = Arc<dyn Fn(&str) + Send + Sync>;
2560
2561#[cfg(test)]
2562impl fmt::Debug for AfterFirstSpawnHook {
2563 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2564 f.write_str("AfterFirstSpawnHook")
2565 }
2566}
2567
2568#[cfg(test)]
2569impl AfterFirstSpawnHook {
2570 fn run(&self, module_id: &str) {
2571 if let Some(hook) = &self.0 {
2572 hook(module_id);
2573 }
2574 }
2575}
2576
2577impl Supervisor {
2578 #[cfg(test)]
2579 pub(crate) fn new_for_test(registry: Arc<Registry>, policy: RestartPolicy) -> Self {
2580 let supervisor = Self::new(registry, policy);
2581 #[cfg(target_os = "macos")]
2582 let supervisor = supervisor.with_privacy_trampoline(test_privacy_trampoline());
2583 supervisor
2584 }
2585 pub fn with_privacy_trampoline(self, path: impl Into<PathBuf>) -> Self {
2590 let path = path.into();
2591 #[cfg(target_os = "macos")]
2592 {
2593 let result = probe_privacy_trampoline(&path).map(|()| path);
2594 if let Err(cause) = &result {
2595 error!(%cause, "macOS privacy identity unavailable; supervised launches will refuse");
2596 }
2597 self.child_roster.set_privacy_trampoline(result);
2598 }
2599 #[cfg(not(target_os = "macos"))]
2600 let _ = path;
2601 self
2602 }
2603 #[cfg(unix)]
2614 pub(crate) fn begin_daemon_shutdown(&self) {
2615 self.child_roster.close();
2616 if let Some(journal) = &self.terminal_journal {
2617 journal.stamp_shutdown();
2618 }
2619 }
2620
2621 #[cfg(unix)]
2625 pub(crate) async fn drain_for_daemon_shutdown(&self) -> Result<(), SuperviseError> {
2626 const NOTICE_BUDGET: Duration = Duration::from_millis(500);
2627 const DRAIN_BUDGET: Duration = Duration::from_secs(2);
2628 let Some(forwarding) = &self.forwarding else {
2629 return Ok(());
2630 };
2631 let modules = forwarding
2632 .begin_daemon_drain()
2633 .map_err(SuperviseError::Forwarding)?;
2634 let deadline_ms =
2635 unix_ms_now().saturating_add((NOTICE_BUDGET + DRAIN_BUDGET).as_millis() as u64);
2636 let mut notices = tokio::task::JoinSet::new();
2637 let mut drains = Vec::new();
2638 for (module_id, endpoint) in modules {
2639 let Some(target) = forwarding
2640 .begin_endpoint_drain(endpoint, RouteCloseReason::Restart)
2641 .map_err(SuperviseError::Forwarding)?
2642 else {
2643 continue;
2644 };
2645 let routes = forwarding
2646 .endpoint_routes(target.endpoint)
2647 .map_err(SuperviseError::Forwarding)?;
2648 let command = serde_json::to_vec(&ModuleControlCommand::Draining {
2654 reason: RouteCloseReason::Restart,
2655 deadline_ms,
2656 })
2657 .expect("module draining serializes");
2658 let mut recipients = vec![(target.sink.clone(), target.negotiated_ver, command)];
2659 let mut clients: Vec<(crate::forwarding::GoodbyeTarget, Vec<u16>)> = Vec::new();
2660 for route in routes {
2661 let client = route.goodbye_target;
2662 if let Some((_, channels)) = clients
2663 .iter_mut()
2664 .find(|(existing, _)| existing.connection_id == client.connection_id)
2665 {
2666 channels.push(client.channel);
2667 } else {
2668 let channel = client.channel;
2669 clients.push((client, vec![channel]));
2670 }
2671 }
2672 for (client, mut channels) in clients {
2673 channels.sort_unstable();
2674 channels.dedup();
2675 let closing = serde_json::to_vec(&ClientControlPush::RouteClosing {
2676 module_id: module_id.clone(),
2677 channels,
2678 reason: RouteCloseReason::Restart,
2679 })
2680 .expect("route closing serializes");
2681 recipients.push((client.sink, client.negotiated_ver, closing));
2682 }
2683 for (sink, version, body) in recipients {
2684 notices.spawn(async move {
2685 let frame = Frame::build_with_version(
2686 version,
2687 FrameType::Push,
2688 control_flags(),
2689 0,
2690 0,
2691 0,
2692 body,
2693 )
2694 .expect("bounded lifecycle notice frame builds");
2695 sink.send_flushed(frame).await
2696 });
2697 }
2698 let gauges = declared_busy_gauges(&self.registry, &module_id)?;
2699 drains.push((module_id, target.endpoint, gauges));
2700 }
2701 let notice_deadline = Instant::now() + NOTICE_BUDGET;
2704 while let Ok(Some(result)) = timeout_at(notice_deadline, notices.join_next()).await {
2705 if !matches!(result, Ok(Ok(()))) {
2706 warn!(?result, "daemon shutdown notice delivery failed");
2707 }
2708 }
2709 notices.abort_all();
2710 let deadline = Instant::now() + DRAIN_BUDGET;
2711 let mut waits = tokio::task::JoinSet::new();
2712 for (module_id, endpoint, gauges) in drains {
2713 let forwarding = Arc::clone(forwarding);
2714 let mut runtime = self.runtime_config();
2715 runtime.health.cadence = Duration::from_millis(100);
2716 waits.spawn(async move {
2717 let wait_result = wait_for_forwarding_quiescence(
2718 &forwarding,
2719 &module_id,
2720 &runtime,
2721 endpoint,
2722 deadline,
2723 &gauges,
2724 DrainScope::Endpoint(endpoint),
2725 )
2726 .await;
2727 forwarding
2731 .record_daemon_drain_result(
2732 endpoint,
2733 drained_after_quiescence_wait(&wait_result),
2734 )
2735 .map_err(SuperviseError::Forwarding)?;
2736 wait_result
2737 });
2738 }
2739 while let Ok(Some(result)) = timeout_at(deadline, waits.join_next()).await {
2740 if !matches!(result, Ok(Ok(true))) {
2741 warn!(?result, "daemon shutdown drain did not reach quiescence");
2742 }
2743 }
2744 Ok(())
2745 }
2746
2747 #[cfg(unix)]
2759 pub(crate) async fn end_children_for_daemon_shutdown(
2760 &self,
2761 already_escalated: bool,
2762 escalate: impl std::future::Future<Output = ()>,
2763 ) {
2764 tokio::pin!(escalate);
2765 let mut escalated = already_escalated;
2766 if let Some(forwarding) = &self.forwarding {
2767 let reason = CloseReason::new(
2768 "daemon_shutdown",
2769 "the daemon is exiting after its shutdown notice and drain",
2770 );
2771 if escalated {
2772 send_module_goodbyes_for_daemon_shutdown(forwarding, &reason, false).await;
2775 } else {
2776 tokio::select! {
2777 biased;
2778 _ = escalate.as_mut() => {
2779 info!("second SIGTERM: abandoning module GOODBYE delivery");
2780 escalated = true;
2781 }
2782 _ = send_module_goodbyes_for_daemon_shutdown(forwarding, &reason, true) => {}
2783 }
2784 }
2785 let closed = forwarding.close_all_connections(&reason);
2786 debug!(closed, "closed established connections for daemon shutdown");
2787 }
2788 let escalated_here = escalated && !already_escalated;
2792 let remaining_escalate = async move {
2793 if escalated_here {
2794 std::future::pending::<()>().await;
2795 } else {
2796 escalate.await;
2797 }
2798 };
2799 crate::child_roster::end_children_for_daemon_shutdown(
2800 &self.child_roster,
2801 escalated,
2802 remaining_escalate,
2803 )
2804 .await;
2805 }
2806
2807 pub fn new(registry: Arc<Registry>, restart_policy: RestartPolicy) -> Self {
2808 Self {
2809 registry,
2810 restart_policy,
2811 drain_timeout: DEFAULT_DRAIN_TIMEOUT,
2812 connection_file_path: None,
2813 capture_logs_dir: None,
2814 forwarding: None,
2815 process_liveness: Arc::new(SupervisorProcessLiveness::default()),
2816 supervisor_handle: None,
2817 health: HealthConfig::default(),
2818 daemon_start_clock: crate::clock::StartClock::capture(),
2819 terminal_journal: None,
2820 spawn_events: SpawnEventFeed::default(),
2821 provenance_probe: ExecutableIdentityProbe::default(),
2822 child_roster: ChildRoster::default(),
2823 #[cfg(target_os = "linux")]
2824 cgroup_placement: None,
2825 #[cfg(test)]
2826 test_after_first_spawn: AfterFirstSpawnHook::default(),
2827 }
2828 }
2829
2830 pub fn with_drain_timeout(mut self, drain_timeout: Duration) -> Self {
2831 self.drain_timeout = drain_timeout;
2832 self
2833 }
2834
2835 pub fn with_process_liveness(
2836 mut self,
2837 process_liveness: Arc<SupervisorProcessLiveness>,
2838 ) -> Self {
2839 self.process_liveness = process_liveness;
2840 self
2841 }
2842
2843 pub fn with_connection_file_path(mut self, connection_file_path: impl Into<PathBuf>) -> Self {
2844 self.connection_file_path = Some(connection_file_path.into());
2845 self
2846 }
2847
2848 pub fn with_capture_logs_dir(mut self, logs_dir: impl Into<PathBuf>) -> Self {
2850 self.capture_logs_dir = Some(logs_dir.into());
2851 self
2852 }
2853
2854 pub fn with_daemon_incarnation(self, daemon_incarnation: String) -> Self {
2857 self.spawn_events.configure_incarnation(daemon_incarnation);
2861 self
2862 }
2863
2864 pub fn with_terminal_journal(self, path: PathBuf, daemon_incarnation: String) -> Self {
2867 let mut this = self.with_daemon_incarnation(daemon_incarnation.clone());
2868 this.terminal_journal = Some(Arc::new(crate::terminal_journal::TerminalJournal::open(
2869 path,
2870 daemon_incarnation,
2871 )));
2872 this
2873 }
2874
2875 pub fn with_forwarding(mut self, forwarding: Arc<ForwardingTable>) -> Self {
2876 self.forwarding = Some(forwarding);
2877 self
2878 }
2879
2880 pub fn with_handle(mut self, supervisor_handle: SupervisorHandle) -> Self {
2881 self.spawn_events = supervisor_handle.spawn_events.clone();
2882 self.supervisor_handle = Some(supervisor_handle);
2883 self
2884 }
2885
2886 pub fn with_health_config(mut self, health: HealthConfig) -> Self {
2887 self.health = health;
2888 self
2889 }
2890
2891 pub fn with_live_children_record(self, path: impl Into<PathBuf>) -> Self {
2895 self.child_roster.record_to(path.into());
2896 self
2897 }
2898
2899 #[cfg(target_os = "linux")]
2900 pub fn with_cgroup_placement(
2901 mut self,
2902 cgroup_placement: Option<subc_cgroup::Placement>,
2903 ) -> Self {
2904 self.cgroup_placement = cgroup_placement;
2905 self
2906 }
2907
2908 pub fn spawn(&self, spec: ModuleSpec) -> Result<SupervisedModule, SuperviseError> {
2914 validate_spec(&spec)?;
2915 self.establish_identity(&spec);
2916
2917 let runtime = self.runtime_config();
2918 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
2919 let activity_spawn = runtime.begin_activity_spawn(&spec.module_id);
2920 let spawned = spawn_child(
2921 &spec,
2922 runtime.connection_file_path.as_deref(),
2923 self.supervisor_handle.as_ref(),
2924 &runtime.stderr_ring,
2925 runtime.capture_logs_dir.as_deref(),
2926 &runtime.child_roster,
2927 #[cfg(target_os = "linux")]
2928 runtime.cgroup_placement.as_ref(),
2929 );
2930 #[cfg(test)]
2931 self.test_after_first_spawn.run(&spec.module_id);
2932 let child = match spawned {
2933 Ok(child) => child,
2934 Err(err) => {
2935 self.abandon_unrostered(&spec.module_id);
2938 return Err(err);
2939 }
2940 };
2941 self.mark_first_process_running(&spec, &runtime, &snapshot, &child)?;
2942 if let Some(spawn) = activity_spawn {
2943 spawn.complete(&snapshot);
2944 }
2945
2946 Ok(self.supervised_module(spec, runtime, snapshot, Some(child)))
2947 }
2948
2949 fn establish_identity(&self, spec: &ModuleSpec) {
2962 if let Some(supervisor_handle) = &self.supervisor_handle {
2963 supervisor_handle.apply_identity_configuration(spec);
2964 supervisor_handle.mark_configured(&spec.module_id);
2965 }
2966 }
2967
2968 fn abandon_unrostered(&self, module_id: &str) {
2971 if let Some(supervisor_handle) = &self.supervisor_handle {
2972 supervisor_handle.unmark_configured_unless_rostered(module_id);
2973 }
2974 }
2975
2976 fn mark_first_process_running(
2979 &self,
2980 spec: &ModuleSpec,
2981 runtime: &SupervisorRuntimeConfig,
2982 snapshot: &SharedSnapshot,
2983 child: &SupervisedChild,
2984 ) -> Result<(), SuperviseError> {
2985 if let Err(err) = set_running(snapshot, child, &spec.module_id, &runtime.spawn_events) {
2986 self.abandon_unrostered(&spec.module_id);
2987 return Err(err);
2988 }
2989 self.process_liveness
2990 .track(spec.module_id.clone(), Arc::clone(snapshot));
2991 Ok(())
2992 }
2993
2994 pub fn supervise_configured(
3000 &self,
3001 spec: ModuleSpec,
3002 enabled: bool,
3003 ) -> Result<SupervisedModule, SuperviseError> {
3004 self.supervise_configured_requested(
3005 spec,
3006 enabled,
3007 self.health.clone(),
3008 None,
3009 self.restart_policy,
3010 None,
3011 )
3012 }
3013
3014 pub fn supervise_configured_with_health(
3020 &self,
3021 spec: ModuleSpec,
3022 enabled: bool,
3023 health: HealthConfig,
3024 drain_timeout_ms: Option<u64>,
3025 restart_policy: RestartPolicy,
3026 ) -> Result<SupervisedModule, SuperviseError> {
3027 self.supervise_configured_requested(
3028 spec,
3029 enabled,
3030 health,
3031 drain_timeout_ms,
3032 restart_policy,
3033 None,
3034 )
3035 }
3036
3037 pub(crate) fn supervise_configured_requested(
3038 &self,
3039 spec: ModuleSpec,
3040 enabled: bool,
3041 health: HealthConfig,
3042 drain_timeout_ms: Option<u64>,
3043 restart_policy: RestartPolicy,
3044 requester: Option<Principal>,
3045 ) -> Result<SupervisedModule, SuperviseError> {
3046 let registered = self.register_configured_with_health(
3047 spec,
3048 enabled,
3049 health,
3050 drain_timeout_ms,
3051 restart_policy,
3052 )?;
3053 self.start_registered_configured(registered, requester)
3054 }
3055
3056 pub(crate) fn register_configured_with_health(
3061 &self,
3062 spec: ModuleSpec,
3063 enabled: bool,
3064 health: HealthConfig,
3065 drain_timeout_ms: Option<u64>,
3066 restart_policy: RestartPolicy,
3067 ) -> Result<RegisteredConfiguredModule, SuperviseError> {
3068 validate_spec(&spec)?;
3069 self.establish_identity(&spec);
3070
3071 let mut runtime = self.runtime_config();
3072 runtime.health = health;
3073 runtime.restart_policy = restart_policy;
3074 if let Some(ms) = drain_timeout_ms {
3075 runtime.drain_timeout = Duration::from_millis(ms);
3076 *runtime
3077 .effective_drain_timeout
3078 .lock()
3079 .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.drain_timeout;
3080 }
3081 let snapshot = if enabled {
3082 SupervisorSnapshot::starting()
3083 } else {
3084 SupervisorSnapshot::disabled()
3085 };
3086 #[cfg(target_os = "linux")]
3087 let snapshot = {
3088 let mut snapshot = snapshot;
3089 if enabled {
3090 snapshot.waiting_for_previous_pid =
3091 runtime.child_roster.previous_instance_pid(&spec.module_id);
3092 }
3093 snapshot
3094 };
3095 let (module, commands) =
3096 self.register_module(&spec, &runtime, Arc::new(Mutex::new(snapshot)));
3097 Ok(RegisteredConfiguredModule {
3098 spec,
3099 runtime,
3100 module,
3101 commands,
3102 })
3103 }
3104
3105 pub(crate) fn start_registered_configured(
3108 &self,
3109 registered: RegisteredConfiguredModule,
3110 requester: Option<Principal>,
3111 ) -> Result<SupervisedModule, SuperviseError> {
3112 let RegisteredConfiguredModule {
3113 spec,
3114 mut runtime,
3115 module,
3116 commands,
3117 } = registered;
3118 runtime.start_requester = requester;
3119 let snapshot = &module.inner.snapshot;
3120 let should_spawn = {
3121 let state = lock_snapshot(snapshot)?;
3122 state.enabled && state.waiting_for_previous_pid.is_none()
3123 };
3124 let child = if should_spawn {
3125 let activity_spawn = runtime.begin_activity_spawn(&spec.module_id);
3126 let spawned = spawn_child(
3127 &spec,
3128 runtime.connection_file_path.as_deref(),
3129 self.supervisor_handle.as_ref(),
3130 &runtime.stderr_ring,
3131 runtime.capture_logs_dir.as_deref(),
3132 &runtime.child_roster,
3133 #[cfg(target_os = "linux")]
3134 runtime.cgroup_placement.as_ref(),
3135 );
3136 #[cfg(test)]
3137 self.test_after_first_spawn.run(&spec.module_id);
3138 match spawned {
3139 Ok(child) => {
3140 self.mark_first_process_running(&spec, &runtime, snapshot, &child)?;
3141 if let Some(spawn) = activity_spawn {
3142 spawn.complete(snapshot);
3143 }
3144 Some(child)
3145 }
3146 Err(err) => {
3147 if runtime.health.critical {
3148 error!(
3149 module_id = %spec.module_id,
3150 program = %spec.program.display(),
3151 error = %err,
3152 "critical configured module failed to spawn; marking failed and alerting"
3153 );
3154 } else {
3155 error!(
3156 module_id = %spec.module_id,
3157 program = %spec.program.display(),
3158 error = %err,
3159 "configured module failed to spawn; marking failed and continuing"
3160 );
3161 }
3162 update_snapshot(snapshot, Some(&spec.module_id), |state| {
3163 state.state = ModuleState::Failed;
3164 clear_current_process_facts(state);
3165 })?;
3166 None
3167 }
3168 }
3169 } else {
3170 None
3171 };
3172 self.start_monitor(spec, runtime, &module, child, commands);
3173 Ok(module)
3174 }
3175
3176 fn runtime_config(&self) -> SupervisorRuntimeConfig {
3177 let effective_drain_timeout = Arc::new(Mutex::new(self.drain_timeout));
3178 SupervisorRuntimeConfig {
3179 start_requester: None,
3180 pending_activity_restart: false,
3181 provenance_probe: self.provenance_probe.clone(),
3182 scheduled_respawn: Arc::default(),
3183 deferred_reload_reply: Arc::default(),
3184 restart_policy: self.restart_policy,
3185 drain_timeout: self.drain_timeout,
3186 child_roster: self
3189 .child_roster
3190 .for_module(Arc::clone(&effective_drain_timeout)),
3191 effective_drain_timeout,
3192 default_drain_timeout: self.drain_timeout,
3193 health: self.health.clone(),
3194 connection_file_path: self.connection_file_path.clone(),
3195 capture_logs_dir: self.capture_logs_dir.clone(),
3196 forwarding: self.forwarding.clone(),
3197 supervisor_handle: self.supervisor_handle.clone(),
3198 stderr_ring: Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default()))),
3199 terminal_ring: Arc::new(Mutex::new(
3200 TerminalRing::new(
3201 TerminalRingConfig::default(),
3202 self.daemon_start_clock.started_at_ms(),
3203 )
3204 .with_start_clock(self.daemon_start_clock)
3205 .with_journal(self.terminal_journal.clone())
3206 .with_daemon_shutdown(self.child_roster.shutdown_flag()),
3207 )),
3208 spawn_events: self.spawn_events.clone(),
3209 #[cfg(target_os = "linux")]
3210 cgroup_placement: self.cgroup_placement.clone(),
3211 #[cfg(test)]
3212 test_seed_stale_facts_before_enable_spawn: false,
3213 #[cfg(test)]
3214 test_reload_exit_record_gate: None,
3215 }
3216 }
3217
3218 fn supervised_module(
3219 &self,
3220 spec: ModuleSpec,
3221 runtime: SupervisorRuntimeConfig,
3222 snapshot: SharedSnapshot,
3223 child: Option<SupervisedChild>,
3224 ) -> SupervisedModule {
3225 let (module, commands) = self.register_module(&spec, &runtime, snapshot);
3226 self.start_monitor(spec, runtime, &module, child, commands);
3227 module
3228 }
3229
3230 fn register_module(
3231 &self,
3232 spec: &ModuleSpec,
3233 runtime: &SupervisorRuntimeConfig,
3234 snapshot: SharedSnapshot,
3235 ) -> (SupervisedModule, mpsc::Receiver<SupervisorCommand>) {
3236 let configuration = Arc::new(Mutex::new(SupervisedConfiguration {
3237 spec: spec.clone(),
3238 health: runtime.health.clone(),
3239 }));
3240 let stderr_ring = Arc::clone(&runtime.stderr_ring);
3241 let terminal_ring = Arc::clone(&runtime.terminal_ring);
3242 let restart_policy = runtime.restart_policy;
3246 let effective_drain_timeout = Arc::clone(&runtime.effective_drain_timeout);
3247 let (tx, rx) = mpsc::channel(4);
3248 let module_id = spec.module_id.clone();
3249 let module = SupervisedModule {
3250 inner: Arc::new(SupervisedModuleInner {
3251 module_id: module_id.clone(),
3252 registry: Arc::clone(&self.registry),
3253 snapshot,
3254 configuration,
3255 stderr_ring,
3256 terminal_ring,
3257 commands: tx,
3258 monitor: Mutex::new(None),
3259 restart_policy,
3260 effective_drain_timeout,
3261 provenance_probe: self.provenance_probe.clone(),
3262 }),
3263 };
3264 if let Some(supervisor_handle) = &self.supervisor_handle {
3267 supervisor_handle.insert(module.clone());
3268 }
3269 (module, rx)
3270 }
3271
3272 fn start_monitor(
3273 &self,
3274 spec: ModuleSpec,
3275 runtime: SupervisorRuntimeConfig,
3276 module: &SupervisedModule,
3277 child: Option<SupervisedChild>,
3278 commands: mpsc::Receiver<SupervisorCommand>,
3279 ) {
3280 let monitor = tokio::spawn(supervise_loop(
3281 spec,
3282 runtime,
3283 Arc::clone(&self.registry),
3284 Arc::clone(&self.process_liveness),
3285 Arc::clone(&module.inner.snapshot),
3286 child,
3287 commands,
3288 ));
3289 *module
3290 .inner
3291 .monitor
3292 .lock()
3293 .unwrap_or_else(|p| p.into_inner()) = Some(monitor);
3294 }
3295}
3296
3297impl Default for Supervisor {
3298 fn default() -> Self {
3299 Self::new(Arc::new(Registry::default()), RestartPolicy::default())
3300 }
3301}
3302
3303#[derive(Clone)]
3305pub struct SupervisedModule {
3306 inner: Arc<SupervisedModuleInner>,
3307}
3308
3309struct SupervisedModuleInner {
3310 module_id: String,
3311 registry: Arc<Registry>,
3312 snapshot: SharedSnapshot,
3313 configuration: Arc<Mutex<SupervisedConfiguration>>,
3314 stderr_ring: Arc<Mutex<StderrRing>>,
3315 terminal_ring: Arc<Mutex<TerminalRing>>,
3316 commands: mpsc::Sender<SupervisorCommand>,
3317 monitor: Mutex<Option<JoinHandle<()>>>,
3318 restart_policy: RestartPolicy,
3322 effective_drain_timeout: Arc<Mutex<Duration>>,
3323 provenance_probe: ExecutableIdentityProbe,
3324}
3325
3326impl fmt::Debug for SupervisedModule {
3327 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3328 f.debug_struct("SupervisedModule")
3329 .field("module_id", &self.inner.module_id)
3330 .field("status", &self.status())
3331 .finish_non_exhaustive()
3332 }
3333}
3334
3335impl SupervisedModule {
3336 pub fn module_id(&self) -> &str {
3337 &self.inner.module_id
3338 }
3339
3340 #[cfg(test)]
3344 pub(crate) fn record_health_probe_failure_for_test(
3345 &self,
3346 detail: &str,
3347 ) -> Result<(), SuperviseError> {
3348 update_snapshot(&self.inner.snapshot, Some(&self.inner.module_id), |state| {
3349 state.health.consecutive_failures = state.health.consecutive_failures.saturating_add(1);
3350 state.health.detail = Some(detail.to_string());
3351 })
3352 }
3353
3354 pub fn state(&self) -> Result<ModuleState, SuperviseError> {
3355 Ok(lock_snapshot(&self.inner.snapshot)?.state)
3356 }
3357
3358 pub(crate) fn registration_end_reason(
3359 &self,
3360 ) -> Result<Option<RegistrationEndReason>, SuperviseError> {
3361 let snapshot = lock_snapshot(&self.inner.snapshot)?;
3362 Ok(match snapshot.state {
3363 ModuleState::Draining if snapshot.draining_to_replace => {
3364 Some(RegistrationEndReason::SupervisorRestart)
3365 }
3366 ModuleState::Draining => Some(RegistrationEndReason::SupervisorStop),
3367 ModuleState::Restarting if snapshot.draining_to_replace => {
3368 Some(RegistrationEndReason::SupervisorRestart)
3369 }
3370 _ => None,
3371 })
3372 }
3373
3374 pub fn stderr_tail(
3381 &self,
3382 max_lines: Option<usize>,
3383 max_bytes: Option<usize>,
3384 ) -> StderrTailSnapshot {
3385 self.inner
3386 .stderr_ring
3387 .lock()
3388 .unwrap_or_else(|poisoned| poisoned.into_inner())
3389 .snapshot(max_lines, max_bytes)
3390 }
3391
3392 pub fn terminal_history(&self) -> TerminalHistorySnapshot {
3397 self.inner
3398 .terminal_ring
3399 .lock()
3400 .unwrap_or_else(|poisoned| poisoned.into_inner())
3401 .snapshot()
3402 }
3403
3404 pub fn durable_terminal_history(&self) -> subc_control::TerminalHistory {
3409 durable_terminal_history_of(&self.inner.terminal_ring, &self.inner.module_id)
3410 }
3411
3412 pub(crate) async fn read_durable_terminal_history(
3417 &self,
3418 ) -> Result<subc_control::TerminalHistory, tokio::task::JoinError> {
3419 let terminal_ring = Arc::clone(&self.inner.terminal_ring);
3420 let module_id = self.inner.module_id.clone();
3421 tokio::task::spawn_blocking(move || durable_terminal_history_of(&terminal_ring, &module_id))
3422 .await
3423 }
3424
3425 pub fn status(&self) -> Result<ModuleStatus, SuperviseError> {
3426 self.status_with_snapshot_lock(&self.inner.snapshot, None)
3427 .map(|(status, _)| status)
3428 }
3429
3430 pub(crate) fn record_deliberate_severance(
3431 &self,
3432 identity: ProcessIdentity,
3433 ) -> Result<bool, SuperviseError> {
3434 let mut snapshot = lock_snapshot(&self.inner.snapshot)?;
3435 if snapshot.pid != Some(identity.pid)
3436 || snapshot.process_start_time != Some(identity.start_time)
3437 {
3438 return Ok(false);
3439 }
3440 snapshot.deliberate_severance = Some(identity);
3441 Ok(true)
3442 }
3443
3444 pub(crate) fn status_for_control(
3449 &self,
3450 caller: &'static str,
3451 ) -> Result<ModuleStatus, SuperviseError> {
3452 self.status_with_snapshot_lock(&self.inner.snapshot, Some(caller))
3453 .map(|(status, _)| status)
3454 }
3455
3456 fn status_with_snapshot_lock(
3457 &self,
3458 snapshot: &SharedSnapshot,
3459 caller: Option<&'static str>,
3460 ) -> Result<(ModuleStatus, Option<SpawnedFileIdentity>), SuperviseError> {
3461 let mut guard = match caller {
3462 Some(caller) => lock_snapshot_for_control(snapshot, &self.inner.module_id, caller)?,
3463 None => lock_snapshot(snapshot)?,
3464 };
3465 let restart_count =
3468 guard.crash_restarts_in_window(self.inner.restart_policy.window, Instant::now());
3469 let snapshot = guard.clone();
3470 drop(guard);
3471 let drain_timeout = *self.inner.effective_drain_timeout.lock().map_err(|_| {
3472 SuperviseError::StatePoisoned {
3473 module_id: Some(self.inner.module_id.clone()),
3474 }
3475 })?;
3476 let registration_active = self
3477 .inner
3478 .registry
3479 .get_module(&self.inner.module_id)
3480 .map_err(SuperviseError::Registry)?
3481 .is_some();
3482 let protocol = snapshot
3483 .spawned_protocol
3484 .unwrap_or(self.declared_protocol()?);
3485 let running_process =
3486 snapshot.enabled && snapshot.state == ModuleState::Running && snapshot.process_alive;
3487 let live = match protocol {
3493 ModuleProtocol::Subc => running_process && registration_active,
3494 ModuleProtocol::None => running_process,
3495 };
3496
3497 Ok((
3498 ModuleStatus {
3499 waiting_for_previous_pid: snapshot.waiting_for_previous_pid,
3500 module_id: self.inner.module_id.clone(),
3501 launch_nonce_source: observed_launch_nonce_source(&snapshot),
3502 state: snapshot.state,
3503 enabled: snapshot.enabled,
3504 process_alive: snapshot.process_alive,
3505 registration_active,
3506 protocol,
3507 live,
3508 restart_count,
3509 lifetime_restarts: snapshot.lifetime_restarts,
3510 spawn_generation: snapshot.spawn_generation,
3511 max_restarts: self.inner.restart_policy.max_restarts,
3512 restart_window: self.inner.restart_policy.window,
3513 drain_timeout,
3514 restart_backoff: self.inner.restart_policy.backoff,
3515 restart_max_backoff: self.inner.restart_policy.max_backoff,
3516 pid: snapshot.reported_pid(),
3517 spawned_at_ms: snapshot.spawned_at_ms,
3518 spawned_from: snapshot.spawned_from,
3519 process_start_time: snapshot.process_start_time,
3520 last_exit: snapshot.last_exit,
3521 health: snapshot.health,
3522 },
3523 snapshot.spawned_file_identity.clone(),
3524 ))
3525 }
3526
3527 #[cfg(test)]
3528 pub(crate) fn hold_snapshot_for_test(
3529 &self,
3530 acquired: std::sync::mpsc::Sender<()>,
3531 hold: Duration,
3532 ) -> std::thread::JoinHandle<()> {
3533 let snapshot = Arc::clone(&self.inner.snapshot);
3534 std::thread::spawn(move || {
3535 let _guard = snapshot.lock().expect("test snapshot lock is not poisoned");
3536 acquired
3537 .send(())
3538 .expect("test receiver waits for snapshot lock");
3539 std::thread::sleep(hold);
3540 })
3541 }
3542
3543 pub(crate) async fn status_and_running_image_agreement(
3549 &self,
3550 ) -> Result<(ModuleStatus, subc_control::RunningImageAgreement), SuperviseError> {
3551 let (status, identity) = self.status_with_snapshot_lock(&self.inner.snapshot, None)?;
3552 let image = self
3553 .inner
3554 .provenance_probe
3555 .observe(
3556 status.pid,
3557 status.spawned_from.as_deref(),
3558 identity,
3559 status.process_start_time,
3560 )
3561 .await;
3562 Ok((status, image))
3563 }
3564
3565 pub(crate) async fn running_image_agreement(&self) -> subc_control::RunningImageAgreement {
3566 let snapshot = match lock_snapshot(&self.inner.snapshot) {
3567 Ok(snapshot) => snapshot.clone(),
3568 Err(_) => {
3569 return subc_control::RunningImageAgreement::Unavailable {
3570 reason: subc_control::RunningImageUnavailableReason::NotRunning,
3571 };
3572 }
3573 };
3574 self.inner
3575 .provenance_probe
3576 .observe(
3577 snapshot.reported_pid(),
3578 snapshot.spawned_from.as_deref(),
3579 snapshot.spawned_file_identity.clone(),
3580 snapshot.process_start_time,
3581 )
3582 .await
3583 }
3584
3585 pub(crate) fn child_resource_usage(&self) -> subc_control::ChildResourceUsage {
3588 #[cfg(windows)]
3589 {
3590 let image = lock_snapshot(&self.inner.snapshot)
3591 .ok()
3592 .and_then(|snapshot| {
3593 snapshot
3594 .spawned_file_identity
3595 .as_ref()
3596 .and_then(|identity| identity.windows_image.clone())
3597 });
3598 match image {
3599 Some(image) => {
3600 crate::child_resources::read_windows_handle(image.process(), image.start_time())
3601 }
3602 None => subc_control::ChildResourceUsage::Unavailable {
3603 reason: subc_control::ChildResourceUnavailableReason::NotRunning,
3604 },
3605 }
3606 }
3607 #[cfg(not(windows))]
3608 {
3609 let (pid, start_time) = match lock_snapshot(&self.inner.snapshot) {
3610 Ok(snapshot) => (snapshot.reported_pid(), snapshot.process_start_time),
3611 Err(_) => {
3612 return subc_control::ChildResourceUsage::Unavailable {
3613 reason: subc_control::ChildResourceUnavailableReason::Unreadable,
3614 }
3615 }
3616 };
3617 crate::child_resources::read(pid, start_time)
3618 }
3619 }
3620
3621 pub(crate) fn will_recover_after_connection_loss(&self) -> Result<bool, SuperviseError> {
3622 let mut snapshot = lock_snapshot(&self.inner.snapshot)?;
3623 Ok(match snapshot.state {
3624 ModuleState::Restarting => true,
3625 ModuleState::Failed | ModuleState::Disabled => false,
3626 _ if snapshot.enabled && snapshot.respawn_pending => true,
3627 _ => daemon_will_restart(&mut snapshot, &self.inner.restart_policy, Instant::now()),
3628 })
3629 }
3630
3631 #[cfg(test)]
3632 pub(crate) fn is_warming(&self) -> Result<bool, SuperviseError> {
3633 self.is_warming_with_snapshot_lock(None)
3634 }
3635
3636 pub(crate) fn is_warming_for_control(
3637 &self,
3638 caller: &'static str,
3639 ) -> Result<bool, SuperviseError> {
3640 self.is_warming_with_snapshot_lock(Some(caller))
3641 }
3642
3643 fn is_warming_with_snapshot_lock(
3644 &self,
3645 caller: Option<&'static str>,
3646 ) -> Result<bool, SuperviseError> {
3647 let snapshot = match caller {
3648 Some(caller) => {
3649 lock_snapshot_for_control(&self.inner.snapshot, &self.inner.module_id, caller)?
3650 }
3651 None => lock_snapshot(&self.inner.snapshot)?,
3652 }
3653 .clone();
3654 Ok(matches!(
3655 snapshot.state,
3656 ModuleState::Starting | ModuleState::Running | ModuleState::Restarting
3657 ))
3658 }
3659
3660 pub async fn drain(&self) -> Result<(), SuperviseError> {
3662 self.stop().await
3663 }
3664
3665 pub(crate) async fn retire_requested(
3666 &self,
3667 requester: Option<Principal>,
3668 ) -> Result<(), SuperviseError> {
3669 match self.state()? {
3670 ModuleState::Stopped | ModuleState::Failed => return Ok(()),
3671 ModuleState::Starting
3672 | ModuleState::Running
3673 | ModuleState::Unresponsive
3674 | ModuleState::Restarting
3675 | ModuleState::Draining
3676 | ModuleState::Disabled => {}
3677 }
3678
3679 let (reply_tx, reply_rx) = oneshot::channel();
3680 self.inner
3681 .commands
3682 .send(SupervisorCommand::Retire {
3683 requester,
3684 reply: reply_tx,
3685 })
3686 .await
3687 .map_err(|_| SuperviseError::CommandClosed {
3688 module_id: self.inner.module_id.clone(),
3689 })?;
3690 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3691 module_id: self.inner.module_id.clone(),
3692 })?
3693 }
3694
3695 pub async fn stop(&self) -> Result<(), SuperviseError> {
3696 self.stop_requested(None).await
3697 }
3698
3699 pub(crate) async fn stop_requested(
3700 &self,
3701 requester: Option<Principal>,
3702 ) -> Result<(), SuperviseError> {
3703 match self.state()? {
3704 ModuleState::Stopped | ModuleState::Failed => return Ok(()),
3705 ModuleState::Starting
3706 | ModuleState::Running
3707 | ModuleState::Unresponsive
3708 | ModuleState::Restarting
3709 | ModuleState::Draining
3710 | ModuleState::Disabled => {}
3711 }
3712
3713 let (reply_tx, reply_rx) = oneshot::channel();
3714 self.inner
3715 .commands
3716 .send(SupervisorCommand::Drain {
3717 requester,
3718 reply: reply_tx,
3719 })
3720 .await
3721 .map_err(|_| SuperviseError::CommandClosed {
3722 module_id: self.inner.module_id.clone(),
3723 })?;
3724 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3725 module_id: self.inner.module_id.clone(),
3726 })?
3727 }
3728
3729 pub async fn restart(&self, drain_timeout_ms: Option<u64>) -> Result<(), SuperviseError> {
3730 self.restart_requested(drain_timeout_ms, None).await
3731 }
3732
3733 pub(crate) async fn restart_requested(
3734 &self,
3735 drain_timeout_ms: Option<u64>,
3736 requester: Option<Principal>,
3737 ) -> Result<(), SuperviseError> {
3738 let received_at_generation = lock_snapshot(&self.inner.snapshot)?.spawn_generation;
3739 let (reply_tx, reply_rx) = oneshot::channel();
3740 self.inner
3741 .commands
3742 .send(SupervisorCommand::Restart {
3743 requester,
3744 drain_timeout_ms,
3745 received_at_generation,
3746 queued_at: Instant::now(),
3747 reply: reply_tx,
3748 })
3749 .await
3750 .map_err(|_| SuperviseError::CommandClosed {
3751 module_id: self.inner.module_id.clone(),
3752 })?;
3753 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3754 module_id: self.inner.module_id.clone(),
3755 })?
3756 }
3757
3758 pub async fn swap(&self, ready_timeout: Option<Duration>) -> Result<(), SuperviseError> {
3763 self.swap_requested(ready_timeout, None).await
3764 }
3765
3766 pub(crate) async fn swap_requested(
3767 &self,
3768 ready_timeout: Option<Duration>,
3769 requester: Option<Principal>,
3770 ) -> Result<(), SuperviseError> {
3771 let (reply_tx, reply_rx) = oneshot::channel();
3772 self.inner
3773 .commands
3774 .send(SupervisorCommand::Swap {
3775 requester,
3776 ready_timeout,
3777 reply: reply_tx,
3778 })
3779 .await
3780 .map_err(|_| SuperviseError::CommandClosed {
3781 module_id: self.inner.module_id.clone(),
3782 })?;
3783 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3784 module_id: self.inner.module_id.clone(),
3785 })?
3786 }
3787
3788 pub async fn reload(&self) -> Result<(), SuperviseError> {
3789 self.reload_requested(None).await
3790 }
3791
3792 pub(crate) async fn reload_requested(
3793 &self,
3794 requester: Option<Principal>,
3795 ) -> Result<(), SuperviseError> {
3796 let (reply_tx, reply_rx) = oneshot::channel();
3797 self.inner
3798 .commands
3799 .send(SupervisorCommand::Reload {
3800 requester,
3801 reply: reply_tx,
3802 })
3803 .await
3804 .map_err(|_| SuperviseError::CommandClosed {
3805 module_id: self.inner.module_id.clone(),
3806 })?;
3807 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3808 module_id: self.inner.module_id.clone(),
3809 })?
3810 }
3811
3812 pub async fn set_enabled(&self, enabled: bool) -> Result<bool, SuperviseError> {
3813 self.set_enabled_requested(enabled, None).await
3814 }
3815
3816 pub(crate) async fn set_enabled_requested(
3817 &self,
3818 enabled: bool,
3819 requester: Option<Principal>,
3820 ) -> Result<bool, SuperviseError> {
3821 let (reply_tx, reply_rx) = oneshot::channel();
3822 self.inner
3823 .commands
3824 .send(SupervisorCommand::SetEnabled {
3825 requester,
3826 enabled,
3827 reply: reply_tx,
3828 })
3829 .await
3830 .map_err(|_| SuperviseError::CommandClosed {
3831 module_id: self.inner.module_id.clone(),
3832 })?;
3833 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3834 module_id: self.inner.module_id.clone(),
3835 })?
3836 }
3837
3838 pub(crate) fn declared_protocol(&self) -> Result<ModuleProtocol, SuperviseError> {
3842 let configured = self
3843 .inner
3844 .configuration
3845 .lock()
3846 .map_err(|_| SuperviseError::StatePoisoned {
3847 module_id: Some(self.inner.module_id.clone()),
3848 })?
3849 .spec
3850 .protocol;
3851 let state = lock_snapshot(&self.inner.snapshot)?;
3852 Ok(state.spawned_protocol.unwrap_or(configured))
3853 }
3854
3855 pub(crate) fn configuration(&self) -> Result<(ModuleSpec, HealthConfig), SuperviseError> {
3856 let configuration =
3857 self.inner
3858 .configuration
3859 .lock()
3860 .map_err(|_| SuperviseError::StatePoisoned {
3861 module_id: Some(self.inner.module_id.clone()),
3862 })?;
3863 Ok((configuration.spec.clone(), configuration.health.clone()))
3864 }
3865
3866 #[cfg(any(test, feature = "test-support"))]
3870 pub async fn update_spec_for_test(&self, spec: ModuleSpec) -> Result<(), SuperviseError> {
3871 let (_, health) = self.configuration()?;
3872 let drain_timeout_ms = u64::try_from(
3873 self.inner
3874 .effective_drain_timeout
3875 .lock()
3876 .unwrap_or_else(|poisoned| poisoned.into_inner())
3877 .as_millis(),
3878 )
3879 .ok();
3880 self.update_configuration(spec, health, drain_timeout_ms)
3881 .await
3882 }
3883
3884 pub(crate) async fn update_configuration(
3885 &self,
3886 spec: ModuleSpec,
3887 health: HealthConfig,
3888 drain_timeout_ms: Option<u64>,
3889 ) -> Result<(), SuperviseError> {
3890 if spec.module_id != self.inner.module_id {
3891 return Err(SuperviseError::InvalidSpec {
3892 reason: "a supervised module's module_id cannot be changed".to_string(),
3893 });
3894 }
3895 validate_spec(&spec)?;
3896 let (reply_tx, reply_rx) = oneshot::channel();
3897 self.inner
3898 .commands
3899 .send(SupervisorCommand::UpdateConfiguration {
3900 spec: spec.clone(),
3901 health: health.clone(),
3902 drain_timeout_ms,
3903 reply: reply_tx,
3904 })
3905 .await
3906 .map_err(|_| SuperviseError::CommandClosed {
3907 module_id: self.inner.module_id.clone(),
3908 })?;
3909 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3910 module_id: self.inner.module_id.clone(),
3911 })?;
3912 let mut configuration =
3913 self.inner
3914 .configuration
3915 .lock()
3916 .map_err(|_| SuperviseError::StatePoisoned {
3917 module_id: Some(self.inner.module_id.clone()),
3918 })?;
3919 configuration.spec = spec;
3920 configuration.health = health;
3921 Ok(())
3922 }
3923}
3924
3925impl Drop for SupervisedModuleInner {
3926 fn drop(&mut self) {
3927 let Ok(mut monitor) = self.monitor.lock() else {
3928 return;
3929 };
3930 if let Some(monitor) = monitor.as_ref().filter(|monitor| !monitor.is_finished()) {
3931 let _ = update_snapshot(&self.snapshot, Some(&self.module_id), |state| {
3932 state.state = ModuleState::Stopped;
3933 clear_current_process_facts(state);
3934 });
3935 monitor.abort();
3936 }
3937 let _ = monitor.take();
3938 }
3939}
3940
3941#[derive(Debug)]
3942enum SupervisorCommand {
3943 Drain {
3944 requester: Option<Principal>,
3945 reply: oneshot::Sender<Result<(), SuperviseError>>,
3946 },
3947 Retire {
3948 requester: Option<Principal>,
3949 reply: oneshot::Sender<Result<(), SuperviseError>>,
3950 },
3951 Restart {
3952 requester: Option<Principal>,
3953 drain_timeout_ms: Option<u64>,
3958 received_at_generation: u64,
3962 queued_at: Instant,
3965 reply: oneshot::Sender<Result<(), SuperviseError>>,
3966 },
3967 Reload {
3968 requester: Option<Principal>,
3969 reply: oneshot::Sender<Result<(), SuperviseError>>,
3970 },
3971 SetEnabled {
3972 requester: Option<Principal>,
3973 enabled: bool,
3974 reply: oneshot::Sender<Result<bool, SuperviseError>>,
3975 },
3976 UpdateConfiguration {
3977 spec: ModuleSpec,
3978 health: HealthConfig,
3979 drain_timeout_ms: Option<u64>,
3982 reply: oneshot::Sender<()>,
3983 },
3984 Swap {
3985 requester: Option<Principal>,
3986 ready_timeout: Option<Duration>,
3989 reply: oneshot::Sender<Result<(), SuperviseError>>,
3991 },
3992}
3993
3994#[derive(Debug)]
3995pub enum SuperviseError {
3996 InvalidSpec {
3997 reason: String,
3998 },
3999 Spawn {
4000 program: PathBuf,
4001 source: io::Error,
4002 cgroup_path: Option<PathBuf>,
4003 },
4004 Cgroup {
4005 module_id: String,
4006 source: io::Error,
4007 },
4008 LaunchNonce {
4011 reason: String,
4012 },
4013 Wait {
4014 module_id: String,
4015 source: io::Error,
4016 },
4017 Kill {
4018 module_id: String,
4019 source: io::Error,
4020 },
4021 Forwarding(ForwardingError),
4022 Registry(RegistryError),
4023 ReloadUnavailable {
4024 module_id: String,
4025 reason: String,
4026 },
4027 Disabled {
4032 module_id: String,
4033 },
4034 ReloadFailed {
4035 module_id: String,
4036 reason: String,
4037 },
4038 RegistrationStillActive {
4039 module_id: String,
4040 waited: Duration,
4041 },
4042 StatePoisoned {
4043 module_id: Option<String>,
4044 },
4045 CommandClosed {
4046 module_id: String,
4047 },
4048 SwapInProgress {
4052 module_id: String,
4053 },
4054 SwapRefused {
4056 module_id: String,
4057 reason: SwapRefusal,
4058 },
4059 SwapFailed {
4063 module_id: String,
4064 arm: SwapFailureArm,
4065 detail: String,
4066 candidate_exit: Option<ExitReport>,
4069 },
4070}
4071
4072#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4074pub enum SwapRefusal {
4075 OverlapExclusive,
4077 NotRegistered,
4080 ProtocolNone,
4083 NotConfigured,
4086 AlreadySwapping,
4088}
4089
4090impl SwapRefusal {
4091 pub fn as_str(self) -> &'static str {
4092 match self {
4093 Self::OverlapExclusive => "overlap_exclusive",
4094 Self::NotRegistered => "not_registered",
4095 Self::ProtocolNone => "protocol_none",
4096 Self::NotConfigured => "not_configured",
4097 Self::AlreadySwapping => "already_swapping",
4098 }
4099 }
4100}
4101
4102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4105pub enum SwapFailureArm {
4106 SpawnFailed,
4108 NeverRegistered,
4110 NeverReady,
4112 CandidateExited,
4114 CandidateUnhealthy,
4116 Interrupted,
4120 CutoverLost,
4125}
4126
4127impl SwapFailureArm {
4128 pub fn as_str(self) -> &'static str {
4129 match self {
4130 Self::SpawnFailed => "spawn_failed",
4131 Self::NeverRegistered => "never_registered",
4132 Self::NeverReady => "never_ready",
4133 Self::CandidateExited => "candidate_exited",
4134 Self::CandidateUnhealthy => "candidate_unhealthy",
4135 Self::Interrupted => "interrupted",
4136 Self::CutoverLost => "cutover_lost",
4137 }
4138 }
4139}
4140
4141impl fmt::Display for SuperviseError {
4142 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4143 match self {
4144 Self::InvalidSpec { reason } => write!(f, "invalid module spec: {reason}"),
4145 Self::Spawn {
4146 program,
4147 source,
4148 cgroup_path: Some(cgroup_path),
4149 } => write!(
4150 f,
4151 "failed to place module in cgroup '{}' while spawning '{}': {source}",
4152 cgroup_path.display(),
4153 program.display()
4154 ),
4155 Self::Spawn {
4156 program,
4157 source,
4158 cgroup_path: None,
4159 } => write!(
4160 f,
4161 "failed to spawn module '{}': {source}",
4162 program.display()
4163 ),
4164 Self::Cgroup { module_id, source } => {
4165 write!(
4166 f,
4167 "failed to prepare cgroup for module '{module_id}': {source}"
4168 )
4169 }
4170 Self::LaunchNonce { reason } => {
4171 write!(
4172 f,
4173 "failed to generate reserved-module launch nonce: {reason}"
4174 )
4175 }
4176 Self::Wait { module_id, source } => {
4177 write!(f, "failed to wait for module '{module_id}': {source}")
4178 }
4179 Self::Kill { module_id, source } => {
4180 write!(f, "failed to kill module '{module_id}': {source}")
4181 }
4182 Self::Forwarding(err) => write!(f, "forwarding error: {err}"),
4183 Self::Registry(err) => write!(f, "registry error: {err}"),
4184 Self::ReloadUnavailable { module_id, reason } => {
4185 write!(f, "reload unavailable for module '{module_id}': {reason}")
4186 }
4187 Self::Disabled { module_id } => {
4188 write!(
4189 f,
4190 "module '{module_id}' is disabled; enable it before restart or reload"
4191 )
4192 }
4193 Self::ReloadFailed { module_id, reason } => {
4194 write!(f, "reload failed for module '{module_id}': {reason}")
4195 }
4196 Self::RegistrationStillActive { module_id, waited } => write!(
4197 f,
4198 "module '{module_id}' registration remained active after waiting {waited:?}"
4199 ),
4200 Self::StatePoisoned { module_id } => match module_id {
4201 Some(module_id) => {
4202 write!(f, "supervisor state for module '{module_id}' was poisoned")
4203 }
4204 None => write!(f, "supervisor state was poisoned"),
4205 },
4206 Self::CommandClosed { module_id } => {
4207 write!(
4208 f,
4209 "supervisor command channel for module '{module_id}' is closed"
4210 )
4211 }
4212 Self::SwapInProgress { module_id } => write!(
4213 f,
4214 "module '{module_id}' is being swapped; retry once the swap has cut over or failed, or stop the module to abort the swap"
4215 ),
4216 Self::SwapRefused { module_id, reason } => match reason {
4217 SwapRefusal::OverlapExclusive => write!(
4218 f,
4219 "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"
4220 ),
4221 SwapRefusal::NotRegistered => write!(
4222 f,
4223 "module '{module_id}' is not registered, so there is no serving process to keep while a replacement warms; use a plain restart"
4224 ),
4225 SwapRefusal::ProtocolNone => write!(
4226 f,
4227 "module '{module_id}' is protocol: \"none\" and never registers, so a swap could never see its replacement become ready; use a plain restart"
4228 ),
4229 SwapRefusal::NotConfigured => write!(
4230 f,
4231 "module '{module_id}' cannot be swapped: the supervisor was built without the forwarding table or shared handle a swap needs"
4232 ),
4233 SwapRefusal::AlreadySwapping => {
4234 write!(f, "module '{module_id}' is already being swapped")
4235 }
4236 },
4237 Self::SwapFailed {
4238 module_id,
4239 arm,
4240 detail,
4241 ..
4242 } => write!(
4243 f,
4244 "swap of module '{module_id}' failed ({}): {detail}; the running process was left serving",
4245 arm.as_str()
4246 ),
4247 }
4248 }
4249}
4250
4251impl Error for SuperviseError {
4252 fn source(&self) -> Option<&(dyn Error + 'static)> {
4253 match self {
4254 Self::Spawn { source, .. }
4255 | Self::Cgroup { source, .. }
4256 | Self::Wait { source, .. }
4257 | Self::Kill { source, .. } => Some(source),
4258 Self::Forwarding(err) => Some(err),
4259 Self::Registry(err) => Some(err),
4260 Self::LaunchNonce { .. }
4261 | Self::InvalidSpec { .. }
4262 | Self::ReloadUnavailable { .. }
4263 | Self::Disabled { .. }
4264 | Self::ReloadFailed { .. }
4265 | Self::RegistrationStillActive { .. }
4266 | Self::StatePoisoned { .. }
4267 | Self::CommandClosed { .. }
4268 | Self::SwapInProgress { .. }
4269 | Self::SwapRefused { .. }
4270 | Self::SwapFailed { .. } => None,
4271 }
4272 }
4273}
4274
4275pub(crate) fn validate_spec(spec: &ModuleSpec) -> Result<(), SuperviseError> {
4276 if spec.module_id.trim().is_empty() {
4277 return Err(SuperviseError::InvalidSpec {
4278 reason: "module_id must not be empty".to_string(),
4279 });
4280 }
4281
4282 Ok(())
4283}
4284
4285#[derive(Debug, Default)]
4286struct HealthProbeRuntime {
4287 configured_health: Option<HealthConfig>,
4288 registered_connection: Option<crate::ConnectionId>,
4289 advertised: bool,
4290 next_probe_at: Option<Instant>,
4291 probe_index: u64,
4292}
4293
4294fn running_protocol(spec: &ModuleSpec, snapshot: &SharedSnapshot) -> ModuleProtocol {
4295 lock_snapshot(snapshot)
4296 .ok()
4297 .and_then(|state| state.spawned_protocol)
4298 .unwrap_or(spec.protocol)
4299}
4300
4301impl HealthProbeRuntime {
4302 fn refresh_registration(
4303 &mut self,
4304 spec: &ModuleSpec,
4305 runtime: &SupervisorRuntimeConfig,
4306 registry: &Registry,
4307 snapshot: &SharedSnapshot,
4308 ) {
4309 if self.configured_health.as_ref() != Some(&runtime.health) {
4310 self.configured_health = Some(runtime.health.clone());
4311 self.next_probe_at = None;
4312 self.registered_connection = None;
4313 self.probe_index = 0;
4314 }
4315 if running_protocol(spec, snapshot) == ModuleProtocol::None {
4319 self.registered_connection = None;
4320 self.advertised = runtime.health.http.is_some();
4321 if !self.advertised {
4322 self.next_probe_at = None;
4323 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4324 let unknown = ModuleHealthStatus::default();
4325 if state.health != unknown {
4326 state.health = unknown;
4327 }
4328 });
4329 } else if self.next_probe_at.is_none() {
4330 self.next_probe_at = Some(
4331 Instant::now()
4332 + jittered_health_delay(&spec.module_id, 0, runtime.health.cadence),
4333 );
4334 }
4335 return;
4336 }
4337
4338 let registration = match registry.get_module(&spec.module_id) {
4339 Ok(registration) => registration,
4340 Err(err) => {
4341 warn!(module_id = %spec.module_id, error = %err, "health prober could not read registry");
4342 self.advertised = false;
4343 self.next_probe_at = None;
4344 return;
4345 }
4346 };
4347
4348 let Some(registration) = registration else {
4349 self.registered_connection = None;
4350 self.advertised = false;
4351 self.next_probe_at = None;
4352 return;
4353 };
4354
4355 let advertised = registration
4356 .control_ops
4357 .iter()
4358 .any(|op| op == MODULE_CONTROL_OP_HEALTH_CHECK);
4359 if !advertised {
4360 self.registered_connection = Some(registration.connection_id);
4361 self.advertised = false;
4362 self.next_probe_at = None;
4363 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4364 if state.health.status != SupervisorHealthStatus::Unknown
4365 || state.health.consecutive_failures != 0
4366 || state.health.last_probe_ms.is_some()
4367 || state.health.detail.is_some()
4368 || state.health.metrics.is_some()
4369 {
4370 state.health.status = SupervisorHealthStatus::Unknown;
4371 state.health.consecutive_failures = 0;
4372 state.health.last_probe_ms = None;
4373 state.health.detail = None;
4374 state.health.metrics = None;
4375 }
4376 });
4377 return;
4378 }
4379
4380 let reregistered = self.registered_connection != Some(registration.connection_id);
4381 self.registered_connection = Some(registration.connection_id);
4382 self.advertised = true;
4383 if reregistered || self.next_probe_at.is_none() {
4384 self.probe_index = 0;
4385 self.next_probe_at = Some(
4386 Instant::now() + jittered_health_delay(&spec.module_id, 0, runtime.health.cadence),
4387 );
4388 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4389 state.health.status = SupervisorHealthStatus::Unknown;
4390 state.health.consecutive_failures = 0;
4391 state.health.detail = None;
4392 state.health.metrics = None;
4393 });
4394 }
4395 }
4396
4397 fn wake_after(&self) -> Option<Duration> {
4398 if !self.advertised {
4399 return None;
4400 }
4401 self.next_probe_at
4402 .map(|next| next.saturating_duration_since(Instant::now()))
4403 }
4404
4405 fn due(&self) -> bool {
4406 self.advertised
4407 && self
4408 .next_probe_at
4409 .is_some_and(|next| Instant::now() >= next)
4410 }
4411
4412 fn schedule_next(&mut self, spec: &ModuleSpec, cadence: Duration) {
4413 self.probe_index = self.probe_index.wrapping_add(1);
4414 self.next_probe_at = Some(
4415 Instant::now() + jittered_health_delay(&spec.module_id, self.probe_index, cadence),
4416 );
4417 }
4418}
4419
4420#[derive(Debug)]
4455enum HealthProbeEvidence {
4456 LaneDead,
4458 NoAnswer,
4460 BadAnswer,
4462 Misconfigured,
4464}
4465
4466#[derive(Debug)]
4467struct HealthProbeError {
4468 evidence: HealthProbeEvidence,
4469 message: String,
4470}
4471
4472impl HealthProbeError {
4473 fn lane_dead(message: impl Into<String>) -> Self {
4474 Self::with(HealthProbeEvidence::LaneDead, message)
4475 }
4476
4477 fn no_answer(message: impl Into<String>) -> Self {
4478 Self::with(HealthProbeEvidence::NoAnswer, message)
4479 }
4480
4481 fn bad_answer(message: impl Into<String>) -> Self {
4482 Self::with(HealthProbeEvidence::BadAnswer, message)
4483 }
4484
4485 fn misconfigured(message: impl Into<String>) -> Self {
4486 Self::with(HealthProbeEvidence::Misconfigured, message)
4487 }
4488
4489 fn with(evidence: HealthProbeEvidence, message: impl Into<String>) -> Self {
4490 Self {
4491 evidence,
4492 message: message.into(),
4493 }
4494 }
4495
4496 #[allow(dead_code)]
4510 fn is_proof_of_death(&self) -> bool {
4511 matches!(self.evidence, HealthProbeEvidence::LaneDead)
4512 }
4513
4514 fn label(&self) -> &'static str {
4522 match self.evidence {
4523 HealthProbeEvidence::LaneDead => "lane-dead",
4524 HealthProbeEvidence::NoAnswer => "no-answer",
4525 HealthProbeEvidence::BadAnswer => "bad-answer",
4526 HealthProbeEvidence::Misconfigured => "daemon-misconfigured",
4527 }
4528 }
4529}
4530
4531impl fmt::Display for HealthProbeError {
4532 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4533 f.write_str(&self.message)
4534 }
4535}
4536
4537async fn run_health_probe_cycle(
4538 spec: &ModuleSpec,
4539 runtime: &SupervisorRuntimeConfig,
4540 registry: &Registry,
4541 process_liveness: &SupervisorProcessLiveness,
4542 snapshot: &SharedSnapshot,
4543 child: &mut Option<SupervisedChild>,
4544) {
4545 let now_ms = unix_ms_now();
4546 let http = (running_protocol(spec, snapshot) == ModuleProtocol::None)
4547 .then_some(runtime.health.http.as_deref())
4548 .flatten();
4549 let result = match http {
4550 Some(url) => probe_http_health(url, runtime.health.deadline).await,
4551 None => probe_module_health(&spec.module_id, runtime, None).await,
4552 };
4553 match result {
4554 Ok(report) => {
4555 handle_health_report(
4556 spec,
4557 runtime,
4558 registry,
4559 process_liveness,
4560 snapshot,
4561 child,
4562 report,
4563 now_ms,
4564 )
4565 .await;
4566 }
4567 Err(err) => {
4568 if http.is_some() {
4569 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4570 state.health.status = SupervisorHealthStatus::Failing;
4571 });
4572 }
4573 handle_health_probe_failure(
4574 spec,
4575 runtime,
4576 registry,
4577 process_liveness,
4578 snapshot,
4579 child,
4580 err,
4581 now_ms,
4582 )
4583 .await;
4584 }
4585 }
4586}
4587
4588pub(crate) struct HttpProbeTarget<'a> {
4589 address: std::net::SocketAddr,
4590 localhost: bool,
4591 authority: &'a str,
4592 path: String,
4593}
4594
4595pub(crate) fn parse_http_probe_url(url: &str) -> Result<HttpProbeTarget<'_>, String> {
4598 if url.bytes().any(|b| b <= b' ' || b == 127) || url.contains('#') {
4599 return Err("must not contain whitespace, controls, or a fragment".into());
4600 }
4601 let rest = url
4602 .strip_prefix("http://")
4603 .ok_or("must use plain http://")?;
4604 let split = rest.find(['/', '?']).unwrap_or(rest.len());
4605 let (authority, suffix) = rest.split_at(split);
4606 let (host, port) = if let Some(rest) = authority.strip_prefix("[::1]") {
4607 ("::1", rest)
4608 } else {
4609 let split = authority.find(':').unwrap_or(authority.len());
4610 authority.split_at(split)
4611 };
4612 let ip: std::net::IpAddr = match host {
4613 "127.0.0.1" | "localhost" => std::net::Ipv4Addr::LOCALHOST.into(),
4614 "::1" => std::net::Ipv6Addr::LOCALHOST.into(),
4615 _ => return Err("host must be 127.0.0.1, [::1], or localhost".into()),
4616 };
4617 let port = if port.is_empty() {
4618 80
4619 } else {
4620 port.strip_prefix(':')
4621 .and_then(|p| p.parse::<u16>().ok())
4622 .filter(|p| *p > 0)
4623 .ok_or("must have a valid nonzero TCP port")?
4624 };
4625 let path = if suffix.is_empty() {
4626 "/".into()
4627 } else if suffix.starts_with('?') {
4628 format!("/{suffix}")
4629 } else {
4630 suffix.into()
4631 };
4632 Ok(HttpProbeTarget {
4633 address: std::net::SocketAddr::new(ip, port),
4634 localhost: host == "localhost",
4635 authority,
4636 path,
4637 })
4638}
4639
4640async fn probe_http_health(
4641 url: &str,
4642 deadline: Duration,
4643) -> Result<HealthReport, HealthProbeError> {
4644 use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader};
4645 let target = parse_http_probe_url(url).map_err(HealthProbeError::misconfigured)?;
4646 let mut response_status = String::new();
4649 let mut body = Vec::new();
4650 let probe = async {
4651 let connection = match tokio::net::TcpStream::connect(target.address).await {
4654 Err(_) if target.localhost => {
4655 tokio::net::TcpStream::connect((
4656 std::net::Ipv6Addr::LOCALHOST,
4657 target.address.port(),
4658 ))
4659 .await
4660 }
4661 result => result,
4662 };
4663 let mut stream = connection.map_err(|error| {
4664 HealthProbeError::no_answer(format!("HTTP connect failed: {error}"))
4665 })?;
4666 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
4667 .map_err(|error| HealthProbeError::no_answer(format!("HTTP write failed: {error}")))?;
4668 let mut reader = BufReader::new(stream);
4669 let mut budget = 16 * 1024;
4670 let status = http_line(&mut reader, &mut budget).await?;
4671 let mut words = status.split_ascii_whitespace();
4672 let version = words.next();
4673 let code = words
4674 .next()
4675 .filter(|word| word.len() == 3)
4676 .and_then(|word| word.parse::<u16>().ok());
4677 if !matches!(version, Some("HTTP/1.1" | "HTTP/1.0"))
4678 || !code.is_some_and(|code| (100..600).contains(&code))
4679 {
4680 return Err(HealthProbeError::bad_answer(format!(
4681 "invalid HTTP status: {status}"
4682 )));
4683 }
4684 let code = code.expect("validated status code");
4685 response_status = status.clone();
4686 let mut length = None;
4687 let mut chunked = false;
4688 loop {
4689 let line = http_line(&mut reader, &mut budget).await?;
4690 if line.is_empty() {
4691 break;
4692 }
4693 if let Some((name, value)) = line.split_once(':') {
4694 if name.eq_ignore_ascii_case("content-length") {
4695 length = Some(value.trim().parse::<u64>().map_err(|_| {
4696 HealthProbeError::bad_answer("invalid HTTP Content-Length")
4697 })?);
4698 } else if name.eq_ignore_ascii_case("transfer-encoding") {
4699 chunked = value.trim().eq_ignore_ascii_case("chunked");
4700 }
4701 }
4702 }
4703 if chunked {
4704 while body.len() < 200 {
4705 let line = http_line(&mut reader, &mut budget).await?;
4706 let size = u64::from_str_radix(line.split(';').next().unwrap_or("").trim(), 16)
4707 .map_err(|_| HealthProbeError::bad_answer("invalid HTTP chunk size"))?;
4708 if size == 0 {
4709 break;
4710 }
4711 let count = size.min((200 - body.len()) as u64) as usize;
4712 let start = body.len();
4713 (&mut reader)
4714 .take(count as u64)
4715 .read_to_end(&mut body)
4716 .await
4717 .map_err(|error| {
4718 HealthProbeError::no_answer(format!("HTTP body read failed: {error}"))
4719 })?;
4720 if body.len() - start != count {
4721 return Err(HealthProbeError::bad_answer("truncated HTTP chunk"));
4722 }
4723 if size > count as u64 || body.len() == 200 {
4724 break;
4725 }
4726 if !http_line(&mut reader, &mut budget).await?.is_empty() {
4727 return Err(HealthProbeError::bad_answer("invalid HTTP chunk delimiter"));
4728 }
4729 }
4730 } else {
4731 reader
4732 .take(length.unwrap_or(200).min(200))
4733 .read_to_end(&mut body)
4734 .await
4735 .map_err(|error| {
4736 HealthProbeError::no_answer(format!("HTTP body read failed: {error}"))
4737 })?;
4738 }
4739 if (200..300).contains(&code) {
4740 Ok(HealthReport::ok())
4741 } else {
4742 Err(HealthProbeError::bad_answer(
4743 "HTTP health endpoint returned non-2xx",
4744 ))
4745 }
4746 };
4747 let mut result = timeout(deadline, probe).await.unwrap_or_else(|_| {
4748 Err(HealthProbeError::no_answer(format!(
4749 "HTTP probe timed out after {deadline:?}"
4750 )))
4751 });
4752 if let Err(error) = &mut result {
4753 if !response_status.is_empty() {
4754 error.message = format!(
4755 "{}; {response_status}: {}",
4756 error.message,
4757 String::from_utf8_lossy(&body)
4758 );
4759 }
4760 }
4761 result
4762}
4763
4764async fn http_line(
4765 reader: &mut tokio::io::BufReader<tokio::net::TcpStream>,
4766 remaining: &mut usize,
4767) -> Result<String, HealthProbeError> {
4768 use tokio::io::{AsyncBufReadExt, AsyncReadExt};
4769 let mut line = Vec::new();
4770 (&mut *reader)
4771 .take(*remaining as u64)
4772 .read_until(b'\n', &mut line)
4773 .await
4774 .map_err(|error| {
4775 HealthProbeError::no_answer(format!("HTTP header read failed: {error}"))
4776 })?;
4777 *remaining -= line.len();
4778 if !line.ends_with(b"\r\n") {
4779 return Err(HealthProbeError::bad_answer(
4780 "HTTP headers are incomplete or exceed 16 KiB",
4781 ));
4782 }
4783 line.truncate(line.len() - 2);
4784 String::from_utf8(line).map_err(|_| HealthProbeError::bad_answer("HTTP header is not UTF-8"))
4785}
4786
4787async fn probe_module_health(
4788 module_id: &str,
4789 runtime: &SupervisorRuntimeConfig,
4790 drain_deadline: Option<Instant>,
4791) -> Result<HealthReport, HealthProbeError> {
4792 let Some(forwarding) = runtime.forwarding.as_ref() else {
4793 return Err(HealthProbeError::misconfigured(
4794 "supervisor was not configured with a forwarding table",
4795 ));
4796 };
4797 let probe_started_at = Instant::now();
4798 let mut deadline = probe_started_at + runtime.health.deadline;
4799 if let Some(drain_deadline) = drain_deadline {
4800 deadline = deadline.min(drain_deadline);
4801 }
4802 let pending = if drain_deadline.is_some() {
4803 forwarding.begin_drain_health_probe_rpc_for(
4804 module_id,
4805 MODULE_CONTROL_OP_HEALTH_CHECK,
4806 probe_started_at,
4807 deadline,
4808 )
4809 } else {
4810 forwarding.begin_health_probe_rpc_for(
4811 module_id,
4812 MODULE_CONTROL_OP_HEALTH_CHECK,
4813 probe_started_at,
4814 deadline,
4815 )
4816 }
4817 .map_err(|err| {
4818 HealthProbeError::lane_dead(format!("failed to begin health.check RPC: {err}"))
4821 })?;
4822 await_health_probe(forwarding, pending, deadline, runtime.health.deadline).await
4823}
4824
4825async fn probe_endpoint_health(
4832 endpoint: crate::ModuleEndpointId,
4833 runtime: &SupervisorRuntimeConfig,
4834 deadline_cap: Option<Instant>,
4835) -> Result<HealthReport, HealthProbeError> {
4836 let Some(forwarding) = runtime.forwarding.as_ref() else {
4837 return Err(HealthProbeError::misconfigured(
4838 "supervisor was not configured with a forwarding table",
4839 ));
4840 };
4841 let probe_started_at = Instant::now();
4842 let mut deadline = probe_started_at + runtime.health.deadline;
4843 if let Some(cap) = deadline_cap {
4844 deadline = deadline.min(cap);
4845 }
4846 let pending = forwarding
4847 .begin_endpoint_health_probe_rpc_for(
4848 endpoint,
4849 MODULE_CONTROL_OP_HEALTH_CHECK,
4850 probe_started_at,
4851 deadline,
4852 )
4853 .map_err(|err| {
4854 HealthProbeError::lane_dead(format!("failed to begin health.check RPC: {err}"))
4855 })?;
4856 await_health_probe(forwarding, pending, deadline, runtime.health.deadline).await
4857}
4858
4859async fn await_health_probe(
4861 forwarding: &ForwardingTable,
4862 pending: PendingModuleControlRpc,
4863 deadline: Instant,
4864 probe_budget: Duration,
4865) -> Result<HealthReport, HealthProbeError> {
4866 let PendingModuleControlRpc {
4867 endpoint,
4868 module_sink,
4869 negotiated_ver,
4870 corr,
4871 receiver,
4872 } = pending;
4873 let body = serde_json::to_vec(&ModuleControlRequest::HealthCheck {}).map_err(|err| {
4874 HealthProbeError::misconfigured(format!("failed to encode health.check: {err}"))
4875 })?;
4876 let frame = Frame::build_with_version(
4877 negotiated_ver,
4878 FrameType::Request,
4879 control_flags(),
4880 0,
4881 0,
4882 corr,
4883 body,
4884 )
4885 .map_err(|err| {
4886 HealthProbeError::misconfigured(format!("failed to build health.check frame: {err}"))
4887 })?;
4888
4889 match timeout_at(deadline, module_sink.send(frame)).await {
4895 Ok(Ok(())) => {}
4896 Ok(Err(err)) => {
4897 let _ = forwarding.cancel_module_control_rpc(endpoint, corr);
4898 return Err(HealthProbeError::lane_dead(format!(
4901 "failed to send health.check: {err}"
4902 )));
4903 }
4904 Err(_elapsed) => {
4905 let _ = forwarding.cancel_module_control_rpc(endpoint, corr);
4906 return Err(HealthProbeError::no_answer(
4910 "health.check send timed out before enqueue (module egress full)",
4911 ));
4912 }
4913 }
4914
4915 match timeout_at(deadline, receiver).await {
4916 Ok(Ok(ModuleControlRpcOutcome::Response(response))) => {
4920 response.health_report().ok_or_else(|| {
4921 HealthProbeError::bad_answer("health.check RPC returned a non-health response")
4922 })
4923 }
4924 Ok(Ok(ModuleControlRpcOutcome::Rejected(body))) => Err(HealthProbeError::bad_answer(
4925 format!("health.check rejected: {}", body.message),
4926 )),
4927 Ok(Ok(ModuleControlRpcOutcome::ModuleGone(message))) => {
4928 Err(HealthProbeError::lane_dead(message))
4929 }
4930 Ok(Ok(ModuleControlRpcOutcome::MalformedResponse(message))) => {
4931 Err(HealthProbeError::bad_answer(message))
4932 }
4933 Ok(Ok(ModuleControlRpcOutcome::UnexpectedOp { expected, actual })) => {
4934 Err(HealthProbeError::bad_answer(format!(
4935 "expected module-control op '{expected}', got '{actual}'"
4936 )))
4937 }
4938 Ok(Ok(ModuleControlRpcOutcome::DeadlineElapsed)) => Err(HealthProbeError::bad_answer(
4942 "module answered health.check after its daemon deadline",
4943 )),
4944 Ok(Err(_)) => Err(HealthProbeError::misconfigured(
4945 "health.check waiter was canceled before the module responded",
4946 )),
4947 Err(_) => {
4948 let _ = forwarding.tombstone_health_probe_rpc(endpoint, corr);
4949 Err(HealthProbeError::no_answer(format!(
4950 "module did not answer health.check within {probe_budget:?}"
4951 )))
4952 }
4953 }
4954}
4955
4956#[allow(clippy::too_many_arguments)]
4957async fn handle_health_report(
4958 spec: &ModuleSpec,
4959 runtime: &SupervisorRuntimeConfig,
4960 registry: &Registry,
4961 process_liveness: &SupervisorProcessLiveness,
4962 snapshot: &SharedSnapshot,
4963 child: &mut Option<SupervisedChild>,
4964 report: HealthReport,
4965 now_ms: u64,
4966) {
4967 let status = supervisor_health_status(report.status);
4968 let detail = report.detail.clone();
4969 let metrics = truncate_health_metrics(report.metrics);
4970 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4971 state.health.status = status;
4972 state.health.last_probe_ms = Some(now_ms);
4973 state.health.detail = detail.clone();
4974 state.health.metrics = metrics.clone();
4975 state.health.consecutive_failures = 0;
4976 });
4977
4978 let action = match report.status {
4979 HealthStatus::Ok => return,
4980 HealthStatus::Degraded => runtime.health.on_degraded,
4981 HealthStatus::Failing => runtime.health.on_failing,
4982 };
4983 apply_l3_health_action(
4984 spec,
4985 runtime,
4986 registry,
4987 process_liveness,
4988 snapshot,
4989 child,
4990 status,
4991 detail.as_deref(),
4992 action,
4993 now_ms,
4994 )
4995 .await;
4996}
4997
4998#[allow(clippy::too_many_arguments)]
4999async fn handle_health_probe_failure(
5000 spec: &ModuleSpec,
5001 runtime: &SupervisorRuntimeConfig,
5002 registry: &Registry,
5003 process_liveness: &SupervisorProcessLiveness,
5004 snapshot: &SharedSnapshot,
5005 child: &mut Option<SupervisedChild>,
5006 err: HealthProbeError,
5007 now_ms: u64,
5008) {
5009 let threshold = runtime.health.failure_threshold.max(1);
5010 let mut failures = 0;
5011 let detail = format!("[{}] {err}", err.label());
5016 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
5017 if state.spawned_protocol.unwrap_or(spec.protocol) == ModuleProtocol::Subc {
5020 state.health.status = SupervisorHealthStatus::Unknown;
5021 }
5022 state.health.last_probe_ms = Some(now_ms);
5023 state.health.consecutive_failures = state.health.consecutive_failures.saturating_add(1);
5024 state.health.detail = Some(detail.clone());
5025 state.health.metrics = None;
5026 failures = state.health.consecutive_failures;
5027 });
5028
5029 if failures < threshold {
5030 warn!(
5031 module_id = %spec.module_id,
5032 consecutive_failures = failures,
5033 threshold,
5034 evidence = err.label(),
5035 detail = %detail,
5036 "health.check probe failed"
5037 );
5038 return;
5039 }
5040
5041 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
5042 state.state = ModuleState::Unresponsive;
5043 state.health.status = SupervisorHealthStatus::Unresponsive;
5044 });
5045 if runtime.health.critical {
5049 error!(
5050 module_id = %spec.module_id,
5051 status = "unresponsive",
5052 evidence = err.label(),
5053 detail = %detail,
5054 "critical module health alert"
5055 );
5056 } else {
5057 warn!(
5058 module_id = %spec.module_id,
5059 status = "unresponsive",
5060 evidence = err.label(),
5061 detail = %detail,
5062 "module health threshold breached"
5063 );
5064 }
5065 if let Err(err) = health_restart_child(
5066 spec,
5067 runtime,
5068 registry,
5069 process_liveness,
5070 snapshot,
5071 child,
5072 SupervisorHealthStatus::Unresponsive,
5073 Some(&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#[allow(clippy::too_many_arguments)]
5083async fn apply_l3_health_action(
5084 spec: &ModuleSpec,
5085 runtime: &SupervisorRuntimeConfig,
5086 registry: &Registry,
5087 process_liveness: &SupervisorProcessLiveness,
5088 snapshot: &SharedSnapshot,
5089 child: &mut Option<SupervisedChild>,
5090 status: SupervisorHealthStatus,
5091 detail: Option<&str>,
5092 action: HealthAction,
5093 now_ms: u64,
5094) {
5095 record_health_action(snapshot, &spec.module_id, action.to_string(), now_ms);
5096 match action {
5097 HealthAction::Report => {
5098 info!(
5099 module_id = %spec.module_id,
5100 status = ?status,
5101 detail,
5102 "module reported non-ok health"
5103 );
5104 }
5105 HealthAction::Alert => {
5106 error!(
5107 module_id = %spec.module_id,
5108 status = ?status,
5109 detail,
5110 "module health alert"
5111 );
5112 }
5113 HealthAction::Restart => {
5114 if let Err(err) = health_restart_child(
5115 spec,
5116 runtime,
5117 registry,
5118 process_liveness,
5119 snapshot,
5120 child,
5121 status,
5122 detail,
5123 now_ms,
5124 )
5125 .await
5126 {
5127 error!(module_id = %spec.module_id, error = %err, "health-triggered restart failed");
5128 }
5129 }
5130 }
5131}
5132
5133#[allow(clippy::too_many_arguments)]
5134async fn health_restart_child(
5135 spec: &ModuleSpec,
5136 runtime: &SupervisorRuntimeConfig,
5137 registry: &Registry,
5138 process_liveness: &SupervisorProcessLiveness,
5139 snapshot: &SharedSnapshot,
5140 child: &mut Option<SupervisedChild>,
5141 status: SupervisorHealthStatus,
5142 detail: Option<&str>,
5143 now_ms: u64,
5144) -> Result<(), SuperviseError> {
5145 let (enabled, schedule) = {
5146 let mut state = lock_snapshot(snapshot)?;
5147 let enabled = state.enabled;
5148 let schedule = if enabled {
5149 state.next_crash_restart(&runtime.restart_policy, Instant::now())
5150 } else {
5151 None
5152 };
5153 (enabled, schedule)
5154 };
5155
5156 if !enabled {
5157 return Err(SuperviseError::Disabled {
5158 module_id: spec.module_id.clone(),
5159 });
5160 }
5161
5162 if schedule.is_none() {
5163 record_health_action(snapshot, &spec.module_id, "failed".to_string(), now_ms);
5164 error!(
5165 module_id = %spec.module_id,
5166 status = ?status,
5167 detail,
5168 max_restarts = runtime.restart_policy.max_restarts,
5169 window_secs = runtime.restart_policy.window.as_secs(),
5170 reason = %runtime.restart_policy.budget_exhausted_detail(),
5171 "health restart budget exhausted; marking module failed"
5172 );
5173 let stop_notice = begin_forwarding_drain_if_configured(
5174 spec,
5175 runtime,
5176 registry,
5177 snapshot,
5178 Some(true),
5179 RouteCloseReason::Disable,
5180 )
5181 .await?;
5182 update_snapshot(snapshot, Some(&spec.module_id), |state| {
5183 state.drain_disposition_detail = Some(runtime.restart_policy.budget_exhausted_detail());
5184 })?;
5185 drain_optional_child(
5186 &spec.module_id,
5187 spec.protocol,
5188 stop_notice,
5189 registry,
5190 runtime.forwarding.as_deref(),
5191 snapshot,
5192 &runtime.terminal_ring,
5193 &runtime.spawn_events,
5194 child,
5195 runtime.drain_timeout,
5196 ModuleState::Failed,
5197 Some(true),
5198 )
5199 .await?;
5200 process_liveness.untrack_if_current(&spec.module_id, snapshot);
5201 return Ok(());
5202 }
5203
5204 let schedule = schedule.expect("a health restart must have a crash-restart schedule");
5205 let mut restart_count = 0;
5206 update_snapshot(snapshot, Some(&spec.module_id), |state| {
5207 restart_count = state.crash_restarts.len();
5208 state.state = ModuleState::Unresponsive;
5209 state.health.status = status;
5210 state.health.last_action = Some(HealthAction::Restart.to_string());
5211 state.health.last_action_ms = Some(now_ms);
5212 })?;
5213 warn!(
5214 module_id = %spec.module_id,
5215 status = ?status,
5216 detail,
5217 restart_count,
5218 restart_in_window = schedule.restart_in_window,
5219 delay_ms = schedule.delay.as_millis() as u64,
5220 "health-triggered module restart"
5221 );
5222
5223 let stop_notice = begin_forwarding_drain_if_configured(
5224 spec,
5225 runtime,
5226 registry,
5227 snapshot,
5228 Some(true),
5229 RouteCloseReason::Restart,
5230 )
5231 .await?;
5232 drain_optional_child(
5233 &spec.module_id,
5234 spec.protocol,
5235 stop_notice,
5236 registry,
5237 runtime.forwarding.as_deref(),
5238 snapshot,
5239 &runtime.terminal_ring,
5240 &runtime.spawn_events,
5241 child,
5242 runtime.drain_timeout,
5243 ModuleState::Restarting,
5244 Some(true),
5245 )
5246 .await?;
5247 schedule_respawn(
5248 runtime,
5249 snapshot,
5250 &spec.module_id,
5251 schedule.delay,
5252 RespawnKind::Spawn,
5253 )
5254}
5255
5256fn cancel_deferred_reload(runtime: &SupervisorRuntimeConfig, module_id: &str, reason: &str) {
5257 if let Some(reply) = runtime
5258 .deferred_reload_reply
5259 .lock()
5260 .unwrap_or_else(|p| p.into_inner())
5261 .take()
5262 {
5263 let _ = reply.send(Err(SuperviseError::ReloadFailed {
5264 module_id: module_id.to_string(),
5265 reason: reason.to_string(),
5266 }));
5267 }
5268}
5269
5270fn schedule_respawn(
5271 runtime: &SupervisorRuntimeConfig,
5272 snapshot: &SharedSnapshot,
5273 module_id: &str,
5274 delay: Duration,
5275 kind: RespawnKind,
5276) -> Result<(), SuperviseError> {
5277 cancel_deferred_reload(runtime, module_id, "respawn superseded by another restart");
5278 update_snapshot(snapshot, Some(module_id), |state| {
5279 state.respawn_pending = true
5280 })?;
5281 *runtime
5282 .scheduled_respawn
5283 .lock()
5284 .unwrap_or_else(|p| p.into_inner()) = Some(PendingRespawn {
5285 deadline: Instant::now() + delay,
5286 kind,
5287 });
5288 Ok(())
5289}
5290
5291fn record_health_action(snapshot: &SharedSnapshot, module_id: &str, action: String, now_ms: u64) {
5292 let _ = update_snapshot(snapshot, Some(module_id), |state| {
5293 state.health.last_action = Some(action);
5294 state.health.last_action_ms = Some(now_ms);
5295 });
5296}
5297
5298fn supervisor_health_status(status: HealthStatus) -> SupervisorHealthStatus {
5299 match status {
5300 HealthStatus::Ok => SupervisorHealthStatus::Ok,
5301 HealthStatus::Degraded => SupervisorHealthStatus::Degraded,
5302 HealthStatus::Failing => SupervisorHealthStatus::Failing,
5303 }
5304}
5305
5306fn truncate_health_metrics(metrics: Option<Value>) -> Option<Value> {
5318 let metrics = metrics?;
5319 match serde_json::to_vec(&metrics) {
5320 Ok(encoded) if encoded.len() > MAX_HEALTH_METRICS_BYTES => Some(serde_json::json!({
5321 "truncated": true,
5322 "original_bytes": encoded.len(),
5323 })),
5324 Ok(_) | Err(_) => Some(metrics),
5325 }
5326}
5327
5328fn jittered_health_delay(module_id: &str, probe_index: u64, cadence: Duration) -> Duration {
5334 if cadence.is_zero() {
5335 return Duration::ZERO;
5336 }
5337 let cadence_ms = cadence.as_millis() as u64;
5338 if cadence_ms == 0 {
5354 return cadence;
5355 }
5356 let jitter_span = (cadence_ms / 10).max(1);
5371 let hash = module_id.as_bytes().iter().fold(
5372 probe_index.wrapping_mul(0x9E37_79B9_7F4A_7C15),
5373 |acc, byte| {
5374 acc.wrapping_mul(1099511628211)
5375 .wrapping_add(u64::from(*byte))
5376 },
5377 );
5378 cadence + Duration::from_millis(hash % jitter_span)
5379}
5380
5381#[cfg(test)]
5382mod tests {
5383 use super::*;
5384
5385 #[test]
5386 fn readding_a_module_clears_its_rescan_removal_tombstone() {
5387 let handle = SupervisorHandle::new();
5388 let module_id = "readded-tombstone";
5389 handle.record_rescan_removal(module_id);
5390 assert!(handle.removal_tombstone_age_ms(module_id).is_some());
5391
5392 handle.apply_identity_configuration(&ModuleSpec {
5393 module_id: module_id.to_string(),
5394 program: PathBuf::from("/test/module"),
5395 args: Vec::new(),
5396 env: Vec::new(),
5397 reserved: false,
5398 reserved_prefixes: Vec::new(),
5399 protocol: ModuleProtocol::Subc,
5400 overlap: Default::default(),
5401 });
5402
5403 assert!(
5404 handle.removal_tombstone_age_ms(module_id).is_none(),
5405 "a re-added module must not retain a stale removal tombstone"
5406 );
5407 }
5408
5409 #[derive(Debug, PartialEq, Eq)]
5412 struct OwnerInSpawnWindow {
5413 module_id: String,
5414 configured: bool,
5415 on_roster: bool,
5416 admission_refusal: Option<&'static str>,
5417 }
5418
5419 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5430 async fn a_new_module_is_configured_before_its_first_process_can_register() {
5431 use crate::scopes::ScopeTable;
5432 use subc_protocol::{error_codes, scope::ScopeSelector, Principal};
5433
5434 let dir = cortexkit_test_support::ScratchDir::new("configured-before-spawn");
5435 let stub = |module_id: &str, program: PathBuf| ModuleSpec {
5436 module_id: module_id.to_string(),
5437 program,
5438 args: Vec::new(),
5439 env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
5440 .into_iter()
5441 .map(|key| (key.to_string(), dir.path().display().to_string()))
5442 .chain([("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string())])
5443 .collect(),
5444 reserved: false,
5445 reserved_prefixes: Vec::new(),
5446 protocol: ModuleProtocol::Subc,
5447 overlap: Default::default(),
5448 };
5449 let live = super::terminal_history_tests::fake_aft_stub_path();
5450 let missing = dir.path().join("definitely-missing-module");
5451
5452 let handle = SupervisorHandle::new();
5453 let mut supervisor =
5454 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5455 .with_handle(handle.clone());
5456 let observed = Arc::new(Mutex::new(Vec::<OwnerInSpawnWindow>::new()));
5457 let hook_handle = handle.clone();
5458 let hook_observed = Arc::clone(&observed);
5459 supervisor.test_after_first_spawn = AfterFirstSpawnHook(Some(Arc::new(move |module_id| {
5460 let configured = hook_handle.is_configured(module_id);
5463 let selector = ScopeSelector {
5464 owner: Principal::Reserved {
5465 module_id: module_id.to_string(),
5466 },
5467 scope_ref: "s".to_string(),
5468 scope_epoch: Some(1),
5469 };
5470 let carrier = Principal::Reserved {
5471 module_id: "carrier".to_string(),
5472 };
5473 let admission_refusal = match ScopeTable::new(Vec::<String>::new())
5474 .admit(&carrier, module_id, &selector, configured)
5475 {
5476 Ok(_) => None,
5477 Err(refusal) => Some(refusal.code),
5478 };
5479 hook_observed.lock().unwrap().push(OwnerInSpawnWindow {
5480 module_id: module_id.to_string(),
5481 configured,
5482 on_roster: hook_handle.get(module_id).is_some(),
5483 admission_refusal,
5484 });
5485 })));
5486
5487 let plain = supervisor.spawn(stub("plain", live.clone())).unwrap();
5488 let configured = supervisor
5489 .supervise_configured(stub("configured", live.clone()), true)
5490 .unwrap();
5491 let with_health = supervisor
5492 .supervise_configured_with_health(
5493 stub("with-health", live.clone()),
5494 true,
5495 HealthConfig::default(),
5496 None,
5497 RestartPolicy::default(),
5498 )
5499 .unwrap();
5500 let failed = supervisor
5503 .supervise_configured_with_health(
5504 stub("failed-spawn", missing.clone()),
5505 true,
5506 HealthConfig::default(),
5507 None,
5508 RestartPolicy::default(),
5509 )
5510 .unwrap();
5511 assert!(supervisor.spawn(stub("spawn-error", missing)).is_err());
5514
5515 let expected = [
5516 "plain",
5517 "configured",
5518 "with-health",
5519 "failed-spawn",
5520 "spawn-error",
5521 ]
5522 .into_iter()
5523 .map(|module_id| OwnerInSpawnWindow {
5524 module_id: module_id.to_string(),
5525 configured: true,
5526 on_roster: !matches!(module_id, "plain" | "spawn-error"),
5527 admission_refusal: Some(error_codes::SCOPE_NOT_SYNCED),
5528 })
5529 .collect::<Vec<_>>();
5530 assert_eq!(*observed.lock().unwrap(), expected);
5531
5532 for module_id in ["plain", "configured", "with-health", "failed-spawn"] {
5533 assert!(
5534 handle.get(module_id).is_some(),
5535 "{module_id} is on the roster"
5536 );
5537 assert!(
5538 handle.is_configured(module_id),
5539 "{module_id} stays configured"
5540 );
5541 }
5542 assert!(handle.get("spawn-error").is_none());
5543 assert!(
5544 !handle.is_configured("spawn-error"),
5545 "a plain spawn that failed must not leave its module marked configured"
5546 );
5547
5548 handle.retire("failed-spawn");
5550 assert!(!handle.is_configured("failed-spawn"));
5551
5552 for module in [plain, configured, with_health] {
5553 module.stop().await.unwrap();
5554 }
5555 drop(failed);
5556 }
5557
5558 fn stale_process_snapshot(state: ModuleState, enabled: bool) -> SharedSnapshot {
5559 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(state, enabled)));
5560 update_snapshot(&snapshot, Some("stale-process-facts"), |snapshot| {
5561 snapshot.process_alive = true;
5562 snapshot.pid = Some(41);
5563 snapshot.spawned_at_ms = Some(42);
5564 snapshot.spawned_from = Some(PathBuf::from("/spawned/module"));
5565 snapshot.spawned_file_identity = Some(SpawnedFileIdentity {
5566 device: 43,
5567 inode: 44,
5568 #[cfg(windows)]
5569 windows_image: None,
5570 });
5571 })
5572 .unwrap();
5573 snapshot
5574 }
5575
5576 fn assert_snapshot_process_facts_cleared(snapshot: &SharedSnapshot) {
5577 let snapshot = lock_snapshot(snapshot).unwrap();
5578 assert!(!snapshot.process_alive);
5579 assert_eq!(snapshot.pid, None);
5580 assert_eq!(snapshot.spawned_at_ms, None);
5581 assert_eq!(snapshot.spawned_from, None);
5582 assert_eq!(snapshot.spawned_file_identity, None);
5583 }
5584
5585 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5586 async fn failed_enable_spawn_clears_preexisting_current_process_facts() {
5587 let supervisor =
5588 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5589 let mut runtime = supervisor.runtime_config();
5590 runtime.test_seed_stale_facts_before_enable_spawn = true;
5591 let snapshot = stale_process_snapshot(ModuleState::Disabled, false);
5592 let mut child = None;
5593 let spec = ModuleSpec {
5594 module_id: "failed-enable-clears-facts".to_string(),
5595 program: PathBuf::from("/definitely/missing/failed-enable-module"),
5596 args: Vec::new(),
5597 env: Vec::new(),
5598 reserved: false,
5599 reserved_prefixes: Vec::new(),
5600 protocol: ModuleProtocol::Subc,
5601 overlap: Default::default(),
5602 };
5603
5604 let result = set_child_enabled(
5605 &spec,
5606 &runtime,
5607 &supervisor.registry,
5608 &supervisor.process_liveness,
5609 &snapshot,
5610 &mut child,
5611 true,
5612 )
5613 .await;
5614
5615 assert!(matches!(result, Err(SuperviseError::Spawn { .. })));
5616 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
5617 assert_snapshot_process_facts_cleared(&snapshot);
5618 }
5619
5620 #[tokio::test]
5621 async fn start_revives_stranded_restarting_but_not_pending_backoff() {
5622 let supervisor =
5623 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5624 let runtime = supervisor.runtime_config();
5625 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(
5626 ModuleState::Restarting,
5627 true,
5628 )));
5629 let spec = ModuleSpec {
5630 module_id: "start-stranded-restarting".to_string(),
5631 program: super::terminal_history_tests::fake_aft_stub_path(),
5632 args: Vec::new(),
5633 env: vec![("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string())],
5634 reserved: false,
5635 reserved_prefixes: Vec::new(),
5636 protocol: ModuleProtocol::None,
5637 overlap: Default::default(),
5638 };
5639 let mut child = None;
5640 lock_snapshot(&snapshot).unwrap().respawn_pending = true;
5641 assert!(!super::set_child_enabled(
5642 &spec,
5643 &runtime,
5644 &Registry::default(),
5645 &supervisor.process_liveness,
5646 &snapshot,
5647 &mut child,
5648 true
5649 )
5650 .await
5651 .unwrap());
5652 assert!(child.is_none());
5653 lock_snapshot(&snapshot).unwrap().respawn_pending = false;
5654 assert!(super::set_child_enabled(
5655 &spec,
5656 &runtime,
5657 &Registry::default(),
5658 &supervisor.process_liveness,
5659 &snapshot,
5660 &mut child,
5661 true
5662 )
5663 .await
5664 .unwrap());
5665 assert_eq!(
5666 lock_snapshot(&snapshot).unwrap().state,
5667 ModuleState::Running
5668 );
5669 let mut child = child.unwrap();
5670 child.start_kill().unwrap();
5671 child.wait().await.unwrap();
5672 }
5673
5674 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5675 async fn failed_reload_spawn_clears_current_process_facts() {
5676 let supervisor =
5677 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5678 let mut runtime = supervisor.runtime_config();
5679 runtime.restart_policy = RestartPolicy::new(0, Duration::ZERO);
5680 let snapshot = stale_process_snapshot(ModuleState::Running, true);
5681 let mut child = None;
5682 let spec = ModuleSpec {
5683 module_id: "failed-reload-clears-facts".to_string(),
5684 program: PathBuf::from("/unused/failed-reload-module"),
5685 args: Vec::new(),
5686 env: Vec::new(),
5687 reserved: false,
5688 reserved_prefixes: Vec::new(),
5689 protocol: ModuleProtocol::Subc,
5690 overlap: Default::default(),
5691 };
5692
5693 let result = handle_reload_spawn_failure(
5694 &spec,
5695 &runtime,
5696 &supervisor.process_liveness,
5697 &snapshot,
5698 &mut child,
5699 "forced reload spawn failure".to_string(),
5700 )
5701 .await;
5702
5703 assert!(matches!(result, Err(SuperviseError::ReloadFailed { .. })));
5704 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
5705 assert_snapshot_process_facts_cleared(&snapshot);
5706 }
5707
5708 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5709 async fn dropping_a_module_with_an_active_monitor_clears_current_process_facts() {
5710 let supervisor =
5711 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5712 let snapshot = stale_process_snapshot(ModuleState::Running, true);
5713 let module = supervisor.supervised_module(
5714 ModuleSpec {
5715 module_id: "drop-clears-facts".to_string(),
5716 program: PathBuf::from("/unused/drop-module"),
5717 args: Vec::new(),
5718 env: Vec::new(),
5719 reserved: false,
5720 reserved_prefixes: Vec::new(),
5721 protocol: ModuleProtocol::Subc,
5722 overlap: Default::default(),
5723 },
5724 supervisor.runtime_config(),
5725 Arc::clone(&snapshot),
5726 None,
5727 );
5728 assert!(!module
5729 .inner
5730 .monitor
5731 .lock()
5732 .unwrap()
5733 .as_ref()
5734 .unwrap()
5735 .is_finished());
5736
5737 drop(module);
5738
5739 assert_eq!(
5740 lock_snapshot(&snapshot).unwrap().state,
5741 ModuleState::Stopped
5742 );
5743 assert_snapshot_process_facts_cleared(&snapshot);
5744 }
5745
5746 #[cfg(unix)]
5747 #[tokio::test]
5748 async fn rescan_preserves_running_protocol_until_respawn() {
5749 let dir = cortexkit_test_support::ScratchDir::new("rescan-protocol");
5750 let initial = ModuleSpec {
5751 module_id: "rescan-protocol".into(),
5752 program: PathBuf::from("/bin/sleep"),
5753 args: vec!["60".into()],
5754 env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
5755 .into_iter()
5756 .map(|key| (key.into(), dir.path().to_string_lossy().into_owned()))
5757 .collect(),
5758 reserved: false,
5759 reserved_prefixes: vec![],
5760 protocol: ModuleProtocol::None,
5761 overlap: Default::default(),
5762 };
5763 let supervisor =
5764 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5765 let module = supervisor.spawn(initial.clone()).unwrap();
5766 assert!(module.status().unwrap().live);
5767 let mut next = initial;
5768 next.protocol = ModuleProtocol::Subc;
5769 module
5770 .update_configuration(next.clone(), HealthConfig::default(), None)
5771 .await
5772 .unwrap();
5773 assert!(
5774 module.status().unwrap().live,
5775 "rescan must not require HELLO from the old non-wire process"
5776 );
5777 let runtime = supervisor.runtime_config();
5778 let action = on_child_exit(
5779 &next,
5780 RestartPolicy::default(),
5781 &supervisor.registry,
5782 &module.inner.snapshot,
5783 &runtime.terminal_ring,
5784 &runtime.spawn_events,
5785 &runtime.child_roster,
5786 ExitReport {
5787 kind: ExitKind::Clean,
5788 code: Some(0),
5789 signal: None,
5790 at_ms: unix_ms_now(),
5791 },
5792 )
5793 .await;
5794 assert!(
5795 matches!(action, NextAction::Restart { .. }),
5796 "the old non-wire process's clean exit must restart"
5797 );
5798 module.drain().await.unwrap();
5799 }
5800
5801 #[cfg(unix)]
5802 #[tokio::test]
5803 async fn orphan_identity_matches_path_names_and_shebang_interpreters() {
5804 use std::os::unix::fs::PermissionsExt;
5805 let dir = cortexkit_test_support::ScratchDir::new("spawn-image-identity");
5806 let script = dir.join("module.sh");
5807 std::fs::write(&script, "#!/bin/sh\nwhile :; do sleep 0.1; done\n").unwrap();
5808 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o700)).unwrap();
5809 let record_path = dir.join("live-children.json");
5810 let supervisor =
5811 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5812 .with_live_children_record(&record_path);
5813 for (program, args) in [
5814 (PathBuf::from("sleep"), vec!["60".into()]),
5815 (script, vec![]),
5816 ] {
5817 let spec = ModuleSpec {
5818 module_id: "image-identity".into(),
5819 program: program.clone(),
5820 args,
5821 env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
5822 .into_iter()
5823 .map(|key| (key.into(), dir.to_string_lossy().into_owned()))
5824 .collect(),
5825 reserved: false,
5826 reserved_prefixes: vec![],
5827 protocol: ModuleProtocol::None,
5828 overlap: Default::default(),
5829 };
5830 let module = supervisor.spawn(spec).unwrap();
5831 #[cfg(target_os = "macos")]
5832 {
5833 let deadline = Instant::now() + Duration::from_secs(5);
5836 while crate::live_children::read_record(&record_path)
5837 .unwrap()
5838 .iter()
5839 .all(|entry| entry.executable.is_none())
5840 {
5841 assert!(Instant::now() < deadline, "module image was not confirmed");
5842 tokio::time::sleep(Duration::from_millis(5)).await;
5843 }
5844 }
5845 let entry = crate::live_children::read_record(&record_path)
5846 .unwrap()
5847 .pop()
5848 .unwrap();
5849 let observed = subc_os::Process::open(entry.pid)
5850 .unwrap()
5851 .unwrap()
5852 .observe()
5853 .unwrap();
5854 let verdict = crate::live_children::identity_verdict(&entry, entry.pid, &observed);
5855 module.drain().await.unwrap();
5856 assert_eq!(
5857 verdict,
5858 crate::live_children::IdentityVerdict::Matches,
5859 "program {program:?}: recorded {entry:?}, observed {observed:?}"
5860 );
5861 }
5862 }
5863
5864 #[cfg(unix)]
5865 fn http_fixture(
5866 dir: &std::path::Path,
5867 url: &str,
5868 threshold: u32,
5869 ) -> crate::daemon_config::ConfiguredModule {
5870 let path = dir.join("subc.jsonc");
5871 std::fs::write(&path, serde_json::json!({"version": 1, "modules": {"http-process": {
5872 "program": "/bin/sleep", "args": ["60"], "protocol": "none",
5873 "env": {"XDG_DATA_HOME":dir,"XDG_RUNTIME_DIR":dir,"XDG_CONFIG_HOME":dir},
5874 "health": {"http":url,"cadence_ms":20,"deadline_ms":200,"failure_threshold":threshold},
5875 "restart": {"backoff_ms":1,"max_backoff_ms":1}, "drain_timeout_ms":10
5876 }}}).to_string()).unwrap();
5877 crate::daemon_config::load(&path)
5878 .unwrap()
5879 .unwrap()
5880 .modules
5881 .pop()
5882 .unwrap()
5883 }
5884
5885 #[cfg(unix)]
5886 async fn wait_http_health(module: &SupervisedModule, status: SupervisorHealthStatus) {
5887 timeout(Duration::from_secs(5), async {
5888 loop {
5889 if module.status().unwrap().health.status == status {
5890 break;
5891 }
5892 sleep(Duration::from_millis(5)).await;
5893 }
5894 })
5895 .await
5896 .unwrap_or_else(|_| {
5897 panic!(
5898 "expected {status:?}, got {:?}",
5899 module.status().unwrap().health
5900 )
5901 });
5902 }
5903
5904 #[cfg(unix)]
5905 #[tokio::test]
5906 async fn http_health_status_flips_ok_failing_ok() {
5907 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5908 let dir = cortexkit_test_support::ScratchDir::new("http-status-flips");
5909 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5910 let status = Arc::new(std::sync::atomic::AtomicU16::new(200));
5911 let serving_status = status.clone();
5912 let url = format!("http://{}/healthz", listener.local_addr().unwrap());
5913 let server = tokio::spawn(async move {
5914 loop {
5915 let (mut stream, _) = listener.accept().await.unwrap();
5916 let mut request = [0u8; 2048];
5917 let count = stream.read(&mut request).await.unwrap();
5918 assert!(count > 0, "a probe must send an HTTP request");
5919 let code = serving_status.load(std::sync::atomic::Ordering::SeqCst);
5920 let body = if code == 200 {
5921 "ready"
5922 } else {
5923 "{\"status\":\"unavailable\",\"error\":\"scratch failure\"}"
5924 };
5925 let response = format!("HTTP/1.1 {code} scratch\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());
5926 let _ = stream.write_all(response.as_bytes()).await;
5927 }
5928 });
5929 let configured = http_fixture(&dir, &url, 1000);
5930 let module =
5931 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5932 .supervise_configured_with_health(
5933 configured.module_spec(),
5934 true,
5935 configured.health,
5936 configured.drain_timeout_ms,
5937 configured.restart,
5938 )
5939 .unwrap();
5940 wait_http_health(&module, SupervisorHealthStatus::Ok).await;
5941 status.store(503, std::sync::atomic::Ordering::SeqCst);
5942 wait_http_health(&module, SupervisorHealthStatus::Failing).await;
5943 assert!(module
5944 .status()
5945 .unwrap()
5946 .health
5947 .detail
5948 .unwrap()
5949 .contains("scratch failure"));
5950 status.store(200, std::sync::atomic::Ordering::SeqCst);
5951 wait_http_health(&module, SupervisorHealthStatus::Ok).await;
5952 assert_eq!(module.status().unwrap().health.consecutive_failures, 0);
5953 let before = module.status().unwrap();
5954 let (spec, mut health) = module.configuration().unwrap();
5955 health.http = None;
5956 module
5957 .update_configuration(spec.clone(), health.clone(), Some(10))
5958 .await
5959 .unwrap();
5960 wait_http_health(&module, SupervisorHealthStatus::Unknown).await;
5961 health.http = Some(url);
5962 module
5963 .update_configuration(spec, health, Some(10))
5964 .await
5965 .unwrap();
5966 wait_http_health(&module, SupervisorHealthStatus::Ok).await;
5967 assert_eq!(
5968 module.status().unwrap().pid,
5969 before.pid,
5970 "changing a probe must apply live, not restart its process"
5971 );
5972 let (spec, mut health) = module.configuration().unwrap();
5973 health.failure_threshold = 2;
5974 module
5975 .update_configuration(spec, health, Some(10))
5976 .await
5977 .unwrap();
5978 status.store(503, std::sync::atomic::Ordering::SeqCst);
5979 timeout(Duration::from_secs(5), async {
5980 while module.status().unwrap().spawn_generation == before.spawn_generation {
5981 sleep(Duration::from_millis(5)).await;
5982 }
5983 })
5984 .await
5985 .expect("sustained HTTP 503 responses must trigger the health restart policy");
5986 module.drain().await.unwrap();
5987 server.abort();
5988 }
5989
5990 #[cfg(unix)]
5991 #[tokio::test]
5992 async fn http_health_no_listener_restarts_after_consecutive_failures() {
5993 let dir = cortexkit_test_support::ScratchDir::new("http-refused");
5994 let unused = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5995 let url = format!("http://{}/healthz", unused.local_addr().unwrap());
5996 drop(unused);
5997 let configured = http_fixture(&dir, &url, 2);
5998 let module =
5999 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
6000 .supervise_configured_with_health(
6001 configured.module_spec(),
6002 true,
6003 configured.health,
6004 configured.drain_timeout_ms,
6005 configured.restart,
6006 )
6007 .unwrap();
6008 let before = module.status().unwrap().spawn_generation;
6009 timeout(Duration::from_secs(5), async {
6010 loop {
6011 let status = module.status().unwrap();
6012 if status.spawn_generation > before {
6013 assert!(status.lifetime_restarts > 0);
6014 break;
6015 }
6016 sleep(Duration::from_millis(5)).await;
6017 }
6018 })
6019 .await
6020 .expect("sustained HTTP refusal must trigger the health restart policy");
6021 module.drain().await.unwrap();
6022 }
6023
6024 #[cfg(unix)]
6025 #[tokio::test]
6026 async fn http_health_timeout_honours_deadline() {
6027 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6028 let url = format!("http://{}/healthz", listener.local_addr().unwrap());
6029 let server = tokio::spawn(async move {
6030 let _held = listener.accept().await.unwrap();
6031 std::future::pending::<()>().await;
6032 });
6033 let error = timeout(
6034 Duration::from_secs(1),
6035 probe_http_health(&url, Duration::from_millis(10)),
6036 )
6037 .await
6038 .expect("the probe must enforce its own deadline")
6039 .unwrap_err();
6040 server.abort();
6041 assert!(error.to_string().contains("timed out"));
6042 }
6043
6044 #[cfg(unix)]
6045 #[tokio::test]
6046 async fn http_health_localhost_reaches_an_ipv6_only_listener() {
6047 use tokio::io::{AsyncReadExt, AsyncWriteExt};
6048 let listener = tokio::net::TcpListener::bind("[::1]:0").await.unwrap();
6049 let url = format!(
6050 "http://localhost:{}/healthz",
6051 listener.local_addr().unwrap().port()
6052 );
6053 let server = tokio::spawn(async move {
6054 let (mut stream, _) = listener.accept().await.unwrap();
6055 let mut request = [0u8; 2048];
6056 assert!(stream.read(&mut request).await.unwrap() > 0);
6057 stream
6058 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
6059 .await
6060 .unwrap();
6061 });
6062 assert_eq!(
6067 probe_http_health(&url, Duration::from_secs(10))
6068 .await
6069 .unwrap()
6070 .status,
6071 HealthStatus::Ok
6072 );
6073 server.await.unwrap();
6074 }
6075
6076 #[cfg(unix)]
6077 #[tokio::test]
6078 async fn http_health_timeout_keeps_partial_status_and_body() {
6079 use tokio::io::{AsyncReadExt, AsyncWriteExt};
6080 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6081 let url = format!("http://{}/healthz", listener.local_addr().unwrap());
6082 let server = tokio::spawn(async move {
6083 let (mut stream, _) = listener.accept().await.unwrap();
6084 let mut request = [0u8; 2048];
6085 assert!(stream.read(&mut request).await.unwrap() > 0);
6086 stream
6087 .write_all(
6088 b"HTTP/1.1 503 Unavailable\r\nContent-Length: 100\r\n\r\npartial diagnostic",
6089 )
6090 .await
6091 .unwrap();
6092 std::future::pending::<()>().await;
6093 });
6094 let error = probe_http_health(&url, Duration::from_secs(1))
6095 .await
6096 .unwrap_err()
6097 .to_string();
6098 server.abort();
6099 assert!(
6100 error.contains("timed out")
6101 && error.contains("503 Unavailable")
6102 && error.contains("partial diagnostic"),
6103 "{error}"
6104 );
6105 }
6106
6107 #[cfg(unix)]
6108 #[tokio::test]
6109 async fn http_health_diagnostic_body_is_bounded_to_200_bytes() {
6110 use tokio::io::{AsyncReadExt, AsyncWriteExt};
6111 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6112 let url = format!("http://{}/healthz", listener.local_addr().unwrap());
6113 let server = tokio::spawn(async move {
6114 let (mut stream, _) = listener.accept().await.unwrap();
6115 let mut request = [0u8; 2048];
6116 assert!(stream.read(&mut request).await.unwrap() > 0);
6117 let body = format!("{}not-in-diagnostic", "x".repeat(400));
6118 let response = format!(
6119 "HTTP/1.1 503 Unavailable\r\nContent-Length: {}\r\n\r\n{body}",
6120 body.len()
6121 );
6122 stream.write_all(response.as_bytes()).await.unwrap();
6123 });
6124 let error = probe_http_health(&url, Duration::from_secs(1))
6125 .await
6126 .unwrap_err()
6127 .to_string();
6128 server.await.unwrap();
6129 assert!(error.ends_with(&"x".repeat(200)), "{error}");
6130 assert_eq!(error.split(": ").last().unwrap().len(), 200);
6131 assert!(!error.contains("not-in-diagnostic"));
6132 }
6133
6134 #[cfg(unix)]
6135 #[tokio::test]
6136 async fn http_health_real_nats_server_monitoring() {
6137 if std::process::Command::new("nats-server")
6138 .arg("--version")
6139 .env("XDG_DATA_HOME", std::env::temp_dir())
6140 .env("XDG_RUNTIME_DIR", std::env::temp_dir())
6141 .env("XDG_CONFIG_HOME", std::env::temp_dir())
6142 .output()
6143 .is_err()
6144 {
6145 eprintln!(
6146 "skipped: http_health_real_nats_server_monitoring (nats-server is not on PATH)"
6147 );
6148 return;
6149 }
6150 let dir = cortexkit_test_support::ScratchDir::new("nats-http-monitoring");
6151 let monitor = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6152 let port = monitor.local_addr().unwrap().port();
6153 let client = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
6154 let client_port = client.local_addr().unwrap().port();
6155 let config = dir.join("server.conf");
6156 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();
6157 let mut configured = http_fixture(&dir, &format!("http://127.0.0.1:{port}/healthz"), 1000);
6158 configured.program = PathBuf::from("nats-server");
6159 configured.args = vec!["-c".into(), config.to_string_lossy().into_owned()];
6160 drop(monitor);
6161 drop(client);
6162 let module =
6163 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
6164 .supervise_configured_with_health(
6165 configured.module_spec(),
6166 true,
6167 configured.health,
6168 configured.drain_timeout_ms,
6169 configured.restart,
6170 )
6171 .unwrap();
6172 wait_http_health(&module, SupervisorHealthStatus::Ok).await;
6173 module.drain().await.unwrap();
6174 }
6175
6176 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
6177 async fn configuration_update_does_not_replace_captured_running_process_facts() {
6178 let supervisor =
6179 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
6180 let snapshot = stale_process_snapshot(ModuleState::Running, true);
6181 let initial = ModuleSpec {
6182 module_id: "rescan-preserves-spawn-facts".to_string(),
6183 program: PathBuf::from("/spawned/module"),
6184 args: Vec::new(),
6185 env: Vec::new(),
6186 reserved: false,
6187 reserved_prefixes: Vec::new(),
6188 protocol: ModuleProtocol::Subc,
6189 overlap: Default::default(),
6190 };
6191 let module = supervisor.supervised_module(
6192 initial.clone(),
6193 supervisor.runtime_config(),
6194 snapshot,
6195 None,
6196 );
6197 let before = module.status().unwrap();
6198 let mut replacement = initial;
6199 replacement.program = PathBuf::from("/rescanned/replacement-module");
6200
6201 module
6202 .update_configuration(replacement, HealthConfig::default(), None)
6203 .await
6204 .unwrap();
6205
6206 let after = module.status().unwrap();
6207 assert_eq!(after.pid, before.pid);
6208 assert_eq!(after.spawned_at_ms, before.spawned_at_ms);
6209 assert_eq!(after.spawned_from, before.spawned_from);
6210 drop(module);
6211 }
6212}
6213
6214fn unix_ms_now() -> u64 {
6215 SystemTime::now()
6216 .duration_since(UNIX_EPOCH)
6217 .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64)
6218 .unwrap_or(0)
6219}
6220
6221async fn supervise_loop(
6222 mut spec: ModuleSpec,
6223 mut runtime: SupervisorRuntimeConfig,
6224 registry: Arc<Registry>,
6225 process_liveness: Arc<SupervisorProcessLiveness>,
6226 snapshot: SharedSnapshot,
6227 mut child: Option<SupervisedChild>,
6228 mut commands: mpsc::Receiver<SupervisorCommand>,
6229) {
6230 let mut health_probe = HealthProbeRuntime::default();
6231 let mut registration_changes = match registry.subscribe_module_changes(&spec.module_id) {
6235 Ok(changes) => Some(changes),
6236 Err(err) => {
6237 warn!(module_id = %spec.module_id, error = %err, "health prober could not subscribe to registry");
6240 None
6241 }
6242 };
6243 let mut pending_respawn: Option<PendingRespawn> = None;
6247 let mut requeued: VecDeque<SupervisorCommand> = VecDeque::new();
6250 #[cfg(target_os = "linux")]
6251 let mut previous_instance_recheck = tokio::time::interval_at(
6252 Instant::now() + Duration::from_secs(5),
6253 Duration::from_secs(5),
6254 );
6255 #[cfg(target_os = "linux")]
6256 previous_instance_recheck.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
6257 loop {
6258 #[cfg(test)]
6259 {
6260 lock_snapshot(&snapshot).unwrap().actor_select = None;
6261 }
6262 #[cfg(target_os = "macos")]
6263 if let Some(active) = child.as_mut() {
6264 active.confirm_privacy_exec().await;
6265 }
6266 if let Some(scheduled) = runtime
6267 .scheduled_respawn
6268 .lock()
6269 .unwrap_or_else(|p| p.into_inner())
6270 .take()
6271 {
6272 pending_respawn = Some(scheduled);
6273 }
6274 if pending_respawn.is_some() && (child.is_some() || !respawn_still_pending(&snapshot)) {
6275 pending_respawn = None;
6276 cancel_deferred_reload(
6277 &runtime,
6278 &spec.module_id,
6279 "respawn cancelled by a supervisor command",
6280 );
6281 }
6282 if child.is_none()
6283 && pending_respawn.is_none()
6284 && lock_snapshot(&snapshot).is_ok_and(|state| state.waiting_for_previous_pid.is_none())
6285 {
6286 cancel_deferred_reload(
6287 &runtime,
6288 &spec.module_id,
6289 "respawn cancelled before a replacement was spawned",
6290 );
6291 let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| {
6292 state.respawn_pending = false;
6293 state.coalesced_restart_pending = false;
6294 if matches!(
6295 state.state,
6296 ModuleState::Restarting
6297 | ModuleState::Starting
6298 | ModuleState::Draining
6299 | ModuleState::Unresponsive
6300 ) {
6301 error!(module_id = %spec.module_id, state = ?state.state,
6302 "supervision operation ended without a child or pending respawn; marking failed so start can retry");
6303 state.state = ModuleState::Failed;
6304 clear_current_process_facts(state);
6305 }
6306 });
6307 }
6308 if let Some(command) = requeued.pop_front() {
6309 if !handle_supervisor_command(
6310 command,
6311 &mut spec,
6312 &mut runtime,
6313 ®istry,
6314 &process_liveness,
6315 &snapshot,
6316 &mut child,
6317 &mut commands,
6318 &mut requeued,
6319 )
6320 .await
6321 {
6322 return;
6323 }
6324 if child.is_some() || !respawn_still_pending(&snapshot) {
6325 pending_respawn = None;
6326 let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| {
6327 state.respawn_pending = false
6328 });
6329 }
6330 continue;
6331 }
6332 if child.is_some() {
6333 #[cfg(test)]
6334 {
6335 lock_snapshot(&snapshot).unwrap().actor_turns += 1;
6336 }
6337 health_probe.refresh_registration(&spec, &runtime, ®istry, &snapshot);
6338 let wake_after = health_probe.wake_after();
6339 let probe_sleep = async move {
6340 match wake_after {
6341 Some(delay) => sleep(delay).await,
6342 None => std::future::pending().await,
6343 }
6344 };
6345 tokio::pin!(probe_sleep);
6346 let wire_child = running_protocol(&spec, &snapshot) == ModuleProtocol::Subc;
6351 #[cfg(test)]
6352 {
6353 let registration_pending = wire_child
6354 && registration_changes
6355 .as_ref()
6356 .is_some_and(|changes| changes.has_changed().unwrap());
6357 let mut state = lock_snapshot(&snapshot).unwrap();
6358 state.actor_select = (!registration_pending).then_some(ActorSelectCheckpoint {
6359 generation: state.spawn_generation,
6360 turn: state.actor_turns,
6361 registered_connection: health_probe.registered_connection,
6362 next_probe_at: health_probe.next_probe_at,
6363 wake_after,
6364 });
6365 }
6366 let active_child = child.as_mut().expect("child checked above");
6367 tokio::select! {
6368 wait_result = active_child.wait() => {
6369 #[cfg(test)]
6370 { lock_snapshot(&snapshot).unwrap().actor_select = None; }
6371 let exit_report = match wait_result {
6380 Ok(status) => classify_reaped_child_exit(&snapshot, active_child, &status),
6381 Err(err) => {
6382 active_child.drain_stderr(&spec.module_id).await;
6383 fail_after_wait_error(
6384 &spec.module_id,
6385 &snapshot,
6386 &runtime.terminal_ring,
6387 &runtime.spawn_events,
6388 format!("failed to wait for supervised module: {err}"),
6389 );
6390 untrack_if_registration_released(
6391 &process_liveness,
6392 ®istry,
6393 &spec.module_id,
6394 &snapshot,
6395 );
6396 error!(module_id = %spec.module_id, error = %err, "failed to wait for supervised module");
6397 child = None;
6398 continue;
6399 }
6400 };
6401 active_child.drain_stderr(&spec.module_id).await;
6402
6403 let next = on_child_exit(
6404 &spec,
6405 runtime.restart_policy,
6406 ®istry,
6407 &snapshot,
6408 &runtime.terminal_ring,
6409 &runtime.spawn_events,
6410 &runtime.child_roster,
6411 exit_report,
6412 ).await;
6413 runtime.start_requester = None;
6414 runtime.pending_activity_restart = false;
6415 active_child.release_roster();
6418 match next {
6419 NextAction::Stop { registration_released } => {
6420 if registration_released {
6421 process_liveness.untrack_if_current(&spec.module_id, &snapshot);
6422 }
6423 child = None;
6424 }
6425 NextAction::Restart { schedule } => {
6426 let delay = schedule.map_or(
6427 runtime.restart_policy.delay_for_restart(0),
6428 |schedule| schedule.delay,
6429 );
6430 if let Some(schedule) = schedule {
6431 log_crash_respawn(&spec.module_id, schedule);
6432 }
6433 child = None;
6441 pending_respawn = Some(PendingRespawn { deadline: Instant::now() + delay, kind: RespawnKind::Spawn });
6442 let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| state.respawn_pending = true);
6443 }
6444 }
6445 }
6446 command = commands.recv() => {
6447 #[cfg(test)]
6448 { lock_snapshot(&snapshot).unwrap().actor_select = None; }
6449 let Some(command) = command else {
6450 return;
6451 };
6452 if !handle_supervisor_command(
6453 command,
6454 &mut spec,
6455 &mut runtime,
6456 ®istry,
6457 &process_liveness,
6458 &snapshot,
6459 &mut child,
6460 &mut commands,
6461 &mut requeued,
6462 ).await {
6463 return;
6464 }
6465 }
6466 _ = async {
6467 match registration_changes.as_mut() {
6468 Some(changes) => { let _ = changes.changed().await; }
6469 None => std::future::pending().await,
6470 }
6471 }, if wire_child => {
6472 #[cfg(test)]
6473 { lock_snapshot(&snapshot).unwrap().actor_select = None; }
6474 }
6475 _ = &mut probe_sleep => {
6476 #[cfg(test)]
6477 { lock_snapshot(&snapshot).unwrap().actor_select = None; }
6478 if health_probe.due() {
6479 run_health_probe_cycle(
6480 &spec,
6481 &runtime,
6482 ®istry,
6483 &process_liveness,
6484 &snapshot,
6485 &mut child,
6486 ).await;
6487 if child.is_none() {
6488 runtime.start_requester = None;
6489 runtime.pending_activity_restart = false;
6490 }
6491 if child.is_some() {
6492 health_probe.schedule_next(&spec, runtime.health.cadence);
6493 }
6494 }
6495 }
6496 }
6497 } else if let Some(pending) = pending_respawn {
6498 tokio::select! {
6499 _ = sleep_until(pending.deadline) => {
6500 pending_respawn = None;
6501 let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| state.respawn_pending = false);
6502 if !respawn_still_pending(&snapshot) {
6506 continue;
6507 }
6508 if runtime.child_roster.is_closed() {
6513 let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| {
6514 state.state = ModuleState::Stopped;
6515 });
6516 debug!(module_id = %spec.module_id, "crash respawn cancelled by daemon shutdown");
6517 continue;
6518 }
6519 if let Err(err) = release_dead_registration(
6520 ®istry,
6521 runtime.forwarding.as_deref(),
6522 &snapshot,
6523 &spec.module_id,
6524 ).await {
6525 fail_snapshot(&snapshot, Some(&spec.module_id), None);
6526 error!(module_id = %spec.module_id, error = %err, "registration did not release before restart");
6527 continue;
6528 }
6529
6530 if matches!(pending.kind, RespawnKind::Reload) {
6531 let reply = runtime.deferred_reload_reply.lock().unwrap_or_else(|p| p.into_inner()).take();
6532 let result = finish_reload_child(&spec, &runtime, ®istry, &process_liveness, &snapshot, &mut child).await;
6533 if result.is_ok() && runtime.pending_activity_restart {
6534 runtime.record_activity_transition("subc.module_restarted", &spec.module_id, runtime.start_requester.clone());
6535 }
6536 runtime.pending_activity_restart = false;
6537 if let Some(reply) = reply { let _ = reply.send(result); }
6538 continue;
6539 }
6540 process_liveness.track(spec.module_id.clone(), Arc::clone(&snapshot));
6541 match spawn_and_mark_running(&spec, &runtime, &snapshot) {
6542 Ok(next_child) => {
6543 child = Some(next_child);
6544 if runtime.pending_activity_restart {
6545 runtime.record_activity_transition("subc.module_restarted", &spec.module_id, runtime.start_requester.clone());
6546 runtime.pending_activity_restart = false;
6547 }
6548 debug!(module_id = %spec.module_id, "supervised module restarted after crash");
6549 }
6550 Err(err) => {
6551 fail_snapshot(&snapshot, Some(&spec.module_id), None);
6552 process_liveness.untrack_if_current(&spec.module_id, &snapshot);
6553 error!(module_id = %spec.module_id, error = %err, "failed to restart supervised module");
6554 }
6555 }
6556 }
6557 command = commands.recv() => {
6558 let Some(command) = command else {
6559 return;
6560 };
6561 if !handle_supervisor_command(
6562 command,
6563 &mut spec,
6564 &mut runtime,
6565 ®istry,
6566 &process_liveness,
6567 &snapshot,
6568 &mut child,
6569 &mut commands,
6570 &mut requeued,
6571 ).await {
6572 return;
6573 }
6574 if child.is_some() || !respawn_still_pending(&snapshot) {
6579 pending_respawn = None;
6580 let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| state.respawn_pending = false);
6581 }
6582 }
6583 }
6584 } else {
6585 #[cfg(target_os = "linux")]
6586 let command = if lock_snapshot(&snapshot)
6587 .is_ok_and(|state| state.waiting_for_previous_pid.is_some())
6588 {
6589 tokio::select! {
6590 command = commands.recv() => command,
6591 _ = previous_instance_recheck.tick() => {
6592 if runtime.child_roster.previous_instance_pid(&spec.module_id).is_none() {
6593 let enabled = lock_snapshot(&snapshot).is_ok_and(|state| state.enabled);
6594 if enabled && !runtime.child_roster.is_closed() {
6595 process_liveness.track(spec.module_id.clone(), Arc::clone(&snapshot));
6596 match spawn_and_mark_running(&spec, &runtime, &snapshot) {
6597 Ok(next_child) => child = Some(next_child),
6598 Err(error) => {
6599 fail_snapshot(&snapshot, Some(&spec.module_id), None);
6600 error!(module_id = %spec.module_id, %error, "failed to start module after its previous instance exited");
6601 }
6602 }
6603 } else {
6604 let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| {
6605 state.waiting_for_previous_pid = None;
6606 });
6607 }
6608 }
6609 continue;
6610 }
6611 }
6612 } else {
6613 commands.recv().await
6614 };
6615 #[cfg(not(target_os = "linux"))]
6616 let command = commands.recv().await;
6617 let Some(command) = command else {
6618 return;
6619 };
6620 if !handle_supervisor_command(
6621 command,
6622 &mut spec,
6623 &mut runtime,
6624 ®istry,
6625 &process_liveness,
6626 &snapshot,
6627 &mut child,
6628 &mut commands,
6629 &mut requeued,
6630 )
6631 .await
6632 {
6633 return;
6634 }
6635 }
6636 }
6637}
6638
6639fn log_crash_respawn(module_id: &str, schedule: CrashRestartSchedule) {
6640 info!(
6641 module_id,
6642 restart_in_window = schedule.restart_in_window,
6643 delay_ms = schedule.delay.as_millis() as u64,
6644 "respawning after crash"
6645 );
6646}
6647
6648fn respawn_still_pending(snapshot: &SharedSnapshot) -> bool {
6654 matches!(
6655 lock_snapshot(snapshot),
6656 Ok(state) if state.enabled && state.state == ModuleState::Restarting
6657 )
6658}
6659
6660enum NextAction {
6661 Stop {
6662 registration_released: bool,
6663 },
6664 Restart {
6665 schedule: Option<CrashRestartSchedule>,
6666 },
6667}
6668
6669#[allow(clippy::too_many_arguments)]
6670async fn handle_supervisor_command(
6671 command: SupervisorCommand,
6672 spec: &mut ModuleSpec,
6673 runtime: &mut SupervisorRuntimeConfig,
6674 registry: &Arc<Registry>,
6675 process_liveness: &SupervisorProcessLiveness,
6676 snapshot: &SharedSnapshot,
6677 child: &mut Option<SupervisedChild>,
6678 commands: &mut mpsc::Receiver<SupervisorCommand>,
6679 requeued: &mut VecDeque<SupervisorCommand>,
6680) -> bool {
6681 #[cfg(target_os = "linux")]
6682 let command = if let Some(pid) = runtime.child_roster.previous_instance_pid(&spec.module_id) {
6683 match command {
6684 SupervisorCommand::Restart { reply, .. }
6685 | SupervisorCommand::Reload { reply, .. }
6686 | SupervisorCommand::Swap { reply, .. } => {
6687 let _ = reply.send(Err(previous_instance_error(spec, pid)));
6688 return true;
6689 }
6690 SupervisorCommand::SetEnabled {
6691 enabled: true,
6692 reply,
6693 ..
6694 } => {
6695 let _ = reply.send(Err(previous_instance_error(spec, pid)));
6696 return true;
6697 }
6698 other => other,
6699 }
6700 } else {
6701 command
6702 };
6703 match command {
6704 SupervisorCommand::Drain { requester, reply } => {
6705 let result = drain_optional_child(
6708 &spec.module_id,
6709 spec.protocol,
6710 StopNotice::NotSent,
6711 registry,
6712 runtime.forwarding.as_deref(),
6713 snapshot,
6714 &runtime.terminal_ring,
6715 &runtime.spawn_events,
6716 child,
6717 runtime.drain_timeout,
6718 ModuleState::Stopped,
6719 None,
6720 )
6721 .await;
6722 let registration_released = result.is_ok();
6723 if registration_released {
6724 runtime.record_activity_transition(
6725 "subc.module_stopped",
6726 &spec.module_id,
6727 requester,
6728 );
6729 }
6730 let _ = reply.send(result);
6731 if registration_released {
6732 process_liveness.untrack_if_current(&spec.module_id, snapshot);
6733 }
6734 false
6735 }
6736 SupervisorCommand::Retire { requester, reply } => {
6737 let result = async {
6738 let stop_notice = begin_forwarding_drain_if_configured(
6739 spec,
6740 runtime,
6741 registry,
6742 snapshot,
6743 None,
6744 RouteCloseReason::Disable,
6745 )
6746 .await?;
6747 drain_optional_child(
6748 &spec.module_id,
6749 spec.protocol,
6750 stop_notice,
6751 registry,
6752 runtime.forwarding.as_deref(),
6753 snapshot,
6754 &runtime.terminal_ring,
6755 &runtime.spawn_events,
6756 child,
6757 runtime.drain_timeout,
6758 ModuleState::Stopped,
6759 None,
6760 )
6761 .await
6762 }
6763 .await;
6764 let registration_released = result.is_ok();
6765 if registration_released {
6766 runtime.record_activity_transition(
6767 "subc.module_stopped",
6768 &spec.module_id,
6769 requester,
6770 );
6771 }
6772 let _ = reply.send(result);
6773 if registration_released {
6774 process_liveness.untrack_if_current(&spec.module_id, snapshot);
6775 }
6776 false
6777 }
6778 SupervisorCommand::Restart {
6779 requester,
6780 drain_timeout_ms,
6781 received_at_generation,
6782 queued_at,
6783 reply,
6784 } => {
6785 info!(
6789 module_id = %spec.module_id,
6790 queued_ms = u64::try_from(queued_at.elapsed().as_millis()).unwrap_or(u64::MAX),
6791 "restart command dequeued"
6792 );
6793 let validation = match lock_snapshot(snapshot) {
6805 Ok(state) if !state.enabled => Err(SuperviseError::Disabled {
6806 module_id: spec.module_id.clone(),
6807 }),
6808 Ok(_) => Ok(()),
6809 Err(err) => Err(err),
6810 };
6811 let initiated = validation.is_ok();
6812 let _ = reply.send(validation);
6813 let satisfied_by_generation = if initiated && child.is_some() {
6824 lock_snapshot(snapshot).ok().and_then(|state| {
6825 (state.spawn_generation > received_at_generation
6826 && !state.configuration_updated_since_spawn)
6827 .then_some(state.spawn_generation)
6828 })
6829 } else {
6830 None
6831 };
6832 let satisfied_by_pending = initiated
6833 && child.is_none()
6834 && lock_snapshot(snapshot).ok().is_some_and(|mut state| {
6835 let pending = state.respawn_pending && !state.configuration_updated_since_spawn;
6836 if pending {
6837 state.coalesced_restart_pending = true;
6838 }
6839 pending
6840 });
6841 if satisfied_by_pending {
6842 debug!(module_id = %spec.module_id, "restart coalesced into the pending replacement");
6843 } else if let Some(generation) = satisfied_by_generation {
6844 info!(
6845 module_id = %spec.module_id,
6846 received_at_generation,
6847 "restart already satisfied by generation {generation}; not restarting again"
6848 );
6849 } else if initiated {
6850 let drain_timeout = drain_timeout_ms
6853 .map(Duration::from_millis)
6854 .unwrap_or(runtime.drain_timeout);
6855 if let Err(err) = restart_child(
6856 spec,
6857 runtime,
6858 registry,
6859 process_liveness,
6860 snapshot,
6861 child,
6862 drain_timeout,
6863 )
6864 .await
6865 {
6866 warn!(
6867 module_id = %spec.module_id,
6868 error = %err,
6869 "operator restart failed after initiation ack; module state carries the outcome"
6870 );
6871 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
6872 state.state = ModuleState::Failed;
6873 clear_current_process_facts(state);
6874 });
6875 } else {
6876 runtime.start_requester = requester;
6877 runtime.pending_activity_restart = true;
6878 }
6879 }
6880 true
6881 }
6882 SupervisorCommand::Reload { requester, reply } => {
6883 let result =
6884 reload_child(spec, runtime, registry, process_liveness, snapshot, child).await;
6885 if result.is_ok() {
6886 runtime.start_requester = requester;
6887 runtime.pending_activity_restart = true;
6888 }
6889 if result.is_ok()
6890 && runtime
6891 .scheduled_respawn
6892 .lock()
6893 .unwrap_or_else(|p| p.into_inner())
6894 .as_ref()
6895 .is_some_and(|pending| matches!(pending.kind, RespawnKind::Reload))
6896 {
6897 *runtime
6898 .deferred_reload_reply
6899 .lock()
6900 .unwrap_or_else(|p| p.into_inner()) = Some(reply);
6901 } else {
6902 let _ = reply.send(result);
6903 }
6904 true
6905 }
6906 SupervisorCommand::SetEnabled {
6907 enabled,
6908 requester,
6909 reply,
6910 } => {
6911 let previous_requester = runtime.start_requester.clone();
6912 if enabled {
6913 runtime.start_requester = requester.clone();
6914 }
6915 let result = set_child_enabled(
6916 spec,
6917 runtime,
6918 registry,
6919 process_liveness,
6920 snapshot,
6921 child,
6922 enabled,
6923 )
6924 .await;
6925 if matches!(result, Ok(true)) {
6926 runtime.pending_activity_restart = false;
6927 if enabled {
6928 runtime.start_requester = requester;
6929 } else {
6930 runtime.start_requester = None;
6931 runtime.record_activity_transition(
6932 "subc.module_disabled",
6933 &spec.module_id,
6934 requester,
6935 );
6936 }
6937 } else {
6938 runtime.start_requester = previous_requester;
6939 }
6940 let _ = reply.send(result);
6941 true
6942 }
6943 SupervisorCommand::UpdateConfiguration {
6944 spec: next_spec,
6945 health,
6946 drain_timeout_ms,
6947 reply,
6948 } => {
6949 if let Some(handle) = &runtime.supervisor_handle {
6950 handle.apply_identity_configuration(&next_spec);
6951 }
6952 *spec = next_spec;
6953 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
6954 state.configuration_updated_since_spawn = true;
6955 });
6956 let health_changed = runtime.health != health;
6957 runtime.health = health;
6958 if health_changed {
6961 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
6962 state.health = ModuleHealthStatus::default();
6963 });
6964 }
6965 runtime.drain_timeout = drain_timeout_ms
6966 .map(Duration::from_millis)
6967 .unwrap_or(runtime.default_drain_timeout);
6968 *runtime
6969 .effective_drain_timeout
6970 .lock()
6971 .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.drain_timeout;
6972 let _ = reply.send(());
6973 true
6974 }
6975 SupervisorCommand::Swap {
6976 requester,
6977 ready_timeout,
6978 reply,
6979 } => {
6980 let mut swap_runtime = runtime.clone();
6981 swap_runtime.start_requester = requester;
6982 let before = lock_snapshot(snapshot)
6983 .ok()
6984 .map(|state| state.spawn_generation);
6985 let end = swap::run_swap(
6986 spec,
6987 &swap_runtime,
6988 registry,
6989 process_liveness,
6990 snapshot,
6991 child,
6992 commands,
6993 ready_timeout.unwrap_or(DEFAULT_SWAP_READY_TIMEOUT),
6994 reply,
6995 )
6996 .await;
6997 if before
6998 != lock_snapshot(snapshot)
6999 .ok()
7000 .map(|state| state.spawn_generation)
7001 {
7002 runtime.start_requester = swap_runtime.start_requester;
7003 }
7004 requeued.extend(end.requeue);
7005 true
7006 }
7007 }
7008}
7009
7010async fn restart_child(
7011 spec: &ModuleSpec,
7012 runtime: &SupervisorRuntimeConfig,
7013 registry: &Registry,
7014 process_liveness: &SupervisorProcessLiveness,
7015 snapshot: &SharedSnapshot,
7016 child: &mut Option<SupervisedChild>,
7017 drain_timeout: Duration,
7018) -> Result<(), SuperviseError> {
7019 if !lock_snapshot(snapshot)?.enabled {
7021 return Err(SuperviseError::Disabled {
7022 module_id: spec.module_id.clone(),
7023 });
7024 }
7025 let stop_notice = begin_forwarding_drain_with_timeout(
7026 spec,
7027 runtime,
7028 registry,
7029 snapshot,
7030 None,
7031 RouteCloseReason::Restart,
7032 drain_timeout,
7033 )
7034 .await?;
7035
7036 if child.is_some() {
7037 drain_optional_child(
7038 &spec.module_id,
7039 spec.protocol,
7040 stop_notice,
7041 registry,
7042 runtime.forwarding.as_deref(),
7043 snapshot,
7044 &runtime.terminal_ring,
7045 &runtime.spawn_events,
7046 child,
7047 drain_timeout,
7048 ModuleState::Restarting,
7049 Some(true),
7050 )
7051 .await?;
7052 } else {
7053 update_snapshot(snapshot, Some(&spec.module_id), |state| {
7054 state.enabled = true;
7055 state.state = ModuleState::Restarting;
7056 clear_current_process_facts(state);
7057 })?;
7058 release_dead_registration(
7059 registry,
7060 runtime.forwarding.as_deref(),
7061 snapshot,
7062 &spec.module_id,
7063 )
7064 .await?;
7065 }
7066
7067 reset_restart_count(snapshot, &spec.module_id)?;
7068 process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
7069 schedule_respawn(
7070 runtime,
7071 snapshot,
7072 &spec.module_id,
7073 runtime.restart_policy.backoff,
7074 RespawnKind::Spawn,
7075 )
7076}
7077
7078async fn reload_child(
7079 spec: &ModuleSpec,
7080 runtime: &SupervisorRuntimeConfig,
7081 registry: &Registry,
7082 process_liveness: &SupervisorProcessLiveness,
7083 snapshot: &SharedSnapshot,
7084 child: &mut Option<SupervisedChild>,
7085) -> Result<(), SuperviseError> {
7086 if !lock_snapshot(snapshot)?.enabled {
7088 return Err(SuperviseError::Disabled {
7089 module_id: spec.module_id.clone(),
7090 });
7091 }
7092 let stop_notice = begin_forwarding_drain(
7093 spec,
7094 runtime,
7095 registry,
7096 snapshot,
7097 Some(true),
7098 RouteCloseReason::Reload,
7099 )
7100 .await?;
7101
7102 if child.is_some() {
7103 drain_optional_child(
7104 &spec.module_id,
7105 spec.protocol,
7106 stop_notice,
7107 registry,
7108 runtime.forwarding.as_deref(),
7109 snapshot,
7110 &runtime.terminal_ring,
7111 &runtime.spawn_events,
7112 child,
7113 runtime.drain_timeout,
7114 ModuleState::Restarting,
7115 Some(true),
7116 )
7117 .await?;
7118 } else {
7119 update_snapshot(snapshot, Some(&spec.module_id), |state| {
7120 state.enabled = true;
7121 state.state = ModuleState::Restarting;
7122 clear_current_process_facts(state);
7123 })?;
7124 release_dead_registration(
7125 registry,
7126 runtime.forwarding.as_deref(),
7127 snapshot,
7128 &spec.module_id,
7129 )
7130 .await?;
7131 }
7132
7133 reset_restart_count(snapshot, &spec.module_id)?;
7134 process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
7135 schedule_respawn(
7136 runtime,
7137 snapshot,
7138 &spec.module_id,
7139 runtime.restart_policy.backoff,
7140 RespawnKind::Reload,
7141 )
7142}
7143
7144async fn finish_reload_child(
7145 spec: &ModuleSpec,
7146 runtime: &SupervisorRuntimeConfig,
7147 registry: &Registry,
7148 process_liveness: &SupervisorProcessLiveness,
7149 snapshot: &SharedSnapshot,
7150 child: &mut Option<SupervisedChild>,
7151) -> Result<(), SuperviseError> {
7152 process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
7153 let next_child = match spawn_and_mark_running(spec, runtime, snapshot) {
7154 Ok(next_child) => next_child,
7155 Err(err) => {
7156 return handle_reload_spawn_failure(
7157 spec,
7158 runtime,
7159 process_liveness,
7160 snapshot,
7161 child,
7162 format!("new child failed to spawn: {err}"),
7163 )
7164 .await;
7165 }
7166 };
7167 *child = Some(next_child);
7168
7169 let wait_outcome = {
7170 let active_child = child.as_mut().expect("new reload child was just stored");
7171 wait_for_registration_after_reload(
7172 registry,
7173 &spec.module_id,
7174 snapshot,
7175 active_child,
7176 REGISTRY_RELEASE_TIMEOUT,
7177 )
7178 .await?
7179 };
7180
7181 match wait_outcome {
7182 RegistrationWaitOutcome::Registered => {
7183 debug!(module_id = %spec.module_id, "supervised module reloaded and registered");
7184 Ok(())
7185 }
7186 RegistrationWaitOutcome::Exited(exit_report) => {
7187 if let Some(active_child) = child.as_mut() {
7188 active_child.drain_stderr(&spec.module_id).await;
7189 }
7190 let mut exited_child = child.take().expect("exited reload child is still stored");
7193 #[cfg(test)]
7194 if let Some(gate) = &runtime.test_reload_exit_record_gate {
7195 gate.reached.notify_one();
7196 gate.resume.notified().await;
7197 }
7198 let result = handle_reload_child_registration_failure(
7199 spec,
7200 runtime,
7201 registry,
7202 process_liveness,
7203 snapshot,
7204 child,
7205 ReloadRegistrationFailure {
7206 exit_report: registration_failure_exit_report(exit_report),
7207 reason: exited_child
7208 .spawn_failure
7209 .clone()
7210 .unwrap_or_else(|| "new child exited before registering".to_string()),
7211 },
7212 )
7213 .await;
7214 exited_child.release_roster();
7215 result
7216 }
7217 RegistrationWaitOutcome::TimedOut => {
7218 let mut timed_out_child = child
7219 .take()
7220 .expect("timed-out reload child is still running");
7221 timed_out_child
7222 .start_kill()
7223 .map_err(|source| SuperviseError::Kill {
7224 module_id: spec.module_id.clone(),
7225 source,
7226 })?;
7227 let status = timed_out_child
7228 .wait()
7229 .await
7230 .map_err(|source| SuperviseError::Wait {
7231 module_id: spec.module_id.clone(),
7232 source,
7233 })?;
7234 timed_out_child.drain_stderr(&spec.module_id).await;
7235 handle_reload_child_registration_failure(
7236 spec,
7237 runtime,
7238 registry,
7239 process_liveness,
7240 snapshot,
7241 child,
7242 ReloadRegistrationFailure {
7243 exit_report: registration_failure_exit_report(classify_reaped_child_exit(
7244 snapshot,
7245 &timed_out_child,
7246 &status,
7247 )),
7248 reason: format!(
7249 "new child did not register within {:?}",
7250 REGISTRY_RELEASE_TIMEOUT
7251 ),
7252 },
7253 )
7254 .await
7255 }
7256 }
7257}
7258
7259async fn set_child_enabled(
7260 spec: &ModuleSpec,
7261 runtime: &SupervisorRuntimeConfig,
7262 registry: &Registry,
7263 process_liveness: &SupervisorProcessLiveness,
7264 snapshot: &SharedSnapshot,
7265 child: &mut Option<SupervisedChild>,
7266 enabled: bool,
7267) -> Result<bool, SuperviseError> {
7268 let (current_enabled, current_state, respawn_pending) = {
7269 let state = lock_snapshot(snapshot)?;
7270 (state.enabled, state.state, state.respawn_pending)
7271 };
7272 let revive_terminal = enabled
7280 && current_enabled
7281 && child.is_none()
7282 && (matches!(current_state, ModuleState::Failed | ModuleState::Stopped)
7283 || (current_state == ModuleState::Restarting && !respawn_pending));
7284 if current_enabled == enabled && !revive_terminal {
7285 return Ok(false);
7286 }
7287
7288 if enabled {
7289 update_snapshot(snapshot, Some(&spec.module_id), |state| {
7290 state.enabled = true;
7291 state.state = ModuleState::Starting;
7292 clear_current_process_facts(state);
7293 })?;
7294 #[cfg(test)]
7295 if runtime.test_seed_stale_facts_before_enable_spawn {
7296 update_snapshot(snapshot, Some(&spec.module_id), |state| {
7297 state.process_alive = true;
7298 state.pid = Some(41);
7299 state.spawned_at_ms = Some(42);
7300 state.spawned_from = Some(PathBuf::from("/spawned/module"));
7301 state.spawned_file_identity = Some(SpawnedFileIdentity {
7302 device: 43,
7303 inode: 44,
7304 #[cfg(windows)]
7305 windows_image: None,
7306 });
7307 })?;
7308 }
7309 release_dead_registration(
7310 registry,
7311 runtime.forwarding.as_deref(),
7312 snapshot,
7313 &spec.module_id,
7314 )
7315 .await?;
7316 reset_restart_count(snapshot, &spec.module_id)?;
7317 process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
7318 let next_child = match spawn_and_mark_running(spec, runtime, snapshot) {
7319 Ok(next_child) => next_child,
7320 Err(err) => {
7321 if let Err(state_err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
7322 state.state = ModuleState::Failed;
7323 clear_current_process_facts(state);
7324 }) {
7325 error!(module_id = %spec.module_id, error = %state_err, "failed to record enable spawn failure");
7326 }
7327 process_liveness.untrack_if_current(&spec.module_id, snapshot);
7328 return Err(err);
7329 }
7330 };
7331 *child = Some(next_child);
7332 debug!(module_id = %spec.module_id, "supervised module enabled");
7333 Ok(true)
7334 } else {
7335 let stop_notice = begin_forwarding_drain_if_configured(
7336 spec,
7337 runtime,
7338 registry,
7339 snapshot,
7340 Some(false),
7341 RouteCloseReason::Disable,
7342 )
7343 .await?;
7344 drain_optional_child(
7345 &spec.module_id,
7346 spec.protocol,
7347 stop_notice,
7348 registry,
7349 runtime.forwarding.as_deref(),
7350 snapshot,
7351 &runtime.terminal_ring,
7352 &runtime.spawn_events,
7353 child,
7354 runtime.drain_timeout,
7355 ModuleState::Disabled,
7356 Some(false),
7357 )
7358 .await?;
7359 debug!(module_id = %spec.module_id, "supervised module disabled");
7360 Ok(true)
7361 }
7362}
7363
7364#[allow(clippy::too_many_arguments)]
7365async fn on_child_exit(
7366 spec: &ModuleSpec,
7367 policy: RestartPolicy,
7368 registry: &Registry,
7369 snapshot: &SharedSnapshot,
7370 terminal_ring: &Arc<Mutex<TerminalRing>>,
7371 spawn_events: &SpawnEventFeed,
7372 roster: &ChildRoster,
7373 exit_report: ExitReport,
7374) -> NextAction {
7375 if roster.is_closed() {
7381 return on_child_exit_during_daemon_shutdown(
7382 spec,
7383 registry,
7384 snapshot,
7385 terminal_ring,
7386 spawn_events,
7387 exit_report,
7388 )
7389 .await;
7390 }
7391 let unrequested_clean_exit_of_protocol_none = exit_report.kind == ExitKind::Clean
7406 && running_protocol(spec, snapshot) == ModuleProtocol::None;
7407 match exit_report.kind {
7408 ExitKind::Clean if !unrequested_clean_exit_of_protocol_none => {
7409 info!(
7410 module_id = %spec.module_id,
7411 exit_code = ?exit_report.code,
7412 exit_signal = ?exit_report.signal,
7413 "supervised module exited cleanly"
7414 );
7415 if let Err(err) = publish_after_terminal_record(
7416 &spec.module_id,
7417 || {
7418 record_terminal(
7419 &spec.module_id,
7420 terminal_ring,
7421 spawn_events,
7422 &exit_report,
7423 TerminalDisposition::Stopped,
7424 )
7425 },
7426 || {
7427 update_snapshot(snapshot, Some(&spec.module_id), |state| {
7428 state.state = ModuleState::Stopped;
7429 clear_current_process_facts(state);
7430 state.last_exit = Some(exit_report.clone());
7431 })
7432 },
7433 ) {
7434 error!(module_id = %spec.module_id, error = %err, "failed to publish clean module exit");
7435 }
7436 let registration_released = match wait_for_registration_release(
7437 registry,
7438 &spec.module_id,
7439 REGISTRY_RELEASE_TIMEOUT,
7440 )
7441 .await
7442 {
7443 Ok(()) => true,
7444 Err(err) => {
7445 warn!(module_id = %spec.module_id, error = %err, "registration still active after clean exit");
7446 false
7447 }
7448 };
7449 NextAction::Stop {
7450 registration_released,
7451 }
7452 }
7453 ExitKind::Clean | ExitKind::Crash => {
7454 if unrequested_clean_exit_of_protocol_none {
7455 warn!(
7456 module_id = %spec.module_id,
7457 exit_code = ?exit_report.code,
7458 exit_signal = ?exit_report.signal,
7459 "protocol-none module exited cleanly without a stop request; handling it as a crash"
7460 );
7461 } else {
7462 warn!(
7463 module_id = %spec.module_id,
7464 exit_code = ?exit_report.code,
7465 exit_signal = ?exit_report.signal,
7466 "supervised module exited abnormally (crash)"
7467 );
7468 }
7469 let mut restart_schedule = None;
7470 let mut disposition = TerminalDisposition::Disabled;
7471 let mut final_state = ModuleState::Disabled;
7472 let mut disposition_detail = lock_snapshot(snapshot)
7476 .ok()
7477 .and_then(|mut state| state.spawn_failure.take());
7478 let now = Instant::now();
7479 if let Err(err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
7480 if state.enabled {
7481 if let Some(schedule) = state.next_crash_restart(&policy, now) {
7482 state.respawn_pending = true;
7488 final_state = ModuleState::Restarting;
7489 restart_schedule = Some(schedule);
7490 disposition = TerminalDisposition::Restarting;
7491 } else {
7492 final_state = ModuleState::Failed;
7493 disposition = TerminalDisposition::Failed;
7494 let budget = policy.budget_exhausted_detail();
7495 disposition_detail =
7496 Some(disposition_detail.take().map_or_else(
7497 || budget.clone(),
7498 |cause| format!("{cause}; {budget}"),
7499 ));
7500 }
7501 } else {
7502 disposition = TerminalDisposition::Disabled;
7503 }
7504 }) {
7505 error!(module_id = %spec.module_id, error = %err, "failed to record crashed module exit");
7506 return NextAction::Stop {
7507 registration_released: false,
7508 };
7509 }
7510 if disposition == TerminalDisposition::Failed {
7511 error!(
7516 module_id = %spec.module_id,
7517 max_restarts = policy.max_restarts,
7518 window_secs = policy.window.as_secs(),
7519 "module stopped: {}",
7520 policy.budget_exhausted_detail()
7521 );
7522 }
7523 if let Err(err) = publish_after_terminal_record(
7524 &spec.module_id,
7525 || {
7526 record_terminal_with_detail(
7527 &spec.module_id,
7528 terminal_ring,
7529 spawn_events,
7530 &exit_report,
7531 disposition,
7532 disposition_detail,
7533 )
7534 },
7535 || {
7536 update_snapshot(snapshot, Some(&spec.module_id), |state| {
7537 state.state = final_state;
7538 clear_current_process_facts(state);
7539 state.last_exit = Some(exit_report.clone());
7540 })
7541 },
7542 ) {
7543 error!(module_id = %spec.module_id, error = %err, "failed to publish crashed module exit");
7544 }
7545
7546 if let Some(schedule) = restart_schedule {
7547 NextAction::Restart {
7548 schedule: Some(schedule),
7549 }
7550 } else {
7551 let registration_released = match wait_for_registration_release(
7552 registry,
7553 &spec.module_id,
7554 REGISTRY_RELEASE_TIMEOUT,
7555 )
7556 .await
7557 {
7558 Ok(()) => true,
7559 Err(err) => {
7560 warn!(module_id = %spec.module_id, error = %err, "registration still active after failed module");
7561 false
7562 }
7563 };
7564 NextAction::Stop {
7565 registration_released,
7566 }
7567 }
7568 }
7569 ExitKind::DeliberateSeverance => {
7570 warn!(
7571 module_id = %spec.module_id,
7572 exit_code = ?exit_report.code,
7573 exit_signal = ?exit_report.signal,
7574 "supervised module exited after deliberate connection severance"
7575 );
7576 let should_restart = match lock_snapshot(snapshot) {
7577 Ok(state) => state.enabled,
7578 Err(err) => {
7579 error!(module_id = %spec.module_id, error = %err, "failed to read deliberately severed module state");
7580 return NextAction::Stop {
7581 registration_released: false,
7582 };
7583 }
7584 };
7585 let final_state = if should_restart {
7586 ModuleState::Restarting
7587 } else {
7588 ModuleState::Disabled
7589 };
7590 if let Err(err) = publish_after_terminal_record(
7591 &spec.module_id,
7592 || {
7593 record_terminal(
7594 &spec.module_id,
7595 terminal_ring,
7596 spawn_events,
7597 &exit_report,
7598 terminal_disposition(final_state),
7599 )
7600 },
7601 || {
7602 update_snapshot(snapshot, Some(&spec.module_id), |state| {
7603 state.state = final_state;
7604 clear_current_process_facts(state);
7605 state.last_exit = Some(exit_report.clone());
7606 state.lifetime_restarts += 1;
7607 })
7608 },
7609 ) {
7610 error!(module_id = %spec.module_id, error = %err, "failed to publish deliberately severed module exit");
7611 return NextAction::Stop {
7612 registration_released: false,
7613 };
7614 }
7615
7616 if should_restart {
7617 NextAction::Restart { schedule: None }
7618 } else {
7619 let registration_released = match wait_for_registration_release(
7620 registry,
7621 &spec.module_id,
7622 REGISTRY_RELEASE_TIMEOUT,
7623 )
7624 .await
7625 {
7626 Ok(()) => true,
7627 Err(err) => {
7628 warn!(module_id = %spec.module_id, error = %err, "registration still active after deliberately severed module exit");
7629 false
7630 }
7631 };
7632 NextAction::Stop {
7633 registration_released,
7634 }
7635 }
7636 }
7637 }
7638}
7639
7640async fn on_child_exit_during_daemon_shutdown(
7641 spec: &ModuleSpec,
7642 registry: &Registry,
7643 snapshot: &SharedSnapshot,
7644 terminal_ring: &Arc<Mutex<TerminalRing>>,
7645 spawn_events: &SpawnEventFeed,
7646 exit_report: ExitReport,
7647) -> NextAction {
7648 info!(
7649 module_id = %spec.module_id,
7650 exit_code = ?exit_report.code,
7651 exit_signal = ?exit_report.signal,
7652 exit_kind = ?exit_report.kind,
7653 "supervised module exited during daemon shutdown; not restarting it"
7654 );
7655 if let Err(err) = publish_after_terminal_record(
7656 &spec.module_id,
7657 || {
7658 record_terminal(
7659 &spec.module_id,
7660 terminal_ring,
7661 spawn_events,
7662 &exit_report,
7663 TerminalDisposition::DaemonShutdown,
7664 )
7665 },
7666 || {
7667 update_snapshot(snapshot, Some(&spec.module_id), |state| {
7668 state.state = ModuleState::Stopped;
7669 clear_current_process_facts(state);
7670 state.last_exit = Some(exit_report.clone());
7671 })
7672 },
7673 ) {
7674 error!(module_id = %spec.module_id, error = %err, "failed to publish module exit during daemon shutdown");
7675 }
7676 let registration_released =
7677 wait_for_registration_release(registry, &spec.module_id, REGISTRY_RELEASE_TIMEOUT)
7678 .await
7679 .is_ok();
7680 NextAction::Stop {
7681 registration_released,
7682 }
7683}
7684
7685fn publish_after_terminal_record<T>(
7690 module_id: &str,
7691 record: impl FnOnce(),
7692 publish: impl FnOnce() -> T,
7693) -> T {
7694 let recorded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(record));
7695 let published = publish();
7696 if let Err(panic) = recorded {
7697 error!(
7698 module_id,
7699 "terminal recording panicked; published final state before resuming unwind"
7700 );
7701 std::panic::resume_unwind(panic);
7702 }
7703 published
7704}
7705
7706fn fail_after_wait_error(
7707 module_id: &str,
7708 snapshot: &SharedSnapshot,
7709 terminal_ring: &Arc<Mutex<TerminalRing>>,
7710 spawn_events: &SpawnEventFeed,
7711 detail: String,
7712) {
7713 let report = wait_error_exit_report();
7714 publish_after_terminal_record(
7715 module_id,
7716 || {
7717 record_terminal_with_detail(
7718 module_id,
7719 terminal_ring,
7720 spawn_events,
7721 &report,
7722 TerminalDisposition::Failed,
7723 Some(detail),
7724 )
7725 },
7726 || fail_snapshot(snapshot, Some(module_id), Some(report.clone())),
7727 );
7728}
7729
7730#[cfg(test)]
7731fn record_wait_error_terminal(
7732 module_id: &str,
7733 terminal_ring: &Arc<Mutex<TerminalRing>>,
7734 spawn_events: &SpawnEventFeed,
7735) {
7736 record_terminal(
7737 module_id,
7738 terminal_ring,
7739 spawn_events,
7740 &wait_error_exit_report(),
7741 TerminalDisposition::Failed,
7742 );
7743}
7744
7745fn record_terminal(
7746 module_id: &str,
7747 terminal_ring: &Arc<Mutex<TerminalRing>>,
7748 spawn_events: &SpawnEventFeed,
7749 exit_report: &ExitReport,
7750 disposition: TerminalDisposition,
7751) {
7752 record_terminal_with_detail(
7753 module_id,
7754 terminal_ring,
7755 spawn_events,
7756 exit_report,
7757 disposition,
7758 None,
7759 );
7760}
7761
7762fn durable_terminal_history_of(
7766 terminal_ring: &Mutex<TerminalRing>,
7767 module_id: &str,
7768) -> subc_control::TerminalHistory {
7769 let read = terminal_ring
7770 .lock()
7771 .unwrap_or_else(|p| p.into_inner())
7772 .capture_durable_history();
7773 read.read(module_id)
7774}
7775
7776fn record_terminal_with_detail(
7777 module_id: &str,
7778 terminal_ring: &Arc<Mutex<TerminalRing>>,
7779 spawn_events: &SpawnEventFeed,
7780 exit_report: &ExitReport,
7781 disposition: TerminalDisposition,
7782 disposition_detail: Option<String>,
7783) {
7784 #[cfg(test)]
7785 terminal_history_tests::before_terminal_record(spawn_events);
7786 spawn_events.emit_exited(module_id, exit_report.code, exit_report.signal);
7787 let record = TerminalRecord {
7788 exit_code: exit_report.code,
7789 exit_signal: exit_report.signal,
7790 at_ms: exit_report.at_ms,
7791 disposition,
7792 exit_kind: exit_report.kind.into(),
7793 disposition_detail,
7794 };
7795 terminal_ring
7796 .lock()
7797 .unwrap_or_else(|poisoned| poisoned.into_inner())
7798 .record_exit(module_id, record);
7799}
7800
7801fn untrack_if_registration_released(
7802 process_liveness: &SupervisorProcessLiveness,
7803 registry: &Registry,
7804 module_id: &str,
7805 snapshot: &SharedSnapshot,
7806) {
7807 match registry.get_module(module_id) {
7808 Ok(None) => process_liveness.untrack_if_current(module_id, snapshot),
7809 Ok(Some(_)) => {}
7810 Err(err) => {
7811 warn!(module_id, error = %err, "could not determine whether supervisor liveness can be untracked");
7812 }
7813 }
7814}
7815
7816#[cfg(test)]
7830fn apply_wire_spawn_args(
7831 command: &mut Command,
7832 spec: &ModuleSpec,
7833 connection_file_path: Option<&std::path::Path>,
7834 handle: Option<&SupervisorHandle>,
7835) -> Result<Option<NonceHandoff>, SuperviseError> {
7836 apply_wire_spawn_args_for_role(
7837 command,
7838 spec,
7839 connection_file_path,
7840 handle,
7841 SpawnRole::Plain,
7842 )
7843}
7844
7845#[cfg(unix)]
7850type NonceHandoff = subc_os::LaunchNonceHandoff;
7851#[cfg(windows)]
7852type NonceHandoff = subc_os::LaunchNoncePipeHandoff;
7853#[cfg(not(any(unix, windows)))]
7854type NonceHandoff = std::convert::Infallible;
7855
7856fn apply_wire_spawn_args_for_role(
7877 command: &mut Command,
7878 spec: &ModuleSpec,
7879 connection_file_path: Option<&std::path::Path>,
7880 handle: Option<&SupervisorHandle>,
7881 role: SpawnRole,
7882) -> Result<Option<NonceHandoff>, SuperviseError> {
7883 command.env(SUBC_MODULE_ID_ENV, &spec.module_id);
7884 command.env_remove(subc_os::LAUNCH_NONCE_FD_ENV);
7889 #[cfg(windows)]
7890 {
7891 command.env_remove(subc_os::LAUNCH_NONCE_PIPE_ENV);
7892 command.env_remove(subc_os::LAUNCH_NONCE_PIPE_FALLBACK_ENV);
7893 }
7894 command.env_remove(SUBC_LAUNCH_NONCE_ENV);
7896 if spec.protocol == ModuleProtocol::None {
7897 return Ok(None);
7898 }
7899 if let Some(connection_file_path) = connection_file_path {
7900 command.arg(SUBC_ARG).arg(connection_file_path);
7901 }
7902
7903 let nonce = generate_launch_nonce()?;
7907 if let Some(handle) = handle {
7908 match role {
7909 SpawnRole::Plain => {
7910 handle.set_spawn_nonce(&spec.module_id, nonce.clone());
7911 if spec.reserved {
7912 handle.set_reserved_nonce(&spec.module_id, nonce.clone());
7913 }
7914 }
7915 SpawnRole::SwapCandidate => handle.open_swap(&spec.module_id, nonce.clone()),
7916 }
7917 }
7918 #[cfg(unix)]
7919 let handoff = {
7920 let handoff =
7921 subc_os::LaunchNonceHandoff::new(&nonce).map_err(|source| SuperviseError::Spawn {
7922 program: spec.program.clone(),
7923 source,
7924 cgroup_path: None,
7925 })?;
7926 command.env(subc_os::LAUNCH_NONCE_FD_ENV, handoff.fd_env_value());
7927 Some(handoff)
7928 };
7929 #[cfg(windows)]
7930 let handoff = prepare_windows_nonce_pipe(command, spec, &nonce);
7931 #[cfg(not(any(unix, windows)))]
7932 let handoff = None;
7933 #[cfg(not(unix))]
7937 command.env(SUBC_LAUNCH_NONCE_ENV, nonce);
7938 Ok(handoff)
7939}
7940
7941fn apply_child_env(command: &mut Command, spec: &ModuleSpec) {
7942 command.env_remove(CK_LOG_ENV);
7943 command.env_remove(SUBC_SPAWN_ROLE_ENV);
7950 for (key, value) in &spec.env {
7951 if matches!(
7955 key.as_str(),
7956 CAPTURE_MAX_FILE_MB_ENV | CAPTURE_KEEP_ENV | CAPTURE_MAX_AGE_DAYS_ENV
7957 ) || key == SUBC_SPAWN_ROLE_ENV
7958 {
7959 continue;
7960 }
7961 command.env(key, value);
7962 }
7963}
7964
7965#[cfg(windows)]
7966fn prepare_windows_nonce_pipe(
7967 command: &mut Command,
7968 spec: &ModuleSpec,
7969 nonce: &str,
7970) -> Option<NonceHandoff> {
7971 match subc_os::LaunchNoncePipeHandoff::new(nonce) {
7972 Ok(handoff) => {
7973 command.env(subc_os::LAUNCH_NONCE_PIPE_ENV, handoff.name());
7974 command.env(subc_os::LAUNCH_NONCE_PIPE_FALLBACK_ENV, "env");
7979 Some(handoff)
7980 }
7981 Err(error) => {
7982 warn!(module_id = %spec.module_id, %error, "could not prepare launch nonce pipe; retaining the environment handoff");
7983 None
7984 }
7985 }
7986}
7987
7988#[cfg(windows)]
7989fn start_windows_nonce_pipe(
7990 handoff: Option<NonceHandoff>,
7991 pid: u32,
7992 spec: &ModuleSpec,
7993) -> Option<subc_os::LaunchNoncePipeDelivery> {
7994 handoff.and_then(|handoff| match handoff.serve(pid, REGISTRY_RELEASE_TIMEOUT) {
7998 Ok(delivery) => Some(delivery),
7999 Err(error) => {
8000 warn!(module_id = %spec.module_id, %error, "could not serve launch nonce pipe; retaining the environment handoff");
8001 None
8002 }
8003 })
8004}
8005
8006fn observed_launch_nonce_source(snapshot: &SupervisorSnapshot) -> Option<String> {
8007 #[cfg(windows)]
8008 if snapshot.process_alive && snapshot.spawned_protocol == Some(ModuleProtocol::Subc) {
8009 return Some(
8010 if snapshot
8011 .pipe_consumed
8012 .as_ref()
8013 .is_some_and(|consumed| consumed.get().is_some())
8014 {
8015 "pipe"
8016 } else {
8017 "env"
8018 }
8019 .to_owned(),
8020 );
8021 }
8022 let _ = snapshot;
8023 None
8024}
8025
8026#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8029enum SpawnRole {
8030 Plain,
8031 SwapCandidate,
8032}
8033
8034fn apply_spawn_role(command: &mut Command, role: SpawnRole) {
8037 if role == SpawnRole::SwapCandidate {
8038 command.env(SUBC_SPAWN_ROLE_ENV, SPAWN_ROLE_SWAP_CANDIDATE);
8039 }
8040}
8041
8042#[cfg(target_os = "linux")]
8043fn previous_instance_error(spec: &ModuleSpec, pid: u32) -> SuperviseError {
8044 SuperviseError::Spawn {
8045 program: spec.program.clone(),
8046 source: io::Error::new(io::ErrorKind::WouldBlock, format!(
8047 "waiting: previous instance (pid {pid}) is still running but hidden; it cannot be identified"
8048 )),
8049 cgroup_path: None,
8050 }
8051}
8052
8053fn spawn_child(
8054 spec: &ModuleSpec,
8055 connection_file_path: Option<&std::path::Path>,
8056 handle: Option<&SupervisorHandle>,
8057 ring: &Arc<Mutex<StderrRing>>,
8058 capture_logs_dir: Option<&std::path::Path>,
8059 roster: &ChildRoster,
8060 #[cfg(target_os = "linux")] cgroup_placement: Option<&subc_cgroup::Placement>,
8061) -> Result<SupervisedChild, SuperviseError> {
8062 spawn_child_in_slot(
8063 spec,
8064 connection_file_path,
8065 handle,
8066 ring,
8067 capture_logs_dir,
8068 roster,
8069 #[cfg(target_os = "linux")]
8070 cgroup_placement,
8071 SpawnRole::Plain,
8072 false,
8073 )
8074}
8075
8076#[allow(clippy::too_many_arguments)]
8089fn spawn_child_in_slot(
8090 spec: &ModuleSpec,
8091 connection_file_path: Option<&std::path::Path>,
8092 handle: Option<&SupervisorHandle>,
8093 ring: &Arc<Mutex<StderrRing>>,
8094 capture_logs_dir: Option<&std::path::Path>,
8095 roster: &ChildRoster,
8096 #[cfg(target_os = "linux")] cgroup_placement: Option<&subc_cgroup::Placement>,
8097 role: SpawnRole,
8098 alternate_slot: bool,
8099) -> Result<SupervisedChild, SuperviseError> {
8100 #[cfg(target_os = "linux")]
8101 if let Some(pid) = roster.previous_instance_pid(&spec.module_id) {
8102 return Err(previous_instance_error(spec, pid));
8103 }
8104 if roster.is_closed() {
8105 return Err(SuperviseError::Spawn {
8106 program: spec.program.clone(),
8107 source: io::Error::other("the daemon is shutting down; not starting a new process"),
8108 cgroup_path: None,
8109 });
8110 }
8111 #[cfg(target_os = "linux")]
8112 let cgroup_name = {
8113 if cgroup_placement.is_none() {
8118 swap::cgroup_name(&spec.module_id, alternate_slot)
8119 } else {
8120 let nonce = generate_launch_nonce()?;
8121 let suffix = u128::from_str_radix(&nonce[..32], 16).expect("hex launch nonce");
8122 let mut end = spec.module_id.len().min(64);
8125 while !spec.module_id.is_char_boundary(end) {
8126 end -= 1;
8127 }
8128 format!(
8129 "{}_{suffix}",
8130 swap::cgroup_name(&spec.module_id[..end], alternate_slot)
8131 )
8132 }
8133 };
8134 #[cfg(not(target_os = "linux"))]
8135 let _ = alternate_slot;
8136 #[cfg(target_os = "macos")]
8137 let (mut command, privacy_exec, exec_ack) = privacy_command(spec, roster)?;
8138 #[cfg(windows)]
8139 let image_capture = windows_spawn_capture(spec).map_err(|source| SuperviseError::Spawn {
8140 program: spec.program.clone(),
8141 source,
8142 cgroup_path: None,
8143 })?;
8144 #[cfg(windows)]
8145 let mut command = Command::new(image_capture.path());
8146 #[cfg(not(any(target_os = "macos", windows)))]
8147 let mut command = Command::new(&spec.program);
8148 command.args(&spec.args);
8149 apply_child_env(&mut command, spec);
8179 apply_spawn_role(&mut command, role);
8180 let nonce_handoff =
8181 apply_wire_spawn_args_for_role(&mut command, spec, connection_file_path, handle, role)?;
8182
8183 #[cfg(target_os = "linux")]
8184 let cgroup_path = cgroup_placement
8185 .map(|placement| placement.module_path(&cgroup_name))
8186 .transpose()
8187 .map_err(|source| SuperviseError::Cgroup {
8188 module_id: spec.module_id.clone(),
8189 source,
8190 })?;
8191 #[cfg(not(target_os = "linux"))]
8192 let cgroup_path: Option<PathBuf> = None;
8193 #[cfg(target_os = "linux")]
8194 if let Some(path) = &cgroup_path {
8195 if let Err(error) = apply_cgroup_placement(&mut command, spec, path) {
8196 if let Some(placement) = cgroup_placement {
8197 remove_module_cgroup(placement, &cgroup_name);
8198 }
8199 return Err(error);
8200 }
8201 }
8202
8203 let output_sink = if let Some(logs_dir) = capture_logs_dir {
8204 let path = logs_dir.join(format!("{}.stderr.log", spec.module_id));
8205 match ChildOutputSink::open(&path, capture_retention(spec)) {
8206 Ok(sink) => sink,
8207 Err(error) => {
8208 warn!(
8209 module_id = %spec.module_id,
8210 path = %path.display(),
8211 error = %error,
8212 "could not open child output capture file; forwarding to stderr"
8213 );
8214 ChildOutputSink::Stderr
8215 }
8216 }
8217 } else {
8218 ChildOutputSink::Stderr
8219 };
8220
8221 command.stdout(Stdio::piped());
8222 command.stderr(Stdio::piped());
8223 command.kill_on_drop(true);
8224 #[cfg(unix)]
8241 command.process_group(0);
8242 command.stdin(Stdio::null());
8243 #[cfg(unix)]
8247 if let Some(handoff) = nonce_handoff {
8248 handoff.install_last(command.as_std_mut());
8249 }
8250 #[cfg(not(any(unix, windows)))]
8251 let _ = nonce_handoff;
8252
8253 #[cfg(windows)]
8258 subc_jobobject::suspend_on_create_async(&mut command);
8259 let mut child = match command.spawn() {
8260 Ok(child) => child,
8261 Err(source) => {
8262 #[cfg(target_os = "linux")]
8263 if let Some(placement) = cgroup_placement {
8264 remove_module_cgroup(placement, &cgroup_name);
8265 }
8266 return Err(SuperviseError::Spawn {
8267 program: spec.program.clone(),
8268 source,
8269 cgroup_path,
8270 });
8271 }
8272 };
8273 #[cfg(target_os = "macos")]
8278 drop(exec_ack);
8279
8280 #[cfg(windows)]
8285 let windows_image =
8286 Arc::new(
8287 image_capture
8288 .bind_tokio(&child)
8289 .map_err(|source| SuperviseError::Spawn {
8290 program: spec.program.clone(),
8291 source,
8292 cgroup_path: None,
8293 })?,
8294 );
8295 #[cfg(windows)]
8297 let job = contain_spawned_child(&child, spec)?;
8298 let spawned_at_ms = unix_ms_now();
8299 #[cfg(not(windows))]
8300 let spawned_from = spec.program.clone();
8301 #[cfg(windows)]
8302 let spawned_from = windows_image.path().to_path_buf();
8303 #[cfg(not(windows))]
8304 let spawned_file_identity = spawned_file_identity(&spawned_from);
8305 #[cfg(windows)]
8306 let spawned_file_identity = Some(SpawnedFileIdentity {
8307 device: 0,
8308 inode: 0,
8309 windows_image: Some(Arc::clone(&windows_image)),
8310 });
8311 let pid = child.id().ok_or_else(|| SuperviseError::Spawn {
8312 program: spec.program.clone(),
8313 source: io::Error::other("spawned child exposed no live pid"),
8314 cgroup_path: cgroup_path.clone(),
8315 })?;
8316 #[cfg(windows)]
8317 let nonce_delivery = start_windows_nonce_pipe(nonce_handoff, pid, spec);
8318 #[cfg(not(windows))]
8319 let process_start_time = crate::provenance::process_start_time(pid);
8320 #[cfg(windows)]
8321 let process_start_time = Some(windows_image.start_time());
8322 let process_identity = process_start_time.map(|start_time| ProcessIdentity { pid, start_time });
8323 #[cfg(all(test, target_os = "macos"))]
8324 privacy_exec_boundary_tests::before_image_sample(spec, pid);
8325 #[cfg(not(windows))]
8331 let recorded_image = observe_spawned_image(pid);
8332 #[cfg(target_os = "macos")]
8336 let recorded_image = if privacy_exec.is_some() {
8337 None
8338 } else {
8339 recorded_image
8340 };
8341 #[cfg(target_os = "linux")]
8342 let recorded_cgroup_name = cgroup_path.as_ref().map(|_| cgroup_name.clone());
8343 #[cfg(not(target_os = "linux"))]
8344 let recorded_cgroup_name = None;
8345 let roster_guard = roster.admit(
8346 spec.module_id.clone(),
8347 pid,
8348 spec.protocol,
8349 process_start_time,
8350 crate::child_roster::RecordedIdentity {
8351 #[cfg(not(windows))]
8352 start_time: recorded_image.map(|image| image.start_time),
8353 #[cfg(windows)]
8354 start_time: process_start_time,
8355 #[cfg(not(windows))]
8356 executable: recorded_image
8357 .and_then(|image| image.executable)
8358 .map(crate::live_children::ExecutableIdentity::from),
8359 #[cfg(windows)]
8360 executable: Some(crate::live_children::ExecutableIdentity::from(
8361 windows_image.identity(),
8362 )),
8363 cgroup_name: recorded_cgroup_name,
8364 #[cfg(target_os = "linux")]
8365 cgroup_placement: cgroup_placement.cloned(),
8366 },
8367 );
8368 if roster.is_closed() {
8377 #[cfg(target_os = "linux")]
8379 kill_module_cgroup(cgroup_placement, &cgroup_name);
8380 if let Err(error) = child.start_kill() {
8381 debug!(module_id = %spec.module_id, pid, %error, "kill of a process spawned during daemon shutdown failed; it may already have exited");
8382 }
8383 #[cfg(target_os = "linux")]
8384 if let Some(placement) = cgroup_placement {
8385 while matches!(child.try_wait(), Ok(None)) {
8389 std::thread::yield_now();
8390 }
8391 if matches!(
8392 subc_cgroup::kill_module(Some(placement), &cgroup_name),
8393 subc_cgroup::KillOutcome::Killed
8394 ) {
8395 if let Ok(path) = placement.module_path(&cgroup_name) {
8396 while std::fs::read_to_string(path.join("cgroup.events"))
8397 .ok()
8398 .is_some_and(|events| events.lines().any(|line| line == "populated 1"))
8399 {
8400 std::thread::yield_now();
8401 }
8402 }
8403 }
8404 remove_module_cgroup(placement, &cgroup_name);
8405 }
8406 drop(roster_guard);
8407 return Err(SuperviseError::Spawn {
8408 program: spec.program.clone(),
8409 source: io::Error::other(
8410 "the daemon began shutting down while this process was starting; ended it",
8411 ),
8412 cgroup_path,
8413 });
8414 }
8415
8416 let stdout_pump = match child.stdout.take() {
8417 Some(stdout) => Some(tokio::spawn(pump_stdout_to(stdout, output_sink.clone()))),
8418 None => {
8419 warn!(
8420 module_id = %spec.module_id,
8421 "spawned child exposed no stdout pipe; file capture will be incomplete"
8422 );
8423 None
8424 }
8425 };
8426 let stderr_pump = match child.stderr.take() {
8427 Some(stderr) => {
8428 let generation = ring
8429 .lock()
8430 .unwrap_or_else(|poisoned| poisoned.into_inner())
8431 .begin_process();
8432 Some(StderrPump {
8433 task: tokio::spawn(pump_stderr_to(
8434 stderr,
8435 Arc::clone(ring),
8436 generation,
8437 output_sink,
8438 )),
8439 generation,
8440 })
8441 }
8442 None => {
8443 ring.lock()
8447 .unwrap_or_else(|poisoned| poisoned.into_inner())
8448 .mark_not_captured("stderr pipe was not available on spawn");
8449 warn!(
8450 module_id = %spec.module_id,
8451 "spawned child exposed no stderr pipe; tail will be unavailable"
8452 );
8453 None
8454 }
8455 };
8456
8457 Ok(SupervisedChild {
8458 child,
8459 #[cfg(windows)]
8460 nonce_delivery,
8461 protocol: spec.protocol,
8462 #[cfg(target_os = "linux")]
8463 module_id: cgroup_name,
8464 #[cfg(target_os = "linux")]
8465 cgroup_placement: cgroup_placement.cloned(),
8466 #[cfg(windows)]
8467 job,
8468 stdout_pump,
8469 stderr_pump,
8470 stderr_ring: Arc::clone(ring),
8471 spawned_at_ms,
8472 spawned_from,
8473 spawned_file_identity,
8474 process_start_time,
8475 process_identity,
8476 pid,
8477 roster_guard: Some(roster_guard),
8478 #[cfg(target_os = "macos")]
8479 privacy_exec,
8480 #[cfg(target_os = "macos")]
8481 report_ready: Arc::new(OnceLock::new()),
8482 spawn_failure: None,
8483 })
8484}
8485
8486#[cfg(target_os = "linux")]
8487pub(crate) fn kill_module_cgroup(placement: Option<&subc_cgroup::Placement>, module_id: &str) {
8488 use subc_cgroup::KillOutcome;
8489 match subc_cgroup::kill_module(placement, module_id) {
8490 KillOutcome::Killed => {}
8491 KillOutcome::NotPlaced | KillOutcome::Unsupported => {
8492 debug!(
8493 module_id,
8494 "cgroup tree kill unavailable; using direct-child kill"
8495 );
8496 }
8497 KillOutcome::IoError { path, error } => {
8498 warn!(module_id, path = %path.display(), %error, "cgroup tree kill failed; using direct-child kill");
8499 }
8500 }
8501}
8502
8503#[cfg(windows)]
8517fn contain_spawned_child(
8518 child: &Child,
8519 spec: &ModuleSpec,
8520) -> Result<Option<subc_jobobject::JobObject>, SuperviseError> {
8521 let module_id = spec.module_id.as_str();
8522 let Some(pid) = child.id() else {
8523 warn!(
8526 module_id,
8527 "spawned child had already exited before containment; no job object attached"
8528 );
8529 return Ok(None);
8530 };
8531
8532 let job = match subc_jobobject::JobObject::new() {
8533 Ok(job) => job,
8534 Err(source) => {
8535 warn!(
8536 module_id,
8537 error = %source,
8538 "could not create a job object; this module's helper processes will not be \
8539 reaped on teardown"
8540 );
8541 resume_suspended_child(pid, spec)?;
8544 return Ok(None);
8545 }
8546 };
8547
8548 if let Err(source) = job.assign(child) {
8549 warn!(
8550 module_id,
8551 error = %source,
8552 "could not assign the child to its job object; this module's helper processes \
8553 will not be reaped on teardown"
8554 );
8555 resume_suspended_child(pid, spec)?;
8556 return Ok(None);
8557 }
8558
8559 resume_suspended_child(pid, spec)?;
8560 Ok(Some(job))
8561}
8562
8563#[cfg(windows)]
8568fn resume_suspended_child(pid: u32, spec: &ModuleSpec) -> Result<(), SuperviseError> {
8569 if let Err(source) = subc_jobobject::resume_main_thread(pid) {
8570 let _ = std::process::Command::new("taskkill.exe")
8574 .args(["/PID", &pid.to_string(), "/T", "/F"])
8575 .stdin(Stdio::null())
8576 .stdout(Stdio::null())
8577 .stderr(Stdio::null())
8578 .status();
8579 return Err(SuperviseError::Spawn {
8580 program: spec.program.clone(),
8581 source,
8582 cgroup_path: None,
8583 });
8584 }
8585 Ok(())
8586}
8587
8588#[cfg(target_os = "linux")]
8589fn remove_module_cgroup(placement: &subc_cgroup::Placement, module_id: &str) {
8590 match placement.remove_module(module_id) {
8591 Ok(()) => debug!(module_id, "removed module cgroup after process exit"),
8592 Err(error) => warn!(
8593 module_id,
8594 error = %error,
8595 "could not remove module cgroup after process exit; continuing teardown"
8596 ),
8597 }
8598}
8599
8600#[cfg(target_os = "linux")]
8601async fn cleanup_reaped_cgroup(placement: &subc_cgroup::Placement, module_id: &str) {
8602 if matches!(
8606 subc_cgroup::kill_module(Some(placement), module_id),
8607 subc_cgroup::KillOutcome::Killed
8608 ) {
8609 if let Ok(path) = placement.module_path(module_id) {
8610 while std::fs::read_to_string(path.join("cgroup.events"))
8611 .ok()
8612 .is_some_and(|events| events.lines().any(|line| line == "populated 1"))
8613 {
8614 sleep(Duration::from_millis(1)).await;
8615 }
8616 }
8617 }
8618 remove_module_cgroup(placement, module_id);
8619}
8620
8621#[cfg(target_os = "linux")]
8622fn apply_cgroup_placement(
8623 command: &mut Command,
8624 spec: &ModuleSpec,
8625 path: &std::path::Path,
8626) -> Result<(), SuperviseError> {
8627 subc_cgroup::apply(command, path).map_err(|source| SuperviseError::Cgroup {
8628 module_id: spec.module_id.clone(),
8629 source,
8630 })
8631}
8632
8633fn capture_retention(spec: &ModuleSpec) -> Retention {
8634 let defaults = Retention::default();
8635 let value = |name: &str| {
8636 spec.env
8637 .iter()
8638 .rev()
8639 .find_map(|(key, value)| (key == name).then_some(value.as_str()))
8640 };
8641 Retention {
8642 max_file_mb: value(CAPTURE_MAX_FILE_MB_ENV)
8643 .and_then(|value| value.parse().ok())
8644 .unwrap_or(defaults.max_file_mb),
8645 keep: value(CAPTURE_KEEP_ENV)
8646 .and_then(|value| value.parse().ok())
8647 .unwrap_or(defaults.keep),
8648 max_age_days: value(CAPTURE_MAX_AGE_DAYS_ENV)
8649 .and_then(|value| value.parse().ok())
8650 .unwrap_or(defaults.max_age_days),
8651 }
8652}
8653
8654fn generate_launch_nonce() -> Result<String, SuperviseError> {
8657 let mut bytes = [0u8; 32];
8658 getrandom::getrandom(&mut bytes).map_err(|source| SuperviseError::LaunchNonce {
8659 reason: source.to_string(),
8660 })?;
8661 let mut hex = String::with_capacity(64);
8662 for b in bytes {
8663 use std::fmt::Write;
8664 let _ = write!(hex, "{b:02x}");
8665 }
8666 Ok(hex)
8667}
8668
8669fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
8672 if a.len() != b.len() {
8673 return false;
8674 }
8675 let mut diff = 0u8;
8676 for (x, y) in a.iter().zip(b.iter()) {
8677 diff |= x ^ y;
8678 }
8679 diff == 0
8680}
8681
8682#[cfg(not(windows))]
8686fn observe_spawned_image(pid: u32) -> Option<subc_os::Observation> {
8687 subc_os::Process::open(pid)
8688 .ok()
8689 .flatten()
8690 .and_then(|process| process.observe())
8691}
8692
8693#[cfg(windows)]
8696fn windows_spawn_capture(spec: &ModuleSpec) -> io::Result<subc_os::ExecutableCapture> {
8697 let mut program = spec.program.clone();
8698 if program.is_absolute() || program.components().count() > 1 {
8699 match subc_os::ExecutableCapture::open(&program) {
8702 Ok(capture) => return Ok(capture),
8703 Err(error)
8704 if error.kind() == io::ErrorKind::NotFound && program.extension().is_none() => {}
8705 Err(error) => return Err(error),
8706 }
8707 program.set_extension("exe");
8708 return subc_os::ExecutableCapture::open(&program);
8709 }
8710 if program.extension().is_none() {
8711 program.set_extension("exe");
8712 }
8713 let custom_path = spec
8714 .env
8715 .iter()
8716 .rev()
8717 .find(|(key, _)| key.eq_ignore_ascii_case("PATH"));
8718 let mut directories = Vec::new();
8719 if let Some(parent) = std::env::current_exe()?.parent() {
8722 directories.push(parent.to_path_buf());
8723 }
8724 directories.push(std::env::current_dir()?);
8725 if let Some(windows) = std::env::var_os("SystemRoot") {
8726 let windows = PathBuf::from(windows);
8727 directories.push(windows.join("System32"));
8728 directories.push(windows.clone());
8729 }
8730 let path = custom_path
8731 .map(|(_, value)| std::ffi::OsString::from(value))
8732 .or_else(|| std::env::var_os("PATH"));
8733 if let Some(path) = path {
8734 directories.extend(std::env::split_paths(&path));
8735 }
8736 for directory in directories {
8737 match subc_os::ExecutableCapture::open(&directory.join(&program)) {
8738 Ok(capture) => return Ok(capture),
8739 Err(error) if error.kind() == io::ErrorKind::NotFound => continue,
8740 Err(error) => return Err(error),
8741 }
8742 }
8743 Err(io::Error::new(
8744 io::ErrorKind::NotFound,
8745 "resolved executable not found",
8746 ))
8747}
8748
8749fn spawn_and_mark_running(
8750 spec: &ModuleSpec,
8751 runtime: &SupervisorRuntimeConfig,
8752 snapshot: &SharedSnapshot,
8753) -> Result<SupervisedChild, SuperviseError> {
8754 let activity_spawn = runtime.begin_activity_spawn(&spec.module_id);
8755 let child = spawn_child(
8756 spec,
8757 runtime.connection_file_path.as_deref(),
8758 runtime.supervisor_handle.as_ref(),
8759 &runtime.stderr_ring,
8760 runtime.capture_logs_dir.as_deref(),
8761 &runtime.child_roster,
8762 #[cfg(target_os = "linux")]
8763 runtime.cgroup_placement.as_ref(),
8764 )?;
8765 set_running(snapshot, &child, &spec.module_id, &runtime.spawn_events)?;
8766 if let Some(spawn) = activity_spawn {
8767 spawn.complete(snapshot);
8768 }
8769 Ok(child)
8770}
8771
8772enum RegistrationWaitOutcome {
8773 Registered,
8774 Exited(ExitReport),
8775 TimedOut,
8776}
8777
8778struct ReloadRegistrationFailure {
8779 exit_report: ExitReport,
8780 reason: String,
8781}
8782
8783#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8784enum BusyGaugeObservation {
8785 Quiescent,
8786 Busy,
8787 Omitted,
8788}
8789
8790fn busy_gauge_observation(metrics: Option<&Value>, gauges: &[String]) -> BusyGaugeObservation {
8791 let Some(metrics) = metrics.and_then(Value::as_object) else {
8792 return BusyGaugeObservation::Omitted;
8793 };
8794 let mut sum = 0u128;
8795 for gauge in gauges {
8796 let Some(value) = metrics.get(gauge) else {
8797 return BusyGaugeObservation::Omitted;
8798 };
8799 let Some(value) = value.as_u64() else {
8800 return BusyGaugeObservation::Busy;
8801 };
8802 sum = sum.saturating_add(u128::from(value));
8803 }
8804 if sum == 0 {
8805 BusyGaugeObservation::Quiescent
8806 } else {
8807 BusyGaugeObservation::Busy
8808 }
8809}
8810
8811fn declared_busy_gauges(
8812 registry: &Registry,
8813 module_id: &str,
8814) -> Result<Vec<String>, SuperviseError> {
8815 busy_gauges_of(
8816 registry
8817 .get_module(module_id)
8818 .map_err(SuperviseError::Registry)?,
8819 )
8820}
8821
8822fn declared_busy_gauges_for_connection(
8826 registry: &Registry,
8827 connection_id: ConnectionId,
8828) -> Result<Vec<String>, SuperviseError> {
8829 busy_gauges_of(
8830 registry
8831 .get_module_by_connection(connection_id)
8832 .map_err(SuperviseError::Registry)?,
8833 )
8834}
8835
8836fn busy_gauges_of(
8837 registration: Option<crate::registry::ModuleRegistration>,
8838) -> Result<Vec<String>, SuperviseError> {
8839 let Some(registration) = registration else {
8840 return Ok(Vec::new());
8841 };
8842 let Some(self_signals) = registration.manifest.self_signals else {
8843 return Ok(Vec::new());
8844 };
8845
8846 let mut gauges = Vec::new();
8847 for declaration in self_signals {
8848 if declaration.kind != SelfSignalKind::Busy {
8849 continue;
8850 }
8851 match declaration.anchored_to {
8852 SignalAnchor::HealthGauges { gauges: declared } if !declared.is_empty() => {
8853 gauges.extend(declared)
8854 }
8855 _ => {
8856 gauges.push(String::new());
8859 }
8860 }
8861 }
8862 Ok(gauges)
8863}
8864
8865async fn wait_for_forwarding_quiescence(
8870 forwarding: &ForwardingTable,
8871 module_id: &str,
8872 runtime: &SupervisorRuntimeConfig,
8873 endpoint: crate::ModuleEndpointId,
8874 deadline: Instant,
8875 busy_gauges: &[String],
8876 scope: DrainScope,
8877) -> Result<bool, SuperviseError> {
8878 let mut gauges_quiescent = busy_gauges.is_empty();
8879 let mut next_probe_at = Instant::now();
8880 let mut omission_counted = false;
8881
8882 loop {
8883 let now = Instant::now();
8884 if !busy_gauges.is_empty() && now >= next_probe_at && now < deadline {
8885 let report = match scope {
8886 DrainScope::Active => probe_module_health(module_id, runtime, Some(deadline)).await,
8887 DrainScope::Endpoint(endpoint) => {
8888 probe_endpoint_health(endpoint, runtime, Some(deadline)).await
8889 }
8890 };
8891 gauges_quiescent = match report {
8892 Ok(report) => match busy_gauge_observation(report.metrics.as_ref(), busy_gauges) {
8893 BusyGaugeObservation::Quiescent => true,
8894 BusyGaugeObservation::Busy => false,
8895 BusyGaugeObservation::Omitted => {
8896 if !omission_counted {
8897 forwarding
8898 .counters()
8899 .increment_drains_with_undeclared_gauge();
8900 omission_counted = true;
8901 }
8902 false
8903 }
8904 },
8905 Err(err) => {
8906 warn!(
8907 module_id,
8908 error = %err,
8909 "drain health.check did not produce declared busy gauges; treating module as busy"
8910 );
8911 false
8912 }
8913 };
8914 next_probe_at = Instant::now() + runtime.health.cadence.max(REGISTRY_RELEASE_POLL);
8915 }
8916
8917 let in_flight = forwarding
8918 .endpoint_in_flight_count(endpoint)
8919 .map_err(SuperviseError::Forwarding)?;
8920 if in_flight == 0 && gauges_quiescent {
8921 return Ok(true);
8922 }
8923
8924 let now = Instant::now();
8925 if now >= deadline {
8926 return Ok(false);
8927 }
8928 let mut wait = deadline
8929 .saturating_duration_since(now)
8930 .min(REGISTRY_RELEASE_POLL);
8931 if !busy_gauges.is_empty() {
8932 wait = wait.min(next_probe_at.saturating_duration_since(now));
8933 }
8934 sleep(wait).await;
8935 }
8936}
8937
8938fn drained_after_quiescence_wait(wait_result: &Result<bool, SuperviseError>) -> bool {
8946 match wait_result {
8947 Ok(drained) => *drained,
8948 Err(_) => false,
8949 }
8950}
8951
8952fn send_route_goodbyes(forwarding: &ForwardingTable, released_routes: Vec<GoodbyeTarget>) {
8953 for released in released_routes {
8954 let frame = match Frame::build_with_version(
8955 released.negotiated_ver,
8956 FrameType::Goodbye,
8957 control_flags(),
8958 released.channel,
8959 released.epoch,
8960 0,
8961 Vec::new(),
8962 ) {
8963 Ok(frame) => frame,
8964 Err(err) => {
8965 warn!(
8966 route_channel = released.channel,
8967 error = %err,
8968 "failed to build supervisor drain route GOODBYE frame"
8969 );
8970 continue;
8971 }
8972 };
8973 if !released.close_on_delivery_failure() {
8974 crate::forwarding::send_module_route_goodbye(
8975 &forwarding.counters(),
8976 &released.sink,
8977 frame,
8978 released.module_id.as_deref(),
8979 "supervisor drain",
8980 );
8981 continue;
8982 }
8983 if let Err(err) = released.sink.try_send(frame) {
8984 warn!(
8985 target_connection_id = released.connection_id.get(),
8986 route_channel = released.channel,
8987 error = %err,
8988 "supervisor drain route GOODBYE was not delivered to client; closing target connection"
8989 );
8990 let _ = forwarding.escalate_client_delivery_failure(
8991 released.connection_id,
8992 released.channel,
8993 released.epoch,
8994 CloseReason::new(
8995 "route_goodbye_delivery_failed",
8996 format!(
8997 "failed to enqueue supervisor drain route GOODBYE for channel {}: {err}",
8998 released.channel
8999 ),
9000 ),
9001 crate::forwarding::UndeliveredFrame {
9002 module_id: released.module_id.as_deref(),
9003 sink: &released.sink,
9004 },
9005 );
9006 }
9007 }
9008}
9009
9010fn send_module_draining(
9011 module_id: &str,
9012 reason: RouteCloseReason,
9013 deadline_ms: u64,
9014 target: &ModuleDrainTarget,
9015) {
9016 let body = match serde_json::to_vec(&ModuleControlCommand::Draining {
9017 reason,
9018 deadline_ms,
9019 }) {
9020 Ok(body) => body,
9021 Err(err) => {
9022 warn!(
9023 module_id,
9024 error = %err,
9025 "failed to encode module draining command"
9026 );
9027 return;
9028 }
9029 };
9030 let frame = match Frame::build_with_version(
9031 target.negotiated_ver,
9032 FrameType::Push,
9033 control_flags(),
9034 0,
9035 0,
9036 0,
9037 body,
9038 ) {
9039 Ok(frame) => frame,
9040 Err(err) => {
9041 warn!(
9042 module_id,
9043 error = %err,
9044 "failed to build module draining command frame"
9045 );
9046 return;
9047 }
9048 };
9049 if let Err(err) = target.sink.try_send(frame) {
9050 warn!(
9051 module_id,
9052 target_connection_id = target.endpoint.connection_id.get(),
9053 error = %err,
9054 "module draining command was not delivered to peer"
9055 );
9056 }
9057}
9058
9059fn module_goodbye_frame(module_id: &str, negotiated_ver: u8) -> Option<Frame> {
9061 match Frame::build_with_version(
9062 negotiated_ver,
9063 FrameType::Goodbye,
9064 control_flags(),
9065 0,
9066 0,
9067 0,
9068 Vec::new(),
9069 ) {
9070 Ok(frame) => Some(frame),
9071 Err(err) => {
9072 warn!(
9073 module_id,
9074 error = %err,
9075 "failed to build module GOODBYE frame"
9076 );
9077 None
9078 }
9079 }
9080}
9081
9082#[cfg(unix)]
9096async fn send_module_goodbyes_for_daemon_shutdown(
9097 forwarding: &Arc<ForwardingTable>,
9098 reason: &CloseReason,
9099 wait_for_flush: bool,
9100) {
9101 const GOODBYE_BUDGET: Duration = Duration::from_millis(500);
9102 let targets = match forwarding.module_connections() {
9103 Ok(targets) => targets,
9104 Err(err) => {
9105 warn!(error = %err, "could not list module connections for shutdown GOODBYE");
9106 return;
9107 }
9108 };
9109 let deadline = Instant::now() + GOODBYE_BUDGET;
9110 let mut sends = tokio::task::JoinSet::new();
9111 for target in targets {
9112 let Some(frame) = module_goodbye_frame(&target.module_id, target.negotiated_ver) else {
9113 continue;
9114 };
9115 if !wait_for_flush {
9116 if let Err(err) = target.sink.try_send(frame) {
9117 debug!(
9118 module_id = %target.module_id,
9119 error = %err,
9120 "shutdown module GOODBYE was not queued"
9121 );
9122 }
9123 continue;
9124 }
9125 let forwarding = Arc::clone(forwarding);
9126 let reason = reason.clone();
9127 sends.spawn(async move {
9128 match timeout_at(deadline, target.sink.send_flushed(frame)).await {
9129 Ok(Ok(())) => {}
9130 Ok(Err(err)) => debug!(
9131 module_id = %target.module_id,
9132 error = %err,
9133 "module connection closed before its shutdown GOODBYE was written"
9134 ),
9135 Err(_) => warn!(
9136 module_id = %target.module_id,
9137 budget = ?GOODBYE_BUDGET,
9138 "shutdown module GOODBYE was not written within its budget; closing anyway"
9139 ),
9140 }
9141 forwarding.request_connection_close(target.endpoint.connection_id, reason);
9142 });
9143 }
9144 while sends.join_next().await.is_some() {}
9146}
9147
9148fn send_module_goodbye(module_id: &str, forwarding: &ForwardingTable, target: &ModuleDrainTarget) {
9149 let Some(frame) = module_goodbye_frame(module_id, target.negotiated_ver) else {
9150 return;
9151 };
9152 if let Err(err) = target.sink.try_send(frame) {
9153 warn!(
9154 module_id,
9155 target_connection_id = target.endpoint.connection_id.get(),
9156 error = %err,
9157 "supervisor drain module GOODBYE was not delivered to peer; closing module connection"
9158 );
9159 forwarding.request_connection_close(
9160 target.endpoint.connection_id,
9161 CloseReason::new(
9162 "module_goodbye_delivery_failed",
9163 format!("failed to enqueue supervisor drain module GOODBYE for module '{module_id}': {err}"),
9164 ),
9165 );
9166 }
9167}
9168
9169#[derive(Clone, Copy)]
9170struct ForwardingDrainContext<'a> {
9171 spec: &'a ModuleSpec,
9172 runtime: &'a SupervisorRuntimeConfig,
9173 registry: &'a Registry,
9174 scope: DrainScope,
9175}
9176
9177#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9179enum DrainScope {
9180 Active,
9183 Endpoint(crate::ModuleEndpointId),
9188}
9189
9190#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9198enum StopNotice {
9199 SentOverConnection,
9202 NoConnection,
9206 NotSent,
9210}
9211
9212async fn begin_forwarding_drain(
9213 spec: &ModuleSpec,
9214 runtime: &SupervisorRuntimeConfig,
9215 registry: &Registry,
9216 snapshot: &SharedSnapshot,
9217 enabled: Option<bool>,
9218 reason: RouteCloseReason,
9219) -> Result<StopNotice, SuperviseError> {
9220 let Some(forwarding) = runtime.forwarding.as_ref() else {
9221 return Err(SuperviseError::ReloadUnavailable {
9222 module_id: spec.module_id.clone(),
9223 reason: "supervisor was not configured with a forwarding table".to_string(),
9224 });
9225 };
9226
9227 begin_forwarding_drain_with(
9228 forwarding,
9229 ForwardingDrainContext {
9230 spec,
9231 runtime,
9232 registry,
9233 scope: DrainScope::Active,
9234 },
9235 snapshot,
9236 enabled,
9237 reason,
9238 runtime.drain_timeout,
9239 )
9240 .await
9241}
9242
9243async fn begin_forwarding_drain_if_configured(
9244 spec: &ModuleSpec,
9245 runtime: &SupervisorRuntimeConfig,
9246 registry: &Registry,
9247 snapshot: &SharedSnapshot,
9248 enabled: Option<bool>,
9249 reason: RouteCloseReason,
9250) -> Result<StopNotice, SuperviseError> {
9251 begin_forwarding_drain_with_timeout(
9252 spec,
9253 runtime,
9254 registry,
9255 snapshot,
9256 enabled,
9257 reason,
9258 runtime.drain_timeout,
9259 )
9260 .await
9261}
9262
9263async fn begin_forwarding_drain_with_timeout(
9267 spec: &ModuleSpec,
9268 runtime: &SupervisorRuntimeConfig,
9269 registry: &Registry,
9270 snapshot: &SharedSnapshot,
9271 enabled: Option<bool>,
9272 reason: RouteCloseReason,
9273 drain_timeout: Duration,
9274) -> Result<StopNotice, SuperviseError> {
9275 let Some(forwarding) = runtime.forwarding.as_ref() else {
9276 return Ok(StopNotice::NotSent);
9277 };
9278
9279 begin_forwarding_drain_with(
9280 forwarding,
9281 ForwardingDrainContext {
9282 spec,
9283 runtime,
9284 registry,
9285 scope: DrainScope::Active,
9286 },
9287 snapshot,
9288 enabled,
9289 reason,
9290 drain_timeout,
9291 )
9292 .await
9293}
9294
9295async fn begin_forwarding_drain_with(
9296 forwarding: &ForwardingTable,
9297 context: ForwardingDrainContext<'_>,
9298 snapshot: &SharedSnapshot,
9299 enabled: Option<bool>,
9300 reason: RouteCloseReason,
9301 drain_timeout: Duration,
9302) -> Result<StopNotice, SuperviseError> {
9303 let ForwardingDrainContext {
9304 spec,
9305 runtime,
9306 registry,
9307 scope,
9308 } = context;
9309 debug_assert_ne!(reason, RouteCloseReason::Crash);
9310 let terminal = matches!(reason, RouteCloseReason::Disable);
9311 let drain_started_at = Instant::now();
9312 let drain_deadline = drain_started_at + drain_timeout;
9313 let deadline_ms =
9314 unix_ms_now().saturating_add(u64::try_from(drain_timeout.as_millis()).unwrap_or(u64::MAX));
9315 let busy_gauges = match scope {
9316 DrainScope::Active => declared_busy_gauges(registry, &spec.module_id)?,
9317 DrainScope::Endpoint(endpoint) => {
9318 declared_busy_gauges_for_connection(registry, endpoint.connection_id)?
9319 }
9320 };
9321
9322 let gate_started = Instant::now();
9325 let drain_target = match scope {
9326 DrainScope::Active => forwarding.begin_module_drain(&spec.module_id, reason),
9327 DrainScope::Endpoint(endpoint) => forwarding.begin_endpoint_drain(endpoint, reason),
9328 }
9329 .map_err(SuperviseError::Forwarding)?;
9330 info!(
9335 module_id = %spec.module_id,
9336 ?reason,
9337 gate_ms = u64::try_from(gate_started.elapsed().as_millis()).unwrap_or(u64::MAX),
9338 connected = drain_target.is_some(),
9339 "module drain began; route admission closed"
9340 );
9341 if scope == DrainScope::Active {
9342 update_snapshot(snapshot, Some(&spec.module_id), |state| {
9343 state.state = ModuleState::Draining;
9344 state.draining_to_replace =
9345 matches!(reason, RouteCloseReason::Restart | RouteCloseReason::Reload);
9346 if let Some(enabled) = enabled {
9347 state.enabled = enabled;
9348 }
9349 })?;
9350 }
9351
9352 let Some(target) = drain_target.as_ref() else {
9353 return Ok(StopNotice::NoConnection);
9357 };
9358 {
9359 send_module_draining(&spec.module_id, reason, deadline_ms, target);
9360 let routes = forwarding
9361 .endpoint_routes(target.endpoint)
9362 .map_err(SuperviseError::Forwarding)?;
9363 let routes_notified = routes.len();
9364 crate::control::send_route_control_pushes(
9365 forwarding,
9366 routes.clone(),
9367 ClientControlPush::RouteClosing {
9368 module_id: spec.module_id.clone(),
9369 channels: Vec::new(),
9370 reason,
9371 },
9372 );
9373 send_route_goodbyes(forwarding, target.abandoned_bindings.clone());
9374
9375 let wait_result = wait_for_forwarding_quiescence(
9381 forwarding,
9382 &spec.module_id,
9383 runtime,
9384 target.endpoint,
9385 drain_deadline,
9386 &busy_gauges,
9387 scope,
9388 )
9389 .await;
9390 let drained = drained_after_quiescence_wait(&wait_result);
9391 if let Err(err) = &wait_result {
9392 error!(
9393 module_id = %spec.module_id,
9394 ?reason,
9395 error = %err,
9396 "forwarding quiescence wait failed after route.closing; forcing route.closed(drained: false) so the client is not left waiting on an unfulfilled promise"
9397 );
9398 } else if !drained {
9399 let holdouts = forwarding
9405 .endpoint_drain_holdouts(target.endpoint)
9406 .unwrap_or_default();
9407 warn!(
9408 module_id = %spec.module_id,
9409 waited = ?drain_timeout,
9410 ?reason,
9411 held_requests = holdouts.requests,
9412 held_routes = holdouts.routes,
9413 total_routes = holdouts.total_routes,
9414 top_connections = ?holdouts.top_connections,
9415 held = %holdouts
9418 .held
9419 .iter()
9420 .map(|(channel, corr)| format!("{channel}:{corr}"))
9421 .collect::<Vec<_>>()
9422 .join(","),
9423 "route drain timed out before request quiescence; forcing teardown"
9424 );
9425 }
9426 crate::control::send_route_control_pushes(
9427 forwarding,
9428 routes,
9429 ClientControlPush::RouteClosed {
9430 module_id: spec.module_id.clone(),
9431 channels: Vec::new(),
9432 reason,
9433 drained,
9434 abandoned: target.abandoned_bindings.len() as u32,
9435 excluded_subscriptions: target.excluded_subscriptions,
9436 terminal: Some(terminal),
9437 },
9438 );
9439 wait_result?;
9440
9441 let released_routes = match forwarding.release_module_endpoint_routes(target.endpoint) {
9447 Ok(routes) => routes,
9448 Err(err) => {
9449 warn!(
9450 module_id = %spec.module_id,
9451 ?reason,
9452 error = %err,
9453 "failed to release module endpoint routes after route.closed; module GOODBYE will still be sent"
9454 );
9455 send_module_goodbye(&spec.module_id, forwarding, target);
9456 return Err(SuperviseError::Forwarding(err));
9457 }
9458 };
9459 let route_goodbye_count = released_routes.len();
9460 send_route_goodbyes(forwarding, released_routes);
9461 send_module_goodbye(&spec.module_id, forwarding, target);
9462
9463 info!(
9469 module_id = %spec.module_id,
9470 ?reason,
9471 routes_notified,
9472 route_goodbyes = route_goodbye_count,
9473 abandoned_reservations = target.abandoned_bindings.len(),
9474 excluded_subscriptions = target.excluded_subscriptions,
9475 drained,
9476 "module drain complete; consumers notified via route.closing/route.closed pushes and per-route GOODBYE frames"
9477 );
9478 }
9479
9480 Ok(StopNotice::SentOverConnection)
9481}
9482
9483async fn wait_for_registration_after_reload(
9486 registry: &Registry,
9487 module_id: &str,
9488 snapshot: &SharedSnapshot,
9489 child: &mut SupervisedChild,
9490 wait: Duration,
9491) -> Result<RegistrationWaitOutcome, SuperviseError> {
9492 wait_for_slot_registration(
9493 registry,
9494 crate::registry::RegistrationSlot::Active(module_id),
9495 module_id,
9496 snapshot,
9497 child,
9498 wait,
9499 )
9500 .await
9501}
9502
9503async fn wait_for_slot_registration(
9511 registry: &Registry,
9512 slot: crate::registry::RegistrationSlot<'_>,
9513 module_id: &str,
9514 snapshot: &SharedSnapshot,
9515 child: &mut SupervisedChild,
9516 wait: Duration,
9517) -> Result<RegistrationWaitOutcome, SuperviseError> {
9518 let deadline = Instant::now() + wait;
9519 loop {
9520 if registry
9521 .registration(slot)
9522 .map_err(SuperviseError::Registry)?
9523 .is_some()
9524 {
9525 return Ok(RegistrationWaitOutcome::Registered);
9526 }
9527
9528 let now = Instant::now();
9529 if now >= deadline {
9530 return Ok(RegistrationWaitOutcome::TimedOut);
9531 }
9532 let remaining = deadline.saturating_duration_since(now);
9533 let poll = remaining.min(REGISTRY_RELEASE_POLL);
9534
9535 tokio::select! {
9536 wait_result = child.wait() => {
9537 let status = wait_result.map_err(|source| SuperviseError::Wait {
9538 module_id: module_id.to_string(),
9539 source,
9540 })?;
9541 return Ok(RegistrationWaitOutcome::Exited(classify_reaped_child_exit(
9542 snapshot,
9543 child,
9544 &status,
9545 )));
9546 }
9547 _ = sleep(poll) => {}
9548 }
9549 }
9550}
9551
9552fn registration_failure_exit_report(mut exit_report: ExitReport) -> ExitReport {
9553 if exit_report.kind != ExitKind::DeliberateSeverance {
9556 exit_report.kind = ExitKind::Crash;
9557 }
9558 exit_report
9559}
9560
9561async fn handle_reload_child_registration_failure(
9562 spec: &ModuleSpec,
9563 runtime: &SupervisorRuntimeConfig,
9564 registry: &Registry,
9565 process_liveness: &SupervisorProcessLiveness,
9566 snapshot: &SharedSnapshot,
9567 _child: &mut Option<SupervisedChild>,
9568 failure: ReloadRegistrationFailure,
9569) -> Result<(), SuperviseError> {
9570 let ReloadRegistrationFailure {
9571 exit_report,
9572 reason,
9573 } = failure;
9574 match on_child_exit(
9575 spec,
9576 runtime.restart_policy,
9577 registry,
9578 snapshot,
9579 &runtime.terminal_ring,
9580 &runtime.spawn_events,
9581 &runtime.child_roster,
9582 exit_report,
9583 )
9584 .await
9585 {
9586 NextAction::Stop {
9587 registration_released,
9588 } => {
9589 if registration_released {
9590 process_liveness.untrack_if_current(&spec.module_id, snapshot);
9591 }
9592 }
9593 NextAction::Restart { schedule } => {
9594 let delay = schedule.map_or(runtime.restart_policy.delay_for_restart(0), |schedule| {
9595 schedule.delay
9596 });
9597 if let Some(schedule) = schedule {
9598 log_crash_respawn(&spec.module_id, schedule);
9599 }
9600 schedule_respawn(
9601 runtime,
9602 snapshot,
9603 &spec.module_id,
9604 delay,
9605 RespawnKind::Spawn,
9606 )?;
9607 }
9608 }
9609 Err(SuperviseError::ReloadFailed {
9610 module_id: spec.module_id.clone(),
9611 reason,
9612 })
9613}
9614
9615async fn handle_reload_spawn_failure(
9616 spec: &ModuleSpec,
9617 runtime: &SupervisorRuntimeConfig,
9618 process_liveness: &SupervisorProcessLiveness,
9619 snapshot: &SharedSnapshot,
9620 _child: &mut Option<SupervisedChild>,
9621 reason: String,
9622) -> Result<(), SuperviseError> {
9623 let now = Instant::now();
9624 let mut schedule = None;
9625 update_snapshot(snapshot, Some(&spec.module_id), |state| {
9626 clear_current_process_facts(state);
9627 if state.enabled {
9628 schedule = state.next_crash_restart(&runtime.restart_policy, now);
9629 state.state = if schedule.is_some() {
9630 ModuleState::Restarting
9631 } else {
9632 ModuleState::Failed
9633 };
9634 } else {
9635 state.state = ModuleState::Disabled;
9636 }
9637 })?;
9638 if let Some(schedule) = schedule {
9639 schedule_respawn(
9640 runtime,
9641 snapshot,
9642 &spec.module_id,
9643 schedule.delay,
9644 RespawnKind::Spawn,
9645 )?;
9646 } else {
9647 process_liveness.untrack_if_current(&spec.module_id, snapshot);
9648 }
9649 Err(SuperviseError::ReloadFailed {
9650 module_id: spec.module_id.clone(),
9651 reason,
9652 })
9653}
9654
9655fn control_flags() -> Flags {
9656 Flags::new(false, Priority::Passive, false)
9657}
9658
9659#[allow(clippy::too_many_arguments)]
9660async fn drain_optional_child(
9661 module_id: &str,
9662 protocol: ModuleProtocol,
9663 stop_notice: StopNotice,
9664 registry: &Registry,
9665 forwarding: Option<&ForwardingTable>,
9666 snapshot: &SharedSnapshot,
9667 terminal_ring: &Arc<Mutex<TerminalRing>>,
9668 spawn_events: &SpawnEventFeed,
9669 child: &mut Option<SupervisedChild>,
9670 drain_timeout: Duration,
9671 final_state: ModuleState,
9672 enabled: Option<bool>,
9673) -> Result<(), SuperviseError> {
9674 if let Some(child) = child.take() {
9675 drain_child_to_state(
9676 module_id,
9677 protocol,
9678 stop_notice,
9679 registry,
9680 forwarding,
9681 snapshot,
9682 terminal_ring,
9683 spawn_events,
9684 child,
9685 drain_timeout,
9686 final_state,
9687 enabled,
9688 )
9689 .await
9690 } else {
9691 update_snapshot(snapshot, Some(module_id), |state| {
9692 state.state = final_state;
9693 if let Some(enabled) = enabled {
9694 state.enabled = enabled;
9695 }
9696 clear_current_process_facts(state);
9697 })?;
9698 release_dead_registration(registry, forwarding, snapshot, module_id).await
9699 }
9700}
9701
9702#[allow(clippy::too_many_arguments)]
9703async fn drain_child_to_state(
9704 module_id: &str,
9705 _protocol: ModuleProtocol,
9706 stop_notice: StopNotice,
9707 registry: &Registry,
9708 forwarding: Option<&ForwardingTable>,
9709 snapshot: &SharedSnapshot,
9710 terminal_ring: &Arc<Mutex<TerminalRing>>,
9711 spawn_events: &SpawnEventFeed,
9712 mut child: SupervisedChild,
9713 drain_timeout: Duration,
9714 final_state: ModuleState,
9715 enabled: Option<bool>,
9716) -> Result<(), SuperviseError> {
9717 let protocol = child.protocol;
9718 update_snapshot(snapshot, Some(module_id), |state| {
9719 state.state = ModuleState::Draining;
9720 state.draining_to_replace = final_state == ModuleState::Restarting;
9721 if let Some(enabled) = enabled {
9722 state.enabled = enabled;
9723 }
9724 })?;
9725
9726 if stop_notice != StopNotice::SentOverConnection {
9737 if protocol == ModuleProtocol::Subc && stop_notice == StopNotice::NoConnection {
9738 info!(
9739 module_id,
9740 pid = child.pid,
9741 budget_ms = u64::try_from(drain_timeout.as_millis()).unwrap_or(u64::MAX),
9742 "module has no connection yet; requesting stop by signal"
9743 );
9744 }
9745 request_graceful_stop(module_id, &child);
9746 }
9747
9748 let exit_report = match timeout(drain_timeout, child.wait()).await {
9749 Ok(Ok(status)) => classify_reaped_child_exit(snapshot, &child, &status),
9750 Ok(Err(source)) => {
9751 fail_after_wait_error(
9752 module_id,
9753 snapshot,
9754 terminal_ring,
9755 spawn_events,
9756 format!("failed to wait during drain: {source}"),
9757 );
9758 return Err(SuperviseError::Wait {
9759 module_id: module_id.to_string(),
9760 source,
9761 });
9762 }
9763 Err(_) => {
9764 warn!(
9777 module_id,
9778 pid = child.pid,
9779 budget_ms = u64::try_from(drain_timeout.as_millis()).unwrap_or(u64::MAX),
9780 reason = ?final_state,
9781 ?stop_notice,
9782 "drain budget expired before the module exited; killing it"
9783 );
9784 child.start_kill().map_err(|source| {
9785 fail_after_wait_error(
9786 module_id,
9787 snapshot,
9788 terminal_ring,
9789 spawn_events,
9790 format!("failed to kill during drain: {source}"),
9791 );
9792 SuperviseError::Kill {
9793 module_id: module_id.to_string(),
9794 source,
9795 }
9796 })?;
9797 let status = child.wait().await.map_err(|source| {
9798 fail_after_wait_error(
9799 module_id,
9800 snapshot,
9801 terminal_ring,
9802 spawn_events,
9803 format!("failed to wait after drain kill: {source}"),
9804 );
9805 SuperviseError::Wait {
9806 module_id: module_id.to_string(),
9807 source,
9808 }
9809 })?;
9810 classify_reaped_child_exit(snapshot, &child, &status)
9811 }
9812 };
9813
9814 let detail = lock_snapshot(snapshot)?.drain_disposition_detail.take();
9815 publish_after_terminal_record(
9816 module_id,
9817 || {
9818 record_terminal_with_detail(
9819 module_id,
9820 terminal_ring,
9821 spawn_events,
9822 &exit_report,
9823 terminal_disposition(final_state),
9824 detail,
9825 )
9826 },
9827 || {
9828 update_snapshot(snapshot, Some(module_id), |state| {
9829 state.state = final_state;
9830 if let Some(enabled) = enabled {
9831 state.enabled = enabled;
9832 }
9833 clear_current_process_facts(state);
9834 state.last_exit = Some(exit_report.clone());
9835 if exit_report.kind == ExitKind::DeliberateSeverance {
9836 state.lifetime_restarts += 1;
9837 }
9838 })
9839 },
9840 )?;
9841 child.drain_stderr(module_id).await;
9842
9843 release_dead_registration(registry, forwarding, snapshot, module_id).await
9844}
9845
9846#[cfg(unix)]
9866fn request_graceful_stop(module_id: &str, child: &SupervisedChild) {
9867 let Some(pid) = child
9868 .id()
9869 .and_then(|pid| i32::try_from(pid).ok())
9870 .and_then(rustix::process::Pid::from_raw)
9871 else {
9872 debug!(
9873 module_id,
9874 "no pid to signal for teardown; falling through to the drain wait"
9875 );
9876 return;
9877 };
9878 match rustix::process::kill_process(pid, rustix::process::Signal::TERM) {
9879 Ok(()) => debug!(
9880 module_id,
9881 "sent SIGTERM to a module nothing else asked to stop"
9882 ),
9883 Err(err) => debug!(
9884 module_id,
9885 error = %err,
9886 "SIGTERM to module failed; the drain wait and kill still apply"
9887 ),
9888 }
9889}
9890
9891#[cfg(not(unix))]
9899fn request_graceful_stop(module_id: &str, _child: &SupervisedChild) {
9900 debug!(
9901 module_id,
9902 "no graceful stop signal exists on this platform; teardown of a module nothing asked to stop waits, then kills"
9903 );
9904}
9905
9906fn terminal_disposition(final_state: ModuleState) -> TerminalDisposition {
9907 match final_state {
9908 ModuleState::Stopped => TerminalDisposition::Stopped,
9909 ModuleState::Disabled => TerminalDisposition::Disabled,
9910 ModuleState::Restarting => TerminalDisposition::Restarting,
9911 ModuleState::Failed => TerminalDisposition::Failed,
9912 ModuleState::Starting
9913 | ModuleState::Running
9914 | ModuleState::Unresponsive
9915 | ModuleState::Draining => {
9916 unreachable!("terminal exits only finish in terminal or restarting states")
9917 }
9918 }
9919}
9920
9921async fn release_dead_registration(
9929 registry: &Registry,
9930 forwarding: Option<&ForwardingTable>,
9931 snapshot: &SharedSnapshot,
9932 module_id: &str,
9933) -> Result<(), SuperviseError> {
9934 let result = async {
9935 let registration = registry
9936 .get_module(module_id)
9937 .map_err(SuperviseError::Registry)?;
9938 match wait_for_registration_release(registry, module_id, REGISTRY_RELEASE_TIMEOUT).await {
9939 Ok(()) => return Ok(()),
9940 Err(SuperviseError::RegistrationStillActive { .. }) => {}
9941 Err(err) => return Err(err),
9942 }
9943 let pid = lock_snapshot(snapshot)?.reaped_pid;
9944 if let (Some(registration), Some(forwarding), Some(pid)) = (registration, forwarding, pid) {
9945 warn!(
9946 module_id,
9947 pid,
9948 connection_id = registration.connection_id.get(),
9949 "reaped module registration outlived release grace; closing dead connection"
9950 );
9951 forwarding.request_connection_close(
9952 registration.connection_id,
9953 CloseReason::new(
9954 "supervised_process_reaped",
9955 format!("module '{module_id}' pid {pid} exited"),
9956 ),
9957 );
9958 wait_for_slot_registration_release(
9959 registry,
9960 crate::registry::RegistrationSlot::Connection(registration.connection_id),
9961 REGISTRY_RELEASE_TIMEOUT,
9962 )
9963 .await?;
9964 }
9965 wait_for_registration_release(registry, module_id, Duration::ZERO).await
9966 }
9967 .await;
9968 if let Err(err) = &result {
9969 fail_snapshot(snapshot, Some(module_id), None);
9970 error!(module_id, error = %err, "registration release failed after child exit; module is failed and start can retry");
9971 }
9972 result
9973}
9974
9975async fn wait_for_registration_release(
9978 registry: &Registry,
9979 module_id: &str,
9980 wait: Duration,
9981) -> Result<(), SuperviseError> {
9982 wait_for_slot_registration_release(
9983 registry,
9984 crate::registry::RegistrationSlot::Active(module_id),
9985 wait,
9986 )
9987 .await
9988}
9989
9990async fn wait_for_slot_registration_release(
9998 registry: &Registry,
9999 slot: crate::registry::RegistrationSlot<'_>,
10000 wait: Duration,
10001) -> Result<(), SuperviseError> {
10002 let deadline = Instant::now() + wait;
10003 let mut release_events = registration_release_events().subscribe();
10004 let still_active = |registration: &crate::registry::ModuleRegistration| {
10005 SuperviseError::RegistrationStillActive {
10006 module_id: registration.manifest.module_id.clone(),
10007 waited: wait,
10008 }
10009 };
10010 loop {
10011 let _observed_generation = *release_events.borrow_and_update();
10012 let Some(registration) = registry
10013 .registration(slot)
10014 .map_err(SuperviseError::Registry)?
10015 else {
10016 return Ok(());
10017 };
10018
10019 let now = Instant::now();
10020 if now >= deadline {
10021 return Err(still_active(®istration));
10022 }
10023
10024 let remaining = deadline.saturating_duration_since(now);
10025 match timeout(remaining, release_events.changed()).await {
10026 Ok(Ok(())) | Ok(Err(_)) => {}
10027 Err(_) => return Err(still_active(®istration)),
10028 }
10029 }
10030}
10031
10032#[cfg(test)]
10033mod slot_registration_wait_tests {
10034 use super::*;
10035 use crate::registry::{ConnectionId, RegistrationSlot};
10036 use subc_protocol::manifest::ModuleManifest;
10037
10038 #[tokio::test]
10039 async fn enable_release_failure_is_failed_and_a_second_enable_retries() {
10040 let registry = Arc::new(Registry::default());
10041 let supervisor = Supervisor::new_for_test(Arc::clone(®istry), RestartPolicy::default());
10042 let runtime = supervisor.runtime_config();
10043 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::disabled()));
10044 let spec = ModuleSpec {
10045 module_id: "enable-stale-registration".to_string(),
10046 program: PathBuf::from("/missing/enable-retry-test"),
10047 args: Vec::new(),
10048 env: Vec::new(),
10049 reserved: false,
10050 reserved_prefixes: Vec::new(),
10051 protocol: ModuleProtocol::Subc,
10052 overlap: Default::default(),
10053 };
10054 let connection = ConnectionId::new(90);
10055 registry
10056 .register_with_control_ops(
10057 ModuleManifest::builder(&spec.module_id, "0.1.0").build(),
10058 1,
10059 connection,
10060 Vec::new(),
10061 )
10062 .unwrap();
10063 let mut child = None;
10064 let err = set_child_enabled(
10065 &spec,
10066 &runtime,
10067 ®istry,
10068 &supervisor.process_liveness,
10069 &snapshot,
10070 &mut child,
10071 true,
10072 )
10073 .await
10074 .unwrap_err();
10075 assert!(matches!(
10076 err,
10077 SuperviseError::RegistrationStillActive { .. }
10078 ));
10079 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
10080 assert!(child.is_none());
10081 registry.deregister_connection(connection).unwrap();
10082 let err = set_child_enabled(
10083 &spec,
10084 &runtime,
10085 ®istry,
10086 &supervisor.process_liveness,
10087 &snapshot,
10088 &mut child,
10089 true,
10090 )
10091 .await
10092 .unwrap_err();
10093 assert!(
10094 matches!(err, SuperviseError::Spawn { .. }),
10095 "second enable must attempt a spawn: {err}"
10096 );
10097 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
10098 }
10099
10100 const INCUMBENT: u64 = 1;
10101 const CANDIDATE: u64 = 2;
10102
10103 fn swapped_registry() -> Arc<Registry> {
10104 let registry = Arc::new(Registry::default());
10105 let manifest = ModuleManifest::builder("m", "0.1.0").build();
10106 registry
10107 .register_with_control_ops(
10108 manifest.clone(),
10109 1,
10110 ConnectionId::new(INCUMBENT),
10111 Vec::new(),
10112 )
10113 .unwrap();
10114 registry
10115 .register_candidate_with_control_ops(
10116 manifest,
10117 1,
10118 ConnectionId::new(CANDIDATE),
10119 Vec::new(),
10120 )
10121 .unwrap();
10122 registry
10123 }
10124
10125 #[tokio::test]
10129 async fn incumbent_release_is_awaited_by_connection_not_by_module_id() {
10130 let registry = swapped_registry();
10131 registry.promote_candidate("m").unwrap().unwrap();
10132
10133 assert!(matches!(
10134 wait_for_registration_release(®istry, "m", Duration::from_millis(50)).await,
10135 Err(SuperviseError::RegistrationStillActive { .. })
10136 ));
10137
10138 assert!(matches!(
10140 wait_for_slot_registration_release(
10141 ®istry,
10142 RegistrationSlot::Connection(ConnectionId::new(INCUMBENT)),
10143 Duration::from_millis(50),
10144 )
10145 .await,
10146 Err(SuperviseError::RegistrationStillActive { .. })
10147 ));
10148
10149 let releaser = Arc::clone(®istry);
10150 let release = tokio::spawn(async move {
10151 sleep(Duration::from_millis(20)).await;
10152 releaser
10153 .deregister_connection(ConnectionId::new(INCUMBENT))
10154 .unwrap();
10155 notify_registration_release();
10156 });
10157 wait_for_slot_registration_release(
10158 ®istry,
10159 RegistrationSlot::Connection(ConnectionId::new(INCUMBENT)),
10160 Duration::from_secs(5),
10161 )
10162 .await
10163 .expect("the incumbent's own registration is released");
10164 release.await.unwrap();
10165 assert!(registry.get_module("m").unwrap().is_some());
10166 }
10167
10168 #[tokio::test]
10171 async fn candidate_slot_wait_ignores_the_incumbents_registration() {
10172 let registry = swapped_registry();
10173 assert!(matches!(
10174 wait_for_slot_registration_release(
10175 ®istry,
10176 RegistrationSlot::Candidate("m"),
10177 Duration::from_millis(50),
10178 )
10179 .await,
10180 Err(SuperviseError::RegistrationStillActive { .. })
10181 ));
10182 registry
10183 .deregister_connection(ConnectionId::new(CANDIDATE))
10184 .unwrap();
10185 wait_for_slot_registration_release(
10186 ®istry,
10187 RegistrationSlot::Candidate("m"),
10188 Duration::from_millis(50),
10189 )
10190 .await
10191 .expect("a candidate slot with no candidate is released");
10192 assert!(registry
10193 .registration(RegistrationSlot::Active("m"))
10194 .unwrap()
10195 .is_some());
10196 }
10197}
10198
10199fn classify_exit(status: &ExitStatus) -> ExitReport {
10200 ExitReport {
10201 kind: if status.success() {
10202 ExitKind::Clean
10203 } else {
10204 ExitKind::Crash
10205 },
10206 code: status.code(),
10207 signal: exit_signal(status),
10208 at_ms: unix_ms_now(),
10209 }
10210}
10211
10212fn wait_error_exit_report() -> ExitReport {
10218 ExitReport {
10219 kind: ExitKind::Crash,
10220 code: None,
10221 signal: None,
10222 at_ms: unix_ms_now(),
10223 }
10224}
10225
10226#[cfg(unix)]
10227fn exit_signal(status: &ExitStatus) -> Option<i32> {
10228 use std::os::unix::process::ExitStatusExt;
10229
10230 status.signal()
10231}
10232
10233#[cfg(not(unix))]
10234fn exit_signal(_status: &ExitStatus) -> Option<i32> {
10235 None
10236}
10237
10238fn reset_restart_count(snapshot: &SharedSnapshot, module_id: &str) -> Result<(), SuperviseError> {
10244 update_snapshot(snapshot, Some(module_id), |state| {
10245 state.clear_crash_restarts();
10246 })
10247}
10248
10249fn set_running(
10250 snapshot: &SharedSnapshot,
10251 child: &SupervisedChild,
10252 module_id: &str,
10253 spawn_events: &SpawnEventFeed,
10254) -> Result<(), SuperviseError> {
10255 let mut state = snapshot.lock().map_err(|_| SuperviseError::StatePoisoned {
10256 module_id: Some(module_id.to_string()),
10257 })?;
10258 state.spawn_generation = spawn_events.emit_spawned(module_id, child.pid, child.spawned_at_ms);
10259 if std::mem::take(&mut state.coalesced_restart_pending) {
10260 let generation = state.spawn_generation;
10261 info!(module_id, "restart already satisfied by generation {generation}; coalesced pending request completed");
10262 }
10263 state.drain_disposition_detail = None;
10264 state.spawn_failure = None;
10265 state.waiting_for_previous_pid = None;
10266 state.in_alternate_slot = false;
10269 state.configuration_updated_since_spawn = false;
10270 state.spawned_protocol = Some(child.protocol);
10271 state.state = ModuleState::Running;
10272 state.enabled = true;
10273 state.process_alive = true;
10274 state.pid = child.id();
10275 #[cfg(windows)]
10276 {
10277 state.pipe_consumed = child
10278 .nonce_delivery
10279 .as_ref()
10280 .map(|delivery| delivery.consumed());
10281 }
10282 #[cfg(target_os = "macos")]
10283 {
10284 state.report_ready = Some(Arc::clone(&child.report_ready));
10285 }
10286 state.spawned_at_ms = Some(child.spawned_at_ms);
10287 state.spawned_from = Some(child.spawned_from.clone());
10288 state.spawned_file_identity = child.spawned_file_identity.clone();
10289 state.process_start_time = child.process_start_time;
10290 Ok(())
10291}
10292
10293fn clear_current_process_facts(state: &mut SupervisorSnapshot) {
10294 state.waiting_for_previous_pid = None;
10295 state.process_alive = false;
10296 state.spawned_protocol = None;
10297 state.pid = None;
10298 #[cfg(windows)]
10299 {
10300 state.pipe_consumed = None;
10301 }
10302 #[cfg(target_os = "macos")]
10303 {
10304 state.report_ready = None;
10305 }
10306 state.spawned_at_ms = None;
10307 state.spawned_from = None;
10308 state.spawned_file_identity = None;
10309 state.process_start_time = None;
10310 state.deliberate_severance = None;
10311}
10312
10313#[cfg(test)]
10314fn record_deliberate_severance(
10315 snapshot: &SharedSnapshot,
10316 identity: ProcessIdentity,
10317) -> Result<(), SuperviseError> {
10318 update_snapshot(snapshot, None, |state| {
10319 state.deliberate_severance = Some(identity);
10320 })
10321}
10322
10323fn apply_deliberate_severance_marker(
10324 snapshot: &SharedSnapshot,
10325 exited_identity: Option<ProcessIdentity>,
10326 mut exit_report: ExitReport,
10327) -> ExitReport {
10328 let marker = lock_snapshot(snapshot)
10329 .ok()
10330 .and_then(|mut state| state.deliberate_severance.take());
10331 if marker.is_some() && marker == exited_identity {
10332 exit_report.kind = ExitKind::DeliberateSeverance;
10333 }
10334 exit_report
10335}
10336
10337fn classify_reaped_child_exit(
10338 snapshot: &SharedSnapshot,
10339 child: &SupervisedChild,
10340 status: &ExitStatus,
10341) -> ExitReport {
10342 let _ = update_snapshot(snapshot, None, |state| {
10343 state.reaped_pid = Some(child.pid);
10344 state.spawn_failure = child.spawn_failure.clone();
10345 });
10346 apply_deliberate_severance_marker(snapshot, child.process_identity(), classify_exit(status))
10347}
10348
10349fn fail_snapshot(
10350 snapshot: &SharedSnapshot,
10351 module_id: Option<&str>,
10352 last_exit: Option<ExitReport>,
10353) {
10354 if let Err(err) = update_snapshot(snapshot, module_id, |state| {
10355 state.state = ModuleState::Failed;
10356 clear_current_process_facts(state);
10357 if let Some(last_exit) = last_exit {
10358 state.last_exit = Some(last_exit);
10359 }
10360 }) {
10361 error!(error = %err, "failed to mark supervisor state failed");
10362 }
10363}
10364
10365fn update_snapshot(
10366 snapshot: &SharedSnapshot,
10367 module_id: Option<&str>,
10368 update: impl FnOnce(&mut SupervisorSnapshot),
10369) -> Result<(), SuperviseError> {
10370 let mut state = snapshot.lock().map_err(|_| SuperviseError::StatePoisoned {
10371 module_id: module_id.map(ToOwned::to_owned),
10372 })?;
10373 update(&mut state);
10374 Ok(())
10375}
10376
10377const SLOW_SNAPSHOT_LOCK_THRESHOLD: Duration = Duration::from_millis(250);
10378
10379fn lock_snapshot_for_control<'a>(
10380 snapshot: &'a SharedSnapshot,
10381 module_id: &str,
10382 caller: &'static str,
10383) -> Result<std::sync::MutexGuard<'a, SupervisorSnapshot>, SuperviseError> {
10384 let started_at = Instant::now();
10385 let guard = lock_snapshot(snapshot)?;
10386 let waited = started_at.elapsed();
10387 if waited >= SLOW_SNAPSHOT_LOCK_THRESHOLD {
10388 warn!(
10389 module_id = %module_id,
10390 waited_ms = waited.as_millis() as u64,
10391 caller = %caller,
10392 "slow snapshot lock"
10393 );
10394 }
10395 Ok(guard)
10396}
10397
10398fn lock_snapshot(
10399 snapshot: &SharedSnapshot,
10400) -> Result<std::sync::MutexGuard<'_, SupervisorSnapshot>, SuperviseError> {
10401 snapshot
10402 .lock()
10403 .map_err(|_| SuperviseError::StatePoisoned { module_id: None })
10404}
10405
10406#[cfg(test)]
10407mod terminal_history_tests {
10408 use std::{
10409 path::PathBuf,
10410 sync::Arc,
10411 time::{Duration, Instant},
10412 };
10413
10414 use tokio::{sync::mpsc, time::sleep};
10415
10416 use super::{
10417 apply_deliberate_severance_marker, daemon_will_restart, drain_child_to_state,
10418 drained_after_quiescence_wait, handle_reload_spawn_failure, health_restart_child,
10419 lock_snapshot, on_child_exit, record_deliberate_severance, record_wait_error_terminal,
10420 reset_restart_count, spawn_and_mark_running, update_snapshot, wait_error_exit_report,
10421 ExitKind, ExitReport, ModuleProtocol, ModuleSpec, ModuleState, NextAction, ProcessIdentity,
10422 RestartPolicy, SpawnEventKind, StopNotice, SuperviseError, SupervisedConfiguration,
10423 SupervisedModule, SupervisedModuleInner, Supervisor, SupervisorHandle,
10424 SupervisorHealthStatus, SupervisorSnapshot,
10425 };
10426 use super::Instant as ClockInstant;
10431 use crate::{
10432 registry::Registry,
10433 terminal_ring::{TerminalRing, TerminalRingConfig},
10434 };
10435 use std::sync::Mutex;
10436 use subc_control::TerminalDisposition;
10437
10438 pub(super) fn fake_aft_stub_path() -> PathBuf {
10443 let mut path = std::env::current_exe().expect("current_exe available in tests");
10444 path.pop();
10445 path.pop();
10446 path.push(if cfg!(windows) {
10447 "fake-aft-stub.exe"
10448 } else {
10449 "fake-aft-stub"
10450 });
10451 assert!(
10452 path.exists(),
10453 "fake-aft-stub not built at {}: run `cargo test -p subc-core` (which builds \
10454 [[bin]] targets) rather than `cargo test -p subc-core --lib` (which does not)",
10455 path.display()
10456 );
10457 path
10458 }
10459
10460 #[test]
10461 fn reserved_never_spawned_refuses_every_hello() {
10462 let supervisor = SupervisorHandle::default();
10467 supervisor.apply_identity_configuration(&ModuleSpec {
10468 module_id: "never-spawned".to_string(),
10469 program: PathBuf::from("/usr/bin/false"),
10470 args: Vec::new(),
10471 env: Vec::new(),
10472 reserved: true,
10473 reserved_prefixes: Vec::new(),
10474 protocol: ModuleProtocol::Subc,
10475 overlap: Default::default(),
10476 });
10477 assert!(
10478 supervisor
10479 .reserved_hello_rejection("never-spawned", Some("any-forged-nonce"))
10480 .is_some(),
10481 "forged nonce must refuse on a reserved never-spawned id"
10482 );
10483 assert!(
10484 supervisor
10485 .reserved_hello_rejection("never-spawned", None)
10486 .is_some(),
10487 "absent nonce must refuse on a reserved never-spawned id"
10488 );
10489 supervisor.set_spawn_nonce("never-spawned", "minted".to_string());
10491 supervisor.apply_identity_configuration(&ModuleSpec {
10492 module_id: "never-spawned".to_string(),
10493 program: PathBuf::from("/usr/bin/false"),
10494 args: Vec::new(),
10495 env: Vec::new(),
10496 reserved: true,
10497 reserved_prefixes: Vec::new(),
10498 protocol: ModuleProtocol::Subc,
10499 overlap: Default::default(),
10500 });
10501 assert!(supervisor
10502 .reserved_hello_rejection("never-spawned", Some("minted"))
10503 .is_none());
10504 assert!(supervisor
10505 .reserved_hello_rejection("never-spawned", Some("forged"))
10506 .is_some());
10507 }
10508
10509 fn seed_crash_restarts(state: &mut SupervisorSnapshot, count: u32) {
10512 let now = ClockInstant::now();
10513 for _ in 0..count {
10514 state.crash_restarts.push_back(now);
10515 }
10516 }
10517
10518 fn age_oldest_crash_restart_out_of_window(state: &mut SupervisorSnapshot, window: Duration) {
10522 let aged = state
10523 .crash_restarts
10524 .front()
10525 .expect("a crash restart must be recorded before it can be aged")
10526 .checked_sub(window + Duration::from_secs(1))
10527 .expect("the test clock is far enough from its origin to age an instant");
10528 state.crash_restarts[0] = aged;
10529 }
10530
10531 fn snapshot_with_restarts(enabled: bool, count: u32) -> SupervisorSnapshot {
10532 let mut state = SupervisorSnapshot::new(ModuleState::Running, enabled);
10533 seed_crash_restarts(&mut state, count);
10534 state
10535 }
10536
10537 #[test]
10538 fn daemon_owned_recovery_predicate_uses_the_pre_increment_budget() {
10539 let policy = RestartPolicy::new(3, Duration::ZERO);
10540 let now = ClockInstant::now();
10541 assert!(daemon_will_restart(
10542 &mut snapshot_with_restarts(true, 2),
10543 &policy,
10544 now
10545 ));
10546 assert!(!daemon_will_restart(
10547 &mut snapshot_with_restarts(true, 3),
10548 &policy,
10549 now
10550 ));
10551 assert!(!daemon_will_restart(
10552 &mut snapshot_with_restarts(false, 0),
10553 &policy,
10554 now
10555 ));
10556 }
10557
10558 #[test]
10559 fn crash_restart_backoff_escalates_with_in_window_count() {
10560 let policy = RestartPolicy::new(4, Duration::from_millis(100))
10561 .with_max_backoff(Duration::from_secs(30));
10562 let now = ClockInstant::now();
10563 let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
10564 let schedules = (0..4)
10565 .map(|_| {
10566 state
10567 .next_crash_restart(&policy, now)
10568 .expect("the test policy allows four crash restarts")
10569 })
10570 .collect::<Vec<_>>();
10571
10572 assert_eq!(
10573 schedules
10574 .iter()
10575 .map(|schedule| schedule.restart_in_window)
10576 .collect::<Vec<_>>(),
10577 vec![0, 1, 2, 3]
10578 );
10579 assert_eq!(
10580 schedules
10581 .iter()
10582 .map(|schedule| schedule.delay)
10583 .collect::<Vec<_>>(),
10584 vec![
10585 Duration::from_millis(100),
10586 Duration::from_secs(1),
10587 Duration::from_secs(10),
10588 Duration::from_secs(30),
10589 ]
10590 );
10591 }
10592
10593 #[test]
10594 fn crash_restart_backoff_resets_after_ring_clear() {
10595 let policy = RestartPolicy::new(3, Duration::from_millis(100));
10596 let now = ClockInstant::now();
10597 let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
10598 assert_eq!(
10599 state.next_crash_restart(&policy, now).unwrap().delay,
10600 Duration::from_millis(100)
10601 );
10602 assert_eq!(
10603 state.next_crash_restart(&policy, now).unwrap().delay,
10604 Duration::from_secs(1)
10605 );
10606
10607 state.clear_crash_restarts();
10608 let schedule = state
10609 .next_crash_restart(&policy, now)
10610 .expect("a cleared ring must allow another restart");
10611 assert_eq!(schedule.restart_in_window, 0);
10612 assert_eq!(schedule.delay, Duration::from_millis(100));
10613 }
10614
10615 #[test]
10616 fn crash_restart_backoff_ignores_aged_restarts() {
10617 let policy = RestartPolicy::new(3, Duration::from_millis(100));
10618 let now = ClockInstant::now();
10619 let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
10620 state
10621 .next_crash_restart(&policy, now)
10622 .expect("the first restart is allowed");
10623 state
10624 .next_crash_restart(&policy, now)
10625 .expect("the second restart is allowed");
10626 state.crash_restarts[0] = now
10627 .checked_sub(policy.window + Duration::from_secs(1))
10628 .expect("the fake clock can age a restart past the window");
10629
10630 let schedule = state
10631 .next_crash_restart(&policy, now)
10632 .expect("an aged restart must release its slot");
10633 assert_eq!(schedule.restart_in_window, 1);
10634 assert_eq!(schedule.delay, Duration::from_secs(1));
10635 assert_eq!(state.crash_restarts.len(), 2);
10636 }
10637
10638 #[test]
10642 fn a_budget_spent_before_the_window_no_longer_refuses() {
10643 let policy = RestartPolicy::new(3, Duration::ZERO);
10644 let mut state = snapshot_with_restarts(true, 3);
10645 let now = ClockInstant::now();
10646 assert!(!daemon_will_restart(&mut state, &policy, now));
10647
10648 assert!(daemon_will_restart(
10649 &mut state,
10650 &policy,
10651 now + policy.window + Duration::from_secs(1)
10652 ));
10653 assert!(
10654 state.crash_restarts.is_empty(),
10655 "reading the budget must drop the instants that left the window"
10656 );
10657 }
10658
10659 fn module_with_recovery_snapshot(
10660 state: ModuleState,
10661 enabled: bool,
10662 restart_count: u32,
10663 ) -> SupervisedModule {
10664 let registry = Arc::new(Registry::default());
10665 let supervisor =
10666 Supervisor::new_for_test(Arc::clone(®istry), RestartPolicy::new(3, Duration::ZERO));
10667 let runtime = supervisor.runtime_config();
10668 let spec = ModuleSpec {
10669 module_id: "recovery-snapshot".to_string(),
10670 program: fake_aft_stub_path(),
10671 args: Vec::new(),
10672 env: Vec::new(),
10673 reserved: false,
10674 reserved_prefixes: Vec::new(),
10675 protocol: ModuleProtocol::Subc,
10676 overlap: Default::default(),
10677 };
10678 let mut snapshot = SupervisorSnapshot::new(state, enabled);
10679 seed_crash_restarts(&mut snapshot, restart_count);
10680 let (commands, _rx) = mpsc::channel(4);
10683 SupervisedModule {
10684 inner: Arc::new(SupervisedModuleInner {
10685 module_id: spec.module_id.clone(),
10686 registry,
10687 snapshot: Arc::new(Mutex::new(snapshot)),
10688 configuration: Arc::new(Mutex::new(SupervisedConfiguration {
10689 spec,
10690 health: runtime.health,
10691 })),
10692 stderr_ring: runtime.stderr_ring,
10693 terminal_ring: runtime.terminal_ring,
10694 commands,
10695 monitor: Mutex::new(None),
10696 restart_policy: runtime.restart_policy,
10697 effective_drain_timeout: runtime.effective_drain_timeout,
10698 provenance_probe: supervisor.provenance_probe.clone(),
10699 }),
10700 }
10701 }
10702
10703 #[cfg(target_os = "linux")]
10704 #[tokio::test]
10705 async fn no_cgroup_placement_does_not_block_fake_aft_stub_spawn() {
10706 let supervisor =
10707 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
10708 .with_cgroup_placement(None);
10709 let result = supervisor.spawn(ModuleSpec {
10710 module_id: "no-cgroup-placement".to_string(),
10711 program: fake_aft_stub_path(),
10712 args: Vec::new(),
10713 env: Vec::new(),
10714 reserved: false,
10715 reserved_prefixes: Vec::new(),
10716 protocol: ModuleProtocol::Subc,
10717 overlap: Default::default(),
10718 });
10719
10720 assert!(
10721 result.is_ok(),
10722 "no delegation must not turn an otherwise valid spawn into a failure: {result:?}"
10723 );
10724 }
10725
10726 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10727 async fn undecided_snapshot_uses_shared_restart_predicate() {
10728 assert!(module_with_recovery_snapshot(ModuleState::Running, true, 2)
10729 .will_recover_after_connection_loss()
10730 .unwrap());
10731 assert!(
10732 !module_with_recovery_snapshot(ModuleState::Running, true, 3)
10733 .will_recover_after_connection_loss()
10734 .unwrap()
10735 );
10736 }
10737
10738 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10739 async fn restarting_snapshot_at_exhausted_budget_is_non_terminal() {
10740 assert!(
10741 module_with_recovery_snapshot(ModuleState::Restarting, true, 3)
10742 .will_recover_after_connection_loss()
10743 .unwrap()
10744 );
10745 }
10746
10747 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10748 async fn terminal_phase_snapshots_are_terminal_before_budget_exhaustion() {
10749 assert!(!module_with_recovery_snapshot(ModuleState::Failed, true, 0)
10750 .will_recover_after_connection_loss()
10751 .unwrap());
10752 assert!(
10753 !module_with_recovery_snapshot(ModuleState::Disabled, true, 0)
10754 .will_recover_after_connection_loss()
10755 .unwrap()
10756 );
10757 }
10758
10759 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10760 async fn warming_snapshot_is_limited_to_startup_phases() {
10761 for state in [
10762 ModuleState::Starting,
10763 ModuleState::Running,
10764 ModuleState::Restarting,
10765 ] {
10766 assert!(
10767 module_with_recovery_snapshot(state, true, 0)
10768 .is_warming()
10769 .unwrap(),
10770 "{state:?} should be warming"
10771 );
10772 }
10773 for state in [
10774 ModuleState::Unresponsive,
10775 ModuleState::Draining,
10776 ModuleState::Stopped,
10777 ModuleState::Failed,
10778 ModuleState::Disabled,
10779 ] {
10780 assert!(
10781 !module_with_recovery_snapshot(state, true, 0)
10782 .is_warming()
10783 .unwrap(),
10784 "{state:?} should not be warming"
10785 );
10786 }
10787 }
10788
10789 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10790 async fn terminal_history_survives_respawn_and_keeps_both_crashes_in_order() {
10791 let registry = Arc::new(Registry::default());
10792 let supervisor =
10793 Supervisor::new_for_test(Arc::clone(®istry), RestartPolicy::new(1, Duration::ZERO));
10794 let module = supervisor
10795 .spawn(ModuleSpec {
10796 module_id: "terminal-history".to_string(),
10797 program: fake_aft_stub_path(),
10798 args: Vec::new(),
10799 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
10800 reserved: false,
10801 reserved_prefixes: Vec::new(),
10802 protocol: ModuleProtocol::Subc,
10803 overlap: Default::default(),
10804 })
10805 .unwrap();
10806
10807 let deadline = Instant::now() + Duration::from_secs(5);
10808 loop {
10809 let history = module.terminal_history();
10810 if history.entries.len() == 2 {
10811 assert_eq!(module.status().unwrap().state, ModuleState::Failed);
10812 assert_eq!(history.dropped, 0);
10813 assert_eq!(
10814 history
10815 .entries
10816 .iter()
10817 .map(|entry| entry.exit_code)
10818 .collect::<Vec<_>>(),
10819 vec![Some(23), Some(23)]
10820 );
10821 assert!(history.entries[0].at_ms <= history.entries[1].at_ms);
10822 return;
10823 }
10824 assert!(
10825 Instant::now() < deadline,
10826 "module did not retain two terminal exits: {history:?}"
10827 );
10828 sleep(Duration::from_millis(10)).await;
10829 }
10830 }
10831
10832 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10836 async fn disable_during_crash_backoff_cancels_pending_respawn() {
10837 let backoff = Duration::from_secs(2);
10838 let supervisor = Supervisor::new_for_test(
10839 Arc::new(Registry::default()),
10840 RestartPolicy::new(10, backoff),
10841 );
10842 let module = supervisor
10843 .spawn(ModuleSpec {
10844 module_id: "disable-during-backoff".to_string(),
10845 program: fake_aft_stub_path(),
10846 args: Vec::new(),
10847 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
10848 reserved: false,
10849 reserved_prefixes: Vec::new(),
10850 protocol: ModuleProtocol::Subc,
10851 overlap: Default::default(),
10852 })
10853 .unwrap();
10854
10855 let deadline = Instant::now() + Duration::from_secs(5);
10857 loop {
10858 if module.status().unwrap().state == ModuleState::Restarting {
10859 break;
10860 }
10861 assert!(
10862 Instant::now() < deadline,
10863 "module never entered the crash backoff"
10864 );
10865 sleep(Duration::from_millis(10)).await;
10866 }
10867
10868 let started = Instant::now();
10869 module.set_enabled(false).await.unwrap();
10870 let waited = started.elapsed();
10871
10872 assert!(
10873 waited < backoff / 2,
10874 "disable waited {waited:?} behind the {backoff:?} crash backoff; the operator command must preempt the pending respawn"
10875 );
10876 assert_eq!(module.status().unwrap().state, ModuleState::Disabled);
10877
10878 sleep(backoff + Duration::from_millis(500)).await;
10880 let status = module.status().unwrap();
10881 assert_eq!(status.state, ModuleState::Disabled);
10882 assert_eq!(
10883 status.spawn_generation, 1,
10884 "module respawned after the operator disabled it"
10885 );
10886 }
10887
10888 #[cfg(unix)]
10891 fn protocol_none_sigterm_exits_clean_spec(
10892 module_id: &str,
10893 dir: &std::path::Path,
10894 ) -> (ModuleSpec, PathBuf, PathBuf) {
10895 let ready = dir.join("ready");
10896 let marker = dir.join("sigterm");
10897 let spec = ModuleSpec {
10898 module_id: module_id.to_string(),
10899 program: fake_aft_stub_path(),
10900 args: Vec::new(),
10901 env: vec![
10902 ("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string()),
10903 (
10904 "FAKE_AFT_SIGTERM_MARKER_PATH".to_string(),
10905 marker.display().to_string(),
10906 ),
10907 (
10908 "FAKE_AFT_NEVER_CONNECT_READY_PATH".to_string(),
10909 ready.display().to_string(),
10910 ),
10911 ],
10912 reserved: false,
10913 reserved_prefixes: Vec::new(),
10914 protocol: ModuleProtocol::None,
10915 overlap: Default::default(),
10916 };
10917 (spec, ready, marker)
10918 }
10919
10920 #[cfg(unix)]
10924 async fn wait_for_file(path: &std::path::Path) {
10925 let deadline = Instant::now() + Duration::from_secs(10);
10926 while !path.exists() {
10927 assert!(
10928 Instant::now() < deadline,
10929 "{} never appeared",
10930 path.display()
10931 );
10932 sleep(Duration::from_millis(10)).await;
10933 }
10934 }
10935
10936 #[cfg(unix)]
10940 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10941 async fn protocol_none_unrequested_clean_exit_restarts_as_a_crash() {
10942 let dir = cortexkit_test_support::ScratchDir::new("none-unrequested-clean-exit");
10943 let (spec, ready, marker) =
10944 protocol_none_sigterm_exits_clean_spec("none-unrequested-clean-exit", dir.path());
10945 let supervisor = Supervisor::new_for_test(
10946 Arc::new(Registry::default()),
10947 RestartPolicy::new(3, Duration::ZERO),
10948 );
10949 let module = supervisor.spawn(spec).unwrap();
10950 wait_for_file(&ready).await;
10951 let deadline = Instant::now() + Duration::from_secs(10);
10955 let first_pid = loop {
10956 let status = module.status().unwrap();
10957 if status.state == ModuleState::Running {
10958 if let Some(pid) = status.pid {
10959 break pid;
10960 }
10961 }
10962 assert!(
10963 Instant::now() < deadline,
10964 "a running module must report its pid after exec confirmation: {status:?}"
10965 );
10966 sleep(Duration::from_millis(10)).await;
10967 };
10968
10969 rustix::process::kill_process(
10970 rustix::process::Pid::from_raw(i32::try_from(first_pid).unwrap()).unwrap(),
10971 rustix::process::Signal::TERM,
10972 )
10973 .unwrap();
10974
10975 let deadline = Instant::now() + Duration::from_secs(10);
10976 let respawned = loop {
10977 let status = module.status().unwrap();
10978 if status.state == ModuleState::Running
10979 && status.pid.is_some_and(|pid| pid != first_pid)
10980 {
10981 break status;
10982 }
10983 assert!(
10984 Instant::now() < deadline,
10985 "protocol-none module was not respawned after an unrequested clean exit: {status:?}"
10986 );
10987 sleep(Duration::from_millis(10)).await;
10988 };
10989 assert_eq!(respawned.spawn_generation, 2);
10990 assert!(
10991 marker.exists(),
10992 "the child must have exited through its SIGTERM handler (exit 0), or this proves nothing about clean exits"
10993 );
10994
10995 let history = module.terminal_history();
10996 assert_eq!(history.entries.len(), 1, "{history:?}");
10997 let entry = &history.entries[0];
10998 assert_eq!(entry.exit_code, Some(0));
10999 assert_eq!(entry.exit_kind, subc_control::TerminalExitKind::Clean);
11000 assert_eq!(entry.disposition, TerminalDisposition::Restarting);
11001
11002 module.stop().await.unwrap();
11003 }
11004
11005 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11009 async fn protocol_none_repeated_clean_exits_exhaust_the_restart_budget() {
11010 let supervisor = Supervisor::new_for_test(
11011 Arc::new(Registry::default()),
11012 RestartPolicy::new(1, Duration::ZERO),
11013 );
11014 let module = supervisor
11015 .spawn(ModuleSpec {
11016 module_id: "none-clean-exit-budget".to_string(),
11017 program: fake_aft_stub_path(),
11018 args: Vec::new(),
11019 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "0".to_string())],
11020 reserved: false,
11021 reserved_prefixes: Vec::new(),
11022 protocol: ModuleProtocol::None,
11023 overlap: Default::default(),
11024 })
11025 .unwrap();
11026
11027 let deadline = Instant::now() + Duration::from_secs(10);
11030 loop {
11031 let status = module.status().unwrap();
11032 if status.state == ModuleState::Failed {
11033 break;
11034 }
11035 assert!(
11036 Instant::now() < deadline,
11037 "module never exhausted its budget: {status:?} {:?}",
11038 module.terminal_history()
11039 );
11040 sleep(Duration::from_millis(10)).await;
11041 }
11042 let history = module.terminal_history();
11043 assert_eq!(
11044 history
11045 .entries
11046 .iter()
11047 .map(|entry| (entry.exit_code, entry.disposition.clone()))
11048 .collect::<Vec<_>>(),
11049 vec![
11050 (Some(0), TerminalDisposition::Restarting),
11051 (Some(0), TerminalDisposition::Failed),
11052 ]
11053 );
11054 let detail = history.entries[1]
11055 .disposition_detail
11056 .as_deref()
11057 .expect("a budget failure names the budget");
11058 assert!(detail.contains("max_restarts=1"), "{detail}");
11059 assert_eq!(module.status().unwrap().spawn_generation, 2);
11060 }
11061
11062 #[test]
11063 fn restart_budget_failure_is_published_after_its_terminal_record() {
11064 use std::io::{Read, Write};
11065 let supervisor = Supervisor::new_for_test(
11066 Arc::new(Registry::default()),
11067 RestartPolicy::new(0, Duration::ZERO),
11068 );
11069 let runtime = supervisor.runtime_config();
11070 let spec = ModuleSpec {
11071 protocol: ModuleProtocol::None,
11072 ..windowed_crash_spec("budget-publication-order")
11073 };
11074 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(
11075 ModuleState::Running,
11076 true,
11077 )));
11078 lock_snapshot(&snapshot).unwrap().process_alive = true;
11079 let reaped_snapshot = snapshot.clone();
11080 let mut barrier = terminal_record_barrier(&runtime.spawn_events);
11081 let history = runtime.terminal_ring.clone();
11082 let reap = std::thread::spawn(move || {
11083 tokio::runtime::Builder::new_current_thread()
11084 .enable_all()
11085 .build()
11086 .unwrap()
11087 .block_on(on_child_exit(
11088 &spec,
11089 runtime.restart_policy,
11090 &supervisor.registry,
11091 &reaped_snapshot,
11092 &runtime.terminal_ring,
11093 &runtime.spawn_events,
11094 &runtime.child_roster,
11095 ExitReport {
11096 kind: ExitKind::Clean,
11097 code: Some(0),
11098 signal: None,
11099 at_ms: 1,
11100 },
11101 ))
11102 });
11103 let mut reached = [0];
11104 barrier.read_exact(&mut reached).unwrap();
11105 assert_eq!(&reached, b"R");
11106 let (before_state, before_alive) = {
11107 let state = lock_snapshot(&snapshot).unwrap();
11108 (state.state, state.process_alive)
11109 };
11110 let before_history = history.lock().unwrap().snapshot();
11111 barrier.write_all(b"X").unwrap();
11112 assert!(matches!(reap.join().unwrap(), NextAction::Stop { .. }));
11113 assert!(before_history.entries.is_empty());
11114 assert_ne!(
11115 before_state,
11116 ModuleState::Failed,
11117 "Failed was visible before its terminal record could be written"
11118 );
11119 assert!(before_alive);
11120 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
11121 let history = history.lock().unwrap().snapshot();
11122 assert_eq!(history.entries.len(), 1);
11123 assert_eq!(history.entries[0].exit_code, Some(0));
11124 assert_eq!(history.entries[0].disposition, TerminalDisposition::Failed);
11125 }
11126
11127 #[test]
11128 fn crash_restart_at_budget_limit_remains_recoverable_while_recording() {
11129 use std::io::{Read, Write};
11130
11131 let supervisor = Supervisor::new_for_test(
11132 Arc::new(Registry::default()),
11133 RestartPolicy::new(3, Duration::ZERO),
11134 );
11135 let mut runtime = supervisor.runtime_config();
11136 let module = module_with_recovery_snapshot(ModuleState::Running, true, 2);
11137 let snapshot = module.inner.snapshot.clone();
11138 lock_snapshot(&snapshot).unwrap().process_alive = true;
11139 runtime.terminal_ring = module.inner.terminal_ring.clone();
11140 let spec = windowed_crash_spec(module.module_id());
11141 let mut barrier = terminal_record_barrier(&runtime.spawn_events);
11142 let reaped_snapshot = snapshot.clone();
11143 let reap = std::thread::spawn(move || {
11144 tokio::runtime::Builder::new_current_thread()
11145 .enable_all()
11146 .build()
11147 .unwrap()
11148 .block_on(on_child_exit(
11149 &spec,
11150 runtime.restart_policy,
11151 &supervisor.registry,
11152 &reaped_snapshot,
11153 &runtime.terminal_ring,
11154 &runtime.spawn_events,
11155 &runtime.child_roster,
11156 ExitReport {
11157 kind: ExitKind::Crash,
11158 code: Some(2),
11159 signal: None,
11160 at_ms: 1,
11161 },
11162 ))
11163 });
11164
11165 let mut reached = [0];
11169 barrier.read_exact(&mut reached).unwrap();
11170 assert_eq!(&reached, b"R");
11171 let before_status = module.status().unwrap();
11172 let before_history = module.terminal_history();
11173 let before_recovers = module.will_recover_after_connection_loss().unwrap();
11174 barrier.write_all(b"X").unwrap();
11175 assert!(matches!(reap.join().unwrap(), NextAction::Restart { .. }));
11176
11177 assert_eq!(before_status.state, ModuleState::Running);
11178 assert!(before_status.process_alive);
11179 assert_eq!(before_status.restart_count, 3);
11180 assert!(before_history.entries.is_empty());
11181 assert!(
11182 before_recovers,
11183 "connection cleanup would report a terminal close for a scheduled crash restart"
11184 );
11185 let restarting = module.status().unwrap();
11186 assert_eq!(restarting.state, ModuleState::Restarting);
11187 assert_eq!(restarting.restart_count, 3);
11188 assert!(module.will_recover_after_connection_loss().unwrap());
11189 let history = module.terminal_history();
11190 assert_eq!(history.entries.len(), 1);
11191 assert_eq!(
11192 history.entries[0].disposition,
11193 TerminalDisposition::Restarting
11194 );
11195
11196 update_snapshot(&snapshot, Some(module.module_id()), |state| {
11199 state.state = ModuleState::Running;
11200 state.respawn_pending = false;
11201 })
11202 .unwrap();
11203 assert!(!module.will_recover_after_connection_loss().unwrap());
11204 update_snapshot(&snapshot, Some(module.module_id()), |state| {
11205 state.enabled = false;
11206 state.respawn_pending = true;
11207 })
11208 .unwrap();
11209 assert!(!module.will_recover_after_connection_loss().unwrap());
11210 }
11211
11212 fn terminal_record_barrier(events: &super::SpawnEventFeed) -> std::net::TcpStream {
11213 use std::net::{TcpListener, TcpStream};
11214 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
11215 let writer = TcpStream::connect(listener.local_addr().unwrap()).unwrap();
11216 let (barrier, _) = listener.accept().unwrap();
11217 for stream in [&writer, &barrier] {
11218 stream
11219 .set_read_timeout(Some(Duration::from_secs(10)))
11220 .unwrap();
11221 stream
11222 .set_write_timeout(Some(Duration::from_secs(10)))
11223 .unwrap();
11224 }
11225 *events.1.lock().unwrap() = Some(writer);
11226 barrier
11227 }
11228
11229 pub(super) fn before_terminal_record(events: &super::SpawnEventFeed) {
11230 use std::io::{Read, Write};
11231 let barrier = events.1.lock().unwrap().take();
11232 if let Some(mut barrier) = barrier {
11233 barrier.write_all(b"R").unwrap();
11234 let mut release = [0];
11235 barrier.read_exact(&mut release).unwrap();
11236 assert_eq!(&release, b"X");
11237 }
11238 }
11239
11240 #[test]
11241 fn health_restart_budget_failure_is_published_after_its_terminal_record() {
11242 use std::io::{Read, Write};
11243
11244 let supervisor = Supervisor::new_for_test(
11245 Arc::new(Registry::default()),
11246 RestartPolicy::new(0, Duration::ZERO),
11247 );
11248 let mut runtime = supervisor.runtime_config();
11249 let module = module_with_recovery_snapshot(ModuleState::Running, true, 0);
11250 let snapshot = module.inner.snapshot.clone();
11251 runtime.terminal_ring = module.inner.terminal_ring.clone();
11252 let spec = ModuleSpec {
11253 module_id: module.module_id().into(),
11254 program: fake_aft_stub_path(),
11255 protocol: ModuleProtocol::None,
11256 env: vec![("FAKE_AFT_EXIT_CODE".into(), "17".into())],
11257 ..windowed_crash_spec(module.module_id())
11258 };
11259 let mut barrier = terminal_record_barrier(&runtime.spawn_events);
11260 let reap = std::thread::spawn(move || {
11261 tokio::runtime::Builder::new_current_thread()
11262 .enable_all()
11263 .build()
11264 .unwrap()
11265 .block_on(async {
11266 let mut child =
11267 Some(spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap());
11268 health_restart_child(
11269 &spec,
11270 &runtime,
11271 &supervisor.registry,
11272 &supervisor.process_liveness,
11273 &snapshot,
11274 &mut child,
11275 SupervisorHealthStatus::Failing,
11276 Some("health probe did not answer"),
11277 1,
11278 )
11279 .await
11280 .unwrap();
11281 assert!(child.is_none());
11282 });
11283 });
11284 let mut reached = [0];
11289 barrier.read_exact(&mut reached).unwrap();
11290 assert_eq!(&reached, b"R");
11291 let before_status = module.status().unwrap();
11292 let before_history = module.terminal_history();
11293 barrier.write_all(b"X").unwrap();
11294 reap.join().unwrap();
11295 assert!(before_history.entries.is_empty());
11296 assert_ne!(
11297 before_status.state,
11298 ModuleState::Failed,
11299 "Failed was visible before its terminal record could be written"
11300 );
11301 assert!(
11302 before_status.process_alive,
11303 "process death was visible before its terminal record could be written"
11304 );
11305 let failed = module.status().unwrap();
11306 assert_eq!(failed.state, ModuleState::Failed);
11307 assert!(!failed.process_alive);
11308 let history = module.terminal_history();
11309 assert_eq!(history.entries.len(), 1);
11310 assert_eq!(history.entries[0].disposition, TerminalDisposition::Failed);
11311 assert_eq!(
11312 history.entries[0].disposition_detail.as_deref(),
11313 Some("crash budget exhausted: max_restarts=0 within window_secs=600")
11314 );
11315 }
11316
11317 #[test]
11318 fn observed_exits_publish_process_death_after_terminal_history() {
11319 use std::io::{Read, Write};
11320 for (kind, enabled, shutdown, wait_error, expected_state) in [
11321 (ExitKind::Clean, true, false, false, ModuleState::Stopped),
11322 (ExitKind::Crash, true, false, false, ModuleState::Restarting),
11323 (ExitKind::Crash, false, false, false, ModuleState::Disabled),
11324 (
11325 ExitKind::DeliberateSeverance,
11326 true,
11327 false,
11328 false,
11329 ModuleState::Restarting,
11330 ),
11331 #[cfg(unix)]
11332 (ExitKind::Crash, true, true, false, ModuleState::Stopped),
11333 (ExitKind::Crash, true, false, true, ModuleState::Failed),
11334 ] {
11335 let supervisor = Supervisor::new_for_test(
11336 Arc::new(Registry::default()),
11337 RestartPolicy::new(1, Duration::ZERO),
11338 );
11339 let runtime = supervisor.runtime_config();
11340 #[cfg(unix)]
11341 if shutdown {
11342 runtime.child_roster.close();
11343 }
11344 #[cfg(not(unix))]
11345 let _ = shutdown;
11346 let spec = windowed_crash_spec("exit-publication-order");
11347 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(
11348 ModuleState::Running,
11349 enabled,
11350 )));
11351 lock_snapshot(&snapshot).unwrap().process_alive = true;
11352 let reaped_snapshot = snapshot.clone();
11353 let history = runtime.terminal_ring.clone();
11354 let mut barrier = terminal_record_barrier(&runtime.spawn_events);
11355 let reap = std::thread::spawn(move || {
11356 if wait_error {
11357 super::fail_after_wait_error(
11358 &spec.module_id,
11359 &reaped_snapshot,
11360 &runtime.terminal_ring,
11361 &runtime.spawn_events,
11362 "wait failed".into(),
11363 );
11364 } else {
11365 tokio::runtime::Builder::new_current_thread()
11366 .enable_all()
11367 .build()
11368 .unwrap()
11369 .block_on(on_child_exit(
11370 &spec,
11371 runtime.restart_policy,
11372 &supervisor.registry,
11373 &reaped_snapshot,
11374 &runtime.terminal_ring,
11375 &runtime.spawn_events,
11376 &runtime.child_roster,
11377 ExitReport {
11378 kind,
11379 code: Some(0),
11380 signal: None,
11381 at_ms: 1,
11382 },
11383 ));
11384 }
11385 });
11386 let mut reached = [0];
11387 barrier.read_exact(&mut reached).unwrap();
11388 assert_eq!(&reached, b"R");
11389 let before_alive = lock_snapshot(&snapshot).unwrap().process_alive;
11390 let before_history = history.lock().unwrap().snapshot();
11391 barrier.write_all(b"X").unwrap();
11392 reap.join().unwrap();
11393 assert!(
11394 before_alive,
11395 "{kind:?}: death was published before recording"
11396 );
11397 assert!(before_history.entries.is_empty());
11398 assert_eq!(lock_snapshot(&snapshot).unwrap().state, expected_state);
11399 assert!(!lock_snapshot(&snapshot).unwrap().process_alive);
11400 assert_eq!(history.lock().unwrap().snapshot().entries.len(), 1);
11401 }
11402 }
11403
11404 #[tokio::test]
11405 async fn health_restart_budget_failure_remains_failed_when_journal_append_fails() {
11406 let dir = cortexkit_test_support::ScratchDir::new("health-budget-journal-failure");
11407 let path = dir.join("terminals.jsonl");
11408 std::fs::create_dir(&path).unwrap();
11409 let supervisor = Supervisor::new_for_test(
11410 Arc::new(Registry::default()),
11411 RestartPolicy::new(0, Duration::ZERO),
11412 )
11413 .with_terminal_journal(path, "health-budget-journal-failure".into());
11414 let runtime = supervisor.runtime_config();
11415 let spec = ModuleSpec {
11416 program: fake_aft_stub_path(),
11417 protocol: ModuleProtocol::None,
11418 env: vec![("FAKE_AFT_EXIT_CODE".into(), "17".into())],
11419 ..windowed_crash_spec("health-budget-journal-failure")
11420 };
11421 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11422 let mut child = Some(spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap());
11423 health_restart_child(
11424 &spec,
11425 &runtime,
11426 &supervisor.registry,
11427 &supervisor.process_liveness,
11428 &snapshot,
11429 &mut child,
11430 SupervisorHealthStatus::Failing,
11431 None,
11432 1,
11433 )
11434 .await
11435 .unwrap();
11436 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
11437 assert!(!lock_snapshot(&snapshot).unwrap().process_alive);
11438 let history = runtime
11439 .terminal_ring
11440 .lock()
11441 .unwrap()
11442 .durable_history(&spec.module_id);
11443 assert!(history.journal_write_failures > 0);
11444 assert_eq!(history.entries.len(), 1);
11445 assert_eq!(history.entries[0].disposition, TerminalDisposition::Failed);
11446 }
11447
11448 #[test]
11449 fn health_restart_budget_failure_remains_failed_when_exit_recording_panics() {
11450 let supervisor = Supervisor::new_for_test(
11451 Arc::new(Registry::default()),
11452 RestartPolicy::new(0, Duration::ZERO),
11453 );
11454 let runtime = supervisor.runtime_config();
11455 let spec = ModuleSpec {
11456 program: fake_aft_stub_path(),
11457 protocol: ModuleProtocol::None,
11458 env: vec![("FAKE_AFT_EXIT_CODE".into(), "17".into())],
11459 ..windowed_crash_spec("health-budget-recording-panic")
11460 };
11461 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11462 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
11463 tokio::runtime::Builder::new_current_thread()
11464 .enable_all()
11465 .build()
11466 .unwrap()
11467 .block_on(async {
11468 let mut child =
11469 Some(spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap());
11470 runtime.spawn_events.0.lock().unwrap().seq = u64::MAX;
11471 health_restart_child(
11472 &spec,
11473 &runtime,
11474 &supervisor.registry,
11475 &supervisor.process_liveness,
11476 &snapshot,
11477 &mut child,
11478 SupervisorHealthStatus::Failing,
11479 None,
11480 1,
11481 )
11482 .await
11483 .unwrap();
11484 });
11485 }));
11486 let panic = result.expect_err("recording must still unwind");
11487 assert_eq!(
11488 panic.downcast_ref::<String>().map(String::as_str),
11489 Some("spawn event sequence exhausted")
11490 );
11491 let state = lock_snapshot(&snapshot).unwrap();
11492 assert_eq!(state.state, ModuleState::Failed);
11493 assert!(!state.process_alive);
11494 assert!(state.last_exit.is_some());
11495 }
11496
11497 #[tokio::test]
11498 async fn restart_budget_failure_remains_failed_when_journal_append_fails() {
11499 let dir = cortexkit_test_support::ScratchDir::new("budget-journal-failure");
11500 let path = dir.join("terminals.jsonl");
11501 std::fs::create_dir(&path).unwrap();
11502 let supervisor = Supervisor::new_for_test(
11503 Arc::new(Registry::default()),
11504 RestartPolicy::new(0, Duration::ZERO),
11505 )
11506 .with_terminal_journal(path, "budget-journal-failure".into());
11507 let runtime = supervisor.runtime_config();
11508 let spec = windowed_crash_spec("budget-journal-failure");
11509 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11510 assert!(matches!(
11511 on_child_exit(
11512 &spec,
11513 runtime.restart_policy,
11514 &supervisor.registry,
11515 &snapshot,
11516 &runtime.terminal_ring,
11517 &runtime.spawn_events,
11518 &runtime.child_roster,
11519 crash_exit_report(1),
11520 )
11521 .await,
11522 NextAction::Stop { .. }
11523 ));
11524 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
11525 let history = runtime
11526 .terminal_ring
11527 .lock()
11528 .unwrap()
11529 .durable_history(&spec.module_id);
11530 assert!(history.journal_write_failures > 0);
11531 assert_eq!(history.entries.len(), 1);
11532 assert_eq!(history.entries[0].disposition, TerminalDisposition::Failed);
11533 }
11534
11535 #[test]
11536 fn restart_budget_failure_remains_failed_when_exit_recording_panics() {
11537 let supervisor = Supervisor::new_for_test(
11538 Arc::new(Registry::default()),
11539 RestartPolicy::new(0, Duration::ZERO),
11540 );
11541 let runtime = supervisor.runtime_config();
11542 let spec = windowed_crash_spec("budget-recording-panic");
11543 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11544 runtime.spawn_events.emit_spawned(&spec.module_id, 1, 1);
11545 runtime.spawn_events.0.lock().unwrap().seq = u64::MAX;
11548 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
11549 tokio::runtime::Builder::new_current_thread()
11550 .enable_all()
11551 .build()
11552 .unwrap()
11553 .block_on(on_child_exit(
11554 &spec,
11555 runtime.restart_policy,
11556 &supervisor.registry,
11557 &snapshot,
11558 &runtime.terminal_ring,
11559 &runtime.spawn_events,
11560 &runtime.child_roster,
11561 crash_exit_report(1),
11562 ))
11563 }));
11564 let panic = result.err().expect("recording must still unwind");
11565 assert_eq!(
11566 panic.downcast_ref::<String>().map(String::as_str),
11567 Some("spawn event sequence exhausted")
11568 );
11569 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
11570 }
11571
11572 #[cfg(unix)]
11575 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11576 async fn protocol_none_requested_stop_and_disable_do_not_respawn() {
11577 for disable in [false, true] {
11578 let label = if disable {
11579 "none-requested-disable"
11580 } else {
11581 "none-requested-stop"
11582 };
11583 let dir = cortexkit_test_support::ScratchDir::new(label);
11584 let (spec, ready, marker) = protocol_none_sigterm_exits_clean_spec(label, dir.path());
11585 let supervisor = Supervisor::new_for_test(
11586 Arc::new(Registry::default()),
11587 RestartPolicy::new(3, Duration::ZERO),
11588 );
11589 let module = supervisor.spawn(spec).unwrap();
11590 wait_for_file(&ready).await;
11591
11592 if disable {
11593 module.set_enabled(false).await.unwrap();
11594 } else {
11595 module.stop().await.unwrap();
11596 }
11597 assert!(
11598 marker.exists(),
11599 "{label}: the child must have left through its SIGTERM handler with exit 0"
11600 );
11601
11602 sleep(Duration::from_millis(500)).await;
11605 let status = module.status().unwrap();
11606 let expected = if disable {
11607 ModuleState::Disabled
11608 } else {
11609 ModuleState::Stopped
11610 };
11611 assert_eq!(status.state, expected, "{label}");
11612 assert_eq!(
11613 status.spawn_generation, 1,
11614 "{label}: respawned after a requested stop"
11615 );
11616 let history = module.terminal_history();
11617 assert_eq!(history.entries.len(), 1, "{label}: {history:?}");
11618 assert_eq!(history.entries[0].exit_code, Some(0), "{label}");
11619 assert_ne!(
11620 history.entries[0].disposition,
11621 TerminalDisposition::Restarting,
11622 "{label}"
11623 );
11624 }
11625 }
11626
11627 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11630 async fn subc_wire_clean_exit_is_still_a_stop() {
11631 let supervisor = Supervisor::new_for_test(
11632 Arc::new(Registry::default()),
11633 RestartPolicy::new(3, Duration::ZERO),
11634 );
11635 let module = supervisor
11636 .spawn(ModuleSpec {
11637 module_id: "wire-clean-exit".to_string(),
11638 program: fake_aft_stub_path(),
11639 args: Vec::new(),
11640 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "0".to_string())],
11641 reserved: false,
11642 reserved_prefixes: Vec::new(),
11643 protocol: ModuleProtocol::Subc,
11644 overlap: Default::default(),
11645 })
11646 .unwrap();
11647
11648 let deadline = Instant::now() + Duration::from_secs(10);
11649 while module.terminal_history().entries.is_empty() {
11650 assert!(Instant::now() < deadline, "module never exited");
11651 sleep(Duration::from_millis(10)).await;
11652 }
11653 sleep(Duration::from_millis(500)).await;
11655 let status = module.status().unwrap();
11656 assert_eq!(status.state, ModuleState::Stopped);
11657 assert_eq!(status.spawn_generation, 1);
11658 let history = module.terminal_history();
11659 assert_eq!(history.entries.len(), 1, "{history:?}");
11660 assert_eq!(history.entries[0].exit_code, Some(0));
11661 assert_eq!(history.entries[0].disposition, TerminalDisposition::Stopped);
11662 }
11663
11664 #[cfg(unix)]
11665 #[tokio::test]
11666 async fn reload_exit_keeps_roster_until_terminal_is_recorded_during_shutdown() {
11667 let dir = cortexkit_test_support::ScratchDir::new("reload-roster-terminal-order");
11668 let record = dir.join("live-children.json");
11669 let supervisor = Supervisor::new_for_test(
11670 Arc::new(Registry::default()),
11671 RestartPolicy::new(0, Duration::ZERO),
11672 );
11673 let mut runtime = supervisor.runtime_config();
11674 runtime.child_roster.record_to(record.clone());
11675 let gate = Arc::new(super::ReloadExitRecordGate::default());
11676 runtime.test_reload_exit_record_gate = Some(Arc::clone(&gate));
11677 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11678 let spec = ModuleSpec {
11679 module_id: "reload-exit-roster".into(),
11680 program: fake_aft_stub_path(),
11681 args: Vec::new(),
11682 env: vec![("FAKE_AFT_EXIT_CODE".into(), "23".into())],
11683 reserved: false,
11684 reserved_prefixes: Vec::new(),
11685 protocol: ModuleProtocol::Subc,
11686 overlap: Default::default(),
11687 };
11688 let mut child = None;
11689 let reload = super::finish_reload_child(
11690 &spec,
11691 &runtime,
11692 &supervisor.registry,
11693 &supervisor.process_liveness,
11694 &snapshot,
11695 &mut child,
11696 );
11697 tokio::pin!(reload);
11698 tokio::select! {
11699 result = &mut reload => panic!("reload missed the before-record gate: {result:?}"),
11700 _ = gate.reached.notified() => {}
11701 }
11702 assert!(runtime
11703 .terminal_ring
11704 .lock()
11705 .unwrap()
11706 .snapshot()
11707 .entries
11708 .is_empty());
11709 assert_eq!(
11710 crate::live_children::read_record(&record).unwrap().len(),
11711 1,
11712 "shutdown must still wait for the reaped child until its terminal record exists"
11713 );
11714 runtime.child_roster.close();
11715 gate.resume.notify_one();
11716 assert!(reload.await.is_err());
11717 assert!(crate::live_children::read_record(&record)
11718 .unwrap()
11719 .is_empty());
11720 let history = runtime.terminal_ring.lock().unwrap().snapshot();
11721 assert_eq!(history.entries.len(), 1);
11722 assert_eq!(
11723 history.entries[0].disposition,
11724 TerminalDisposition::DaemonShutdown
11725 );
11726 }
11727
11728 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11732 async fn every_restart_increment_path_advances_lifetime_count() {
11733 let supervisor = Supervisor::new_for_test(
11734 Arc::new(Registry::default()),
11735 RestartPolicy::new(1, Duration::ZERO),
11736 );
11737 let runtime = supervisor.runtime_config();
11738 let spec = ModuleSpec {
11739 module_id: "lifetime-increment-path".to_string(),
11740 program: PathBuf::from("/unused/lifetime-increment-path"),
11741 args: Vec::new(),
11742 env: Vec::new(),
11743 reserved: false,
11744 reserved_prefixes: Vec::new(),
11745 protocol: ModuleProtocol::Subc,
11746 overlap: Default::default(),
11747 };
11748
11749 let crash_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11750 assert!(matches!(
11751 on_child_exit(
11752 &spec,
11753 runtime.restart_policy,
11754 &supervisor.registry,
11755 &crash_snapshot,
11756 &runtime.terminal_ring,
11757 &runtime.spawn_events,
11758 &runtime.child_roster,
11759 ExitReport {
11760 kind: ExitKind::Crash,
11761 code: Some(1),
11762 signal: None,
11763 at_ms: 1,
11764 },
11765 )
11766 .await,
11767 NextAction::Restart { schedule: _ }
11768 ));
11769 let (crash_restarts, crash_lifetime) = {
11770 let state = lock_snapshot(&crash_snapshot).unwrap();
11771 (state.crash_restarts.len(), state.lifetime_restarts)
11772 };
11773 assert_eq!(crash_restarts, 1);
11774 assert_eq!(crash_lifetime, 1);
11775
11776 let health_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11777 let mut health_child = None;
11778 assert!(matches!(
11779 health_restart_child(
11780 &spec,
11781 &runtime,
11782 &supervisor.registry,
11783 &supervisor.process_liveness,
11784 &health_snapshot,
11785 &mut health_child,
11786 SupervisorHealthStatus::Failing,
11787 None,
11788 2,
11789 )
11790 .await,
11791 Ok(())
11792 ));
11793 assert!(health_child.is_none());
11794 assert!(lock_snapshot(&health_snapshot).unwrap().respawn_pending);
11795 assert!(runtime.scheduled_respawn.lock().unwrap().take().is_some());
11796 let (health_restarts, health_lifetime) = {
11797 let state = lock_snapshot(&health_snapshot).unwrap();
11798 (state.crash_restarts.len(), state.lifetime_restarts)
11799 };
11800 assert_eq!(health_restarts, 1);
11801 assert_eq!(health_lifetime, 1);
11802
11803 let reload_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11804 let mut reload_child = None;
11805 assert!(matches!(
11806 handle_reload_spawn_failure(
11807 &spec,
11808 &runtime,
11809 &supervisor.process_liveness,
11810 &reload_snapshot,
11811 &mut reload_child,
11812 "forced reload spawn failure".to_string(),
11813 )
11814 .await,
11815 Err(SuperviseError::ReloadFailed { .. })
11816 ));
11817 let (reload_restarts, reload_lifetime) = {
11818 let state = lock_snapshot(&reload_snapshot).unwrap();
11819 (state.crash_restarts.len(), state.lifetime_restarts)
11820 };
11821 assert_eq!(reload_restarts, 1);
11822 assert_eq!(reload_lifetime, 1);
11823 }
11824
11825 #[tokio::test]
11826 async fn deliberately_severed_live_child_records_lifetime_without_spending_restart_budget() {
11827 let supervisor = Supervisor::new_for_test(
11828 Arc::new(Registry::default()),
11829 RestartPolicy::new(3, Duration::ZERO),
11830 );
11831 let runtime = supervisor.runtime_config();
11832 let spec = ModuleSpec {
11833 module_id: "deliberately-severed".to_string(),
11834 program: PathBuf::from("/unused/deliberately-severed"),
11835 args: Vec::new(),
11836 env: Vec::new(),
11837 reserved: false,
11838 reserved_prefixes: Vec::new(),
11839 protocol: ModuleProtocol::Subc,
11840 overlap: Default::default(),
11841 };
11842 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11843 let process = ProcessIdentity {
11844 pid: 41,
11845 start_time: 101,
11846 };
11847 record_deliberate_severance(&snapshot, process).unwrap();
11848 let exit_report = apply_deliberate_severance_marker(
11849 &snapshot,
11850 Some(process),
11851 ExitReport {
11852 kind: ExitKind::Crash,
11853 code: Some(1),
11854 signal: None,
11855 at_ms: 1,
11856 },
11857 );
11858 assert_eq!(exit_report.kind, ExitKind::DeliberateSeverance);
11859
11860 assert!(matches!(
11861 on_child_exit(
11862 &spec,
11863 runtime.restart_policy,
11864 &supervisor.registry,
11865 &snapshot,
11866 &runtime.terminal_ring,
11867 &runtime.spawn_events,
11868 &runtime.child_roster,
11869 exit_report,
11870 )
11871 .await,
11872 NextAction::Restart { schedule: _ }
11873 ));
11874 let state = lock_snapshot(&snapshot).unwrap();
11875 assert_eq!(state.lifetime_restarts, 1);
11876 assert_eq!(state.crash_restarts.len(), 0);
11877 }
11878
11879 #[tokio::test]
11880 async fn genuine_crash_spends_restart_budget_and_records_lifetime() {
11881 let supervisor = Supervisor::new_for_test(
11882 Arc::new(Registry::default()),
11883 RestartPolicy::new(3, Duration::ZERO),
11884 );
11885 let runtime = supervisor.runtime_config();
11886 let spec = ModuleSpec {
11887 module_id: "genuine-crash".to_string(),
11888 program: PathBuf::from("/unused/genuine-crash"),
11889 args: Vec::new(),
11890 env: Vec::new(),
11891 reserved: false,
11892 reserved_prefixes: Vec::new(),
11893 protocol: ModuleProtocol::Subc,
11894 overlap: Default::default(),
11895 };
11896 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11897
11898 assert!(matches!(
11899 on_child_exit(
11900 &spec,
11901 runtime.restart_policy,
11902 &supervisor.registry,
11903 &snapshot,
11904 &runtime.terminal_ring,
11905 &runtime.spawn_events,
11906 &runtime.child_roster,
11907 ExitReport {
11908 kind: ExitKind::Crash,
11909 code: Some(1),
11910 signal: None,
11911 at_ms: 1,
11912 },
11913 )
11914 .await,
11915 NextAction::Restart { schedule: _ }
11916 ));
11917 let state = lock_snapshot(&snapshot).unwrap();
11918 assert_eq!(state.lifetime_restarts, 1);
11919 assert_eq!(state.crash_restarts.len(), 1);
11920 }
11921
11922 fn crash_exit_report(at_ms: u64) -> ExitReport {
11923 ExitReport {
11924 kind: ExitKind::Crash,
11925 code: Some(1),
11926 signal: None,
11927 at_ms,
11928 }
11929 }
11930
11931 fn windowed_crash_spec(module_id: &str) -> ModuleSpec {
11932 ModuleSpec {
11933 module_id: module_id.to_string(),
11934 program: PathBuf::from("/unused").join(module_id),
11935 args: Vec::new(),
11936 env: Vec::new(),
11937 reserved: false,
11938 reserved_prefixes: Vec::new(),
11939 protocol: ModuleProtocol::Subc,
11940 overlap: Default::default(),
11941 }
11942 }
11943
11944 #[tokio::test]
11950 async fn three_crashes_inside_the_window_stop_the_module_and_name_the_window() {
11951 let (logs, _guard) = crate::router::test_log::log_capture(tracing::Level::ERROR);
11952 let supervisor = Supervisor::new_for_test(
11953 Arc::new(Registry::default()),
11954 RestartPolicy::new(2, Duration::ZERO),
11955 );
11956 let runtime = supervisor.runtime_config();
11957 let spec = windowed_crash_spec("crash-loop-in-window");
11958 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11959
11960 for attempt in 1..=2 {
11961 assert!(
11962 matches!(
11963 on_child_exit(
11964 &spec,
11965 runtime.restart_policy,
11966 &supervisor.registry,
11967 &snapshot,
11968 &runtime.terminal_ring,
11969 &runtime.spawn_events,
11970 &runtime.child_roster,
11971 crash_exit_report(attempt),
11972 )
11973 .await,
11974 NextAction::Restart { schedule: _ }
11975 ),
11976 "crash {attempt} is inside the budget and must respawn"
11977 );
11978 }
11979
11980 assert!(matches!(
11981 on_child_exit(
11982 &spec,
11983 runtime.restart_policy,
11984 &supervisor.registry,
11985 &snapshot,
11986 &runtime.terminal_ring,
11987 &runtime.spawn_events,
11988 &runtime.child_roster,
11989 crash_exit_report(3),
11990 )
11991 .await,
11992 NextAction::Stop { .. }
11993 ));
11994
11995 {
11996 let state = lock_snapshot(&snapshot).unwrap();
11997 assert_eq!(state.state, ModuleState::Failed);
11998 assert_eq!(state.crash_restarts.len(), 2);
11999 assert_eq!(state.lifetime_restarts, 2);
12000 }
12001
12002 let history = runtime
12003 .terminal_ring
12004 .lock()
12005 .expect("terminal ring is not poisoned")
12006 .snapshot();
12007 let last = history
12008 .entries
12009 .last()
12010 .expect("the refused crash is retained");
12011 assert_eq!(last.disposition, TerminalDisposition::Failed);
12012 assert_eq!(
12013 last.disposition_detail.as_deref(),
12014 Some("crash budget exhausted: max_restarts=2 within window_secs=600")
12015 );
12016
12017 let captured = crate::router::test_log::captured_logs(&logs);
12018 assert!(
12019 captured.contains("crash budget exhausted: max_restarts=2 within window_secs=600"),
12020 "the stop must be logged with its window: {captured}"
12021 );
12022 }
12023
12024 #[tokio::test]
12032 async fn a_crash_older_than_the_window_frees_its_slot_for_a_later_crash() {
12033 let supervisor = Supervisor::new_for_test(
12034 Arc::new(Registry::default()),
12035 RestartPolicy::new(2, Duration::ZERO),
12036 );
12037 let runtime = supervisor.runtime_config();
12038 let spec = windowed_crash_spec("crash-across-windows");
12039 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
12040
12041 for attempt in 1..=2 {
12042 assert!(matches!(
12043 on_child_exit(
12044 &spec,
12045 runtime.restart_policy,
12046 &supervisor.registry,
12047 &snapshot,
12048 &runtime.terminal_ring,
12049 &runtime.spawn_events,
12050 &runtime.child_roster,
12051 crash_exit_report(attempt),
12052 )
12053 .await,
12054 NextAction::Restart { schedule: _ }
12055 ));
12056 }
12057
12058 update_snapshot(&snapshot, Some(&spec.module_id), |state| {
12061 age_oldest_crash_restart_out_of_window(state, runtime.restart_policy.window);
12062 })
12063 .unwrap();
12064
12065 assert!(
12066 matches!(
12067 on_child_exit(
12068 &spec,
12069 runtime.restart_policy,
12070 &supervisor.registry,
12071 &snapshot,
12072 &runtime.terminal_ring,
12073 &runtime.spawn_events,
12074 &runtime.child_roster,
12075 crash_exit_report(3),
12076 )
12077 .await,
12078 NextAction::Restart { schedule: _ }
12079 ),
12080 "a crash older than the window must not hold a budget slot"
12081 );
12082
12083 let state = lock_snapshot(&snapshot).unwrap();
12084 assert_eq!(state.state, ModuleState::Restarting);
12085 assert_eq!(
12086 state.crash_restarts.len(),
12087 2,
12088 "the aged instant is dropped and the new one takes its place"
12089 );
12090 assert_eq!(
12091 state.lifetime_restarts, 3,
12092 "the ledger counts every restart, including the ones the window forgot"
12093 );
12094 }
12095
12096 #[tokio::test]
12101 async fn an_operator_restart_clears_the_ring_and_leaves_the_ledger_alone() {
12102 let supervisor = Supervisor::new_for_test(
12103 Arc::new(Registry::default()),
12104 RestartPolicy::new(2, Duration::ZERO),
12105 );
12106 let runtime = supervisor.runtime_config();
12107 let spec = windowed_crash_spec("operator-cleared-budget");
12108 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
12109
12110 for attempt in 1..=2 {
12111 assert!(matches!(
12112 on_child_exit(
12113 &spec,
12114 runtime.restart_policy,
12115 &supervisor.registry,
12116 &snapshot,
12117 &runtime.terminal_ring,
12118 &runtime.spawn_events,
12119 &runtime.child_roster,
12120 crash_exit_report(attempt),
12121 )
12122 .await,
12123 NextAction::Restart { schedule: _ }
12124 ));
12125 }
12126
12127 reset_restart_count(&snapshot, &spec.module_id).unwrap();
12128 {
12129 let state = lock_snapshot(&snapshot).unwrap();
12130 assert!(
12131 state.crash_restarts.is_empty(),
12132 "an operator restart returns the full budget"
12133 );
12134 assert_eq!(
12135 state.lifetime_restarts, 2,
12136 "clearing the budget must not unmake the crashes"
12137 );
12138 }
12139
12140 assert!(
12141 matches!(
12142 on_child_exit(
12143 &spec,
12144 runtime.restart_policy,
12145 &supervisor.registry,
12146 &snapshot,
12147 &runtime.terminal_ring,
12148 &runtime.spawn_events,
12149 &runtime.child_roster,
12150 crash_exit_report(3),
12151 )
12152 .await,
12153 NextAction::Restart { schedule: _ }
12154 ),
12155 "the cleared budget must be spendable again"
12156 );
12157 let state = lock_snapshot(&snapshot).unwrap();
12158 assert_eq!(state.crash_restarts.len(), 1);
12159 assert_eq!(state.lifetime_restarts, 3);
12160 }
12161
12162 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
12163 async fn severance_marker_for_a_dead_child_does_not_label_its_successor() {
12164 let severed = ProcessIdentity {
12165 pid: 41,
12166 start_time: 101,
12167 };
12168 let successor = ProcessIdentity {
12169 pid: 41,
12170 start_time: 202,
12171 };
12172 let module = module_with_recovery_snapshot(ModuleState::Running, true, 0);
12173 update_snapshot(&module.inner.snapshot, Some("recovery-snapshot"), |state| {
12174 state.pid = Some(successor.pid);
12175 state.process_start_time = Some(successor.start_time);
12176 })
12177 .unwrap();
12178 assert!(!module.record_deliberate_severance(severed).unwrap());
12179
12180 let exit_report = apply_deliberate_severance_marker(
12181 &module.inner.snapshot,
12182 Some(successor),
12183 ExitReport {
12184 kind: ExitKind::Crash,
12185 code: Some(1),
12186 signal: None,
12187 at_ms: 1,
12188 },
12189 );
12190
12191 assert_eq!(exit_report.kind, ExitKind::Crash);
12192 }
12193
12194 #[tokio::test]
12195 async fn drain_reap_marks_deliberate_severance_and_records_lifetime_without_budget() {
12196 let registry = Registry::default();
12197 let supervisor = Supervisor::new_for_test(
12198 Arc::new(Registry::default()),
12199 RestartPolicy::new(3, Duration::ZERO),
12200 );
12201 let runtime = supervisor.runtime_config();
12202 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
12203 let spec = ModuleSpec {
12204 module_id: "drain-deliberate-severance".to_string(),
12205 program: fake_aft_stub_path(),
12206 args: Vec::new(),
12207 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
12208 reserved: false,
12209 reserved_prefixes: Vec::new(),
12210 protocol: ModuleProtocol::Subc,
12211 overlap: Default::default(),
12212 };
12213 let mut child = spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap();
12214 let process = ProcessIdentity {
12215 pid: 41,
12216 start_time: 101,
12217 };
12218 child.process_identity = Some(process);
12219 update_snapshot(&snapshot, Some(&spec.module_id), |state| {
12220 state.pid = Some(process.pid);
12221 state.process_start_time = Some(process.start_time);
12222 })
12223 .unwrap();
12224 record_deliberate_severance(&snapshot, process).unwrap();
12225
12226 drain_child_to_state(
12227 &spec.module_id,
12228 spec.protocol,
12229 StopNotice::SentOverConnection,
12232 ®istry,
12233 None,
12234 &snapshot,
12235 &runtime.terminal_ring,
12236 &runtime.spawn_events,
12237 child,
12238 Duration::from_secs(1),
12239 ModuleState::Stopped,
12240 Some(false),
12241 )
12242 .await
12243 .unwrap();
12244
12245 let state = lock_snapshot(&snapshot).unwrap();
12246 assert_eq!(
12247 state.last_exit.as_ref().map(|exit| exit.kind),
12248 Some(ExitKind::DeliberateSeverance)
12249 );
12250 assert_eq!(state.lifetime_restarts, 1);
12251 assert_eq!(state.crash_restarts.len(), 0);
12252 drop(state);
12253 let history = runtime.terminal_ring.lock().unwrap().snapshot();
12254 assert_eq!(
12255 history.entries[0].exit_kind,
12256 subc_control::TerminalExitKind::DeliberateSeverance
12257 );
12258 }
12259
12260 #[tokio::test]
12261 async fn ordinary_drain_reap_does_not_record_a_lifetime_restart() {
12262 let registry = Registry::default();
12263 let supervisor = Supervisor::new_for_test(
12264 Arc::new(Registry::default()),
12265 RestartPolicy::new(3, Duration::ZERO),
12266 );
12267 let runtime = supervisor.runtime_config();
12268 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
12269 let spec = ModuleSpec {
12270 module_id: "ordinary-drain".to_string(),
12271 program: fake_aft_stub_path(),
12272 args: Vec::new(),
12273 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
12274 reserved: false,
12275 reserved_prefixes: Vec::new(),
12276 protocol: ModuleProtocol::Subc,
12277 overlap: Default::default(),
12278 };
12279 let child = spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap();
12280
12281 drain_child_to_state(
12282 &spec.module_id,
12283 spec.protocol,
12284 StopNotice::SentOverConnection,
12287 ®istry,
12288 None,
12289 &snapshot,
12290 &runtime.terminal_ring,
12291 &runtime.spawn_events,
12292 child,
12293 Duration::from_secs(1),
12294 ModuleState::Stopped,
12295 Some(false),
12296 )
12297 .await
12298 .unwrap();
12299
12300 let state = lock_snapshot(&snapshot).unwrap();
12301 assert_eq!(
12302 state.last_exit.as_ref().map(|exit| exit.kind),
12303 Some(ExitKind::Crash)
12304 );
12305 assert_eq!(state.lifetime_restarts, 0);
12306 assert_eq!(state.crash_restarts.len(), 0);
12307 }
12308
12309 #[test]
12310 fn fatal_connection_teardown_cannot_arm_a_marker_for_a_surviving_process() {
12311 assert!(!include_str!("server.rs")
12317 .contains("router.record_deliberate_connection_severance(ctx.connection_id)"));
12318 }
12319
12320 #[test]
12327 fn drained_after_quiescence_wait_passes_ok_through_and_forces_false_on_err() {
12328 assert!(drained_after_quiescence_wait(&Ok(true)));
12329 assert!(!drained_after_quiescence_wait(&Ok(false)));
12330 assert!(!drained_after_quiescence_wait(&Err(
12331 SuperviseError::StatePoisoned { module_id: None }
12332 )));
12333 }
12334
12335 #[test]
12344 fn wait_error_exit_report_records_a_failed_terminal_with_no_code_or_signal() {
12345 let ring = Arc::new(Mutex::new(TerminalRing::new(
12346 TerminalRingConfig::default(),
12347 0,
12348 )));
12349 record_wait_error_terminal("wait-error", &ring, &super::SpawnEventFeed::default());
12350
12351 let snapshot = ring.lock().unwrap().snapshot();
12352 assert_eq!(snapshot.entries.len(), 1);
12353 let entry = &snapshot.entries[0];
12354 assert_eq!(entry.exit_code, None);
12355 assert_eq!(entry.exit_signal, None);
12356 assert_eq!(entry.disposition, TerminalDisposition::Failed);
12357 }
12358
12359 #[test]
12360 fn wait_error_exit_path_preserves_spawn_event_density() {
12361 let feed = super::SpawnEventFeed::default();
12362 feed.configure_incarnation("wait-error-density".to_string());
12363 feed.emit_spawned("wait-error", 41, 1);
12364 let ring = Arc::new(Mutex::new(TerminalRing::new(
12365 TerminalRingConfig::default(),
12366 0,
12367 )));
12368
12369 record_wait_error_terminal("wait-error", &ring, &feed);
12370 feed.emit_spawned("after-wait-error", 42, 2);
12371
12372 let state = feed.0.lock().unwrap();
12373 let sequences = state
12374 .events
12375 .iter()
12376 .map(|event| event.cursor.seq)
12377 .collect::<Vec<_>>();
12378 assert_eq!(sequences, vec![1, 2, 3]);
12379 assert_eq!(state.events[1].kind, SpawnEventKind::Exited);
12380 assert_eq!(state.events[1].exit_code, None);
12381 assert_eq!(state.events[1].exit_signal, None);
12382 }
12383
12384 #[test]
12388 fn wait_error_exit_report_is_classified_as_a_crash() {
12389 assert_eq!(wait_error_exit_report().kind, ExitKind::Crash);
12390 }
12391}
12392
12393#[cfg(all(test, unix))]
12394mod health_event_tests {
12395 use super::*;
12396 use cortexkit_test_support::ScratchDir;
12397 use subc_protocol::{manifest::Concurrency, session::ModuleControlResponse};
12398
12399 struct Actor {
12400 module: SupervisedModule,
12401 registry: Arc<Registry>,
12402 forwarding: Arc<ForwardingTable>,
12403 spec: ModuleSpec,
12404 health: HealthConfig,
12405 rx: mpsc::Receiver<crate::router::OutboundFrame>,
12406 _home: ScratchDir,
12407 }
12408
12409 async fn wait_for<T>(reason: &str, mut observe: impl FnMut() -> Option<T>) -> T {
12410 let deadline = std::time::Instant::now() + Duration::from_secs(10);
12414 let tick = Instant::now();
12415 loop {
12416 if let Some(value) = observe() {
12417 assert_eq!(Instant::now(), tick, "{reason} must not wait for a timer");
12418 return value;
12419 }
12420 assert!(
12421 std::time::Instant::now() < deadline,
12422 "timed out waiting for {reason}"
12423 );
12424 tokio::task::yield_now().await;
12425 }
12426 }
12427
12428 impl Actor {
12429 async fn start(protocol: ModuleProtocol, cadence: Duration) -> Self {
12430 let home = ScratchDir::new("health-events");
12431 let registry = Arc::new(Registry::default());
12432 let forwarding = Arc::new(ForwardingTable::default());
12433 let health = HealthConfig {
12434 cadence,
12435 deadline: Duration::from_secs(3600),
12436 ..HealthConfig::default()
12437 };
12438 let supervisor =
12439 Supervisor::new_for_test(registry.clone(), RestartPolicy::new(3, Duration::ZERO))
12440 .with_forwarding(forwarding.clone())
12441 .with_health_config(health.clone());
12442 let spec = ModuleSpec {
12443 module_id: "event-health-child".into(),
12444 program: PathBuf::from("/bin/sh"),
12445 args: vec!["-c".into(), "exec sleep 600".into()],
12448 env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
12449 .into_iter()
12450 .map(|key| (key.into(), home.path().display().to_string()))
12451 .collect(),
12452 reserved: false,
12453 reserved_prefixes: vec![],
12454 protocol,
12455 overlap: Default::default(),
12456 };
12457 let module = supervisor.spawn(spec.clone()).unwrap();
12458 let (_, rx) = mpsc::channel(8);
12459 let actor = Self {
12460 module,
12461 registry,
12462 forwarding,
12463 spec,
12464 health,
12465 rx,
12466 _home: home,
12467 };
12468 actor
12469 .wait_for_select(|checkpoint| checkpoint.generation > 0)
12470 .await;
12471 actor
12472 }
12473
12474 fn turns(&self) -> u64 {
12475 lock_snapshot(&self.module.inner.snapshot)
12476 .unwrap()
12477 .actor_turns
12478 }
12479
12480 async fn wait_for_select(
12481 &self,
12482 expected: impl Fn(&ActorSelectCheckpoint) -> bool,
12483 ) -> ActorSelectCheckpoint {
12484 wait_for("actor select checkpoint", || {
12485 let state = lock_snapshot(&self.module.inner.snapshot).unwrap();
12486 state.actor_select.filter(|checkpoint| {
12487 checkpoint.generation == state.spawn_generation
12488 && state.state == ModuleState::Running
12489 && state.process_alive
12490 && state.reported_pid().is_some()
12491 && expected(checkpoint)
12492 })
12493 })
12494 .await
12495 }
12496
12497 fn hello(&mut self, connection: u64, health: bool) {
12498 let manifest =
12499 subc_protocol::manifest::ModuleManifest::builder(&self.spec.module_id, "1.0.0")
12500 .protocol_ver(subc_protocol::PROTOCOL_VERSION)
12501 .build();
12502 let connection = ConnectionId::new(connection);
12503 let (tx, rx) = mpsc::channel(8);
12504 self.rx = rx;
12505 self.forwarding
12506 .register_module_connection(
12507 connection,
12508 self.spec.module_id.clone(),
12509 subc_protocol::PROTOCOL_VERSION,
12510 Concurrency::ModuleManaged,
12511 FrameSink::new(tx),
12512 )
12513 .unwrap();
12514 self.registry
12515 .register_with_control_ops(
12516 manifest,
12517 subc_protocol::PROTOCOL_VERSION,
12518 connection,
12519 if health {
12520 vec![MODULE_CONTROL_OP_HEALTH_CHECK.into()]
12521 } else {
12522 vec![]
12523 },
12524 )
12525 .unwrap();
12526 }
12527
12528 async fn probe(&mut self) -> crate::router::OutboundFrame {
12529 let frame = wait_for("outbound health probe", || match self.rx.try_recv() {
12530 Ok(frame) => Some(frame),
12531 Err(mpsc::error::TryRecvError::Empty) => None,
12532 Err(mpsc::error::TryRecvError::Disconnected) => panic!("health peer disconnected"),
12533 })
12534 .await;
12535 let body: Value = serde_json::from_slice(&frame.body).unwrap();
12536 assert_eq!(body["op"], MODULE_CONTROL_OP_HEALTH_CHECK);
12537 frame
12538 }
12539
12540 fn no_probe(&mut self) {
12541 assert!(matches!(
12542 self.rx.try_recv(),
12543 Err(mpsc::error::TryRecvError::Empty)
12544 ));
12545 }
12546
12547 fn answer(&self, connection: u64, frame: crate::router::OutboundFrame) {
12548 self.forwarding
12549 .complete_module_control_rpc(
12550 ConnectionId::new(connection),
12551 frame.header.corr,
12552 Some(MODULE_CONTROL_OP_HEALTH_CHECK),
12553 ModuleControlRpcOutcome::Response(ModuleControlResponse::HealthCheck {
12554 status: HealthStatus::Ok,
12555 detail: None,
12556 metrics: None,
12557 }),
12558 )
12559 .unwrap();
12560 }
12561 }
12562
12563 #[tokio::test(start_paused = true)]
12564 async fn no_health_children_park_for_sixty_seconds() {
12565 let none = Actor::start(ModuleProtocol::None, Duration::from_secs(30)).await;
12566 let unregistered = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12567 let mut unadvertised = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12568 unadvertised.hello(1, false);
12569 for _ in 0..6000 {
12574 tokio::time::advance(Duration::from_millis(10)).await;
12575 tokio::task::yield_now().await;
12576 }
12577 for actor in [&none, &unregistered, &unadvertised] {
12578 assert!(
12579 actor.turns() <= 5,
12580 "no-health actor woke {} times",
12581 actor.turns()
12582 );
12583 assert_eq!(actor.module.state().unwrap(), ModuleState::Running);
12584 }
12585 }
12586
12587 #[tokio::test(start_paused = true)]
12588 async fn late_hello_starts_a_due_probe_in_the_notification_tick() {
12589 let mut actor = Actor::start(ModuleProtocol::Subc, Duration::ZERO).await;
12593 let tick = Instant::now();
12594 actor.hello(1, true);
12595 actor.probe().await;
12596 assert_eq!(Instant::now(), tick, "HELLO must not wait for a poll");
12597 }
12598
12599 #[tokio::test(start_paused = true)]
12600 async fn hello_keeps_the_jittered_cadence_and_catalog_updates_do_not_reset_it() {
12601 let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12602 let hello_tick = Instant::now();
12603 actor.hello(1, true);
12604 let armed = actor
12605 .wait_for_select(|checkpoint| {
12606 checkpoint.registered_connection == Some(ConnectionId::new(1))
12607 && checkpoint.next_probe_at.is_some()
12608 })
12609 .await;
12610 let delay = jittered_health_delay(&actor.spec.module_id, 0, actor.health.cadence);
12611 assert!((Duration::from_secs(30)..Duration::from_secs(33)).contains(&delay));
12612 let first_deadline = hello_tick + delay;
12613 assert_eq!(armed.next_probe_at, Some(first_deadline));
12614 tokio::time::advance(Duration::from_secs(29)).await;
12615 let turns = actor.turns();
12616 actor
12617 .registry
12618 .replace_catalog_for_connection(ConnectionId::new(1), vec![], None, Some(true))
12619 .unwrap();
12620 let updated = actor
12621 .wait_for_select(|checkpoint| checkpoint.turn > turns)
12622 .await;
12623 assert_eq!(
12624 updated.next_probe_at,
12625 Some(first_deadline),
12626 "catalog update must not reset cadence"
12627 );
12628 actor.no_probe();
12629 tokio::time::advance(first_deadline - Instant::now()).await;
12630 let frame = actor.probe().await;
12631 actor.answer(1, frame);
12632 let next = jittered_health_delay(&actor.spec.module_id, 1, actor.health.cadence);
12633 let next_deadline = Instant::now() + next;
12634 let rearmed = actor
12635 .wait_for_select(|checkpoint| {
12636 checkpoint
12637 .next_probe_at
12638 .is_some_and(|deadline| deadline > first_deadline)
12639 })
12640 .await;
12641 assert_eq!(rearmed.next_probe_at, Some(next_deadline));
12642 tokio::time::advance(next - Duration::from_nanos(1)).await;
12643 actor.no_probe();
12644 tokio::time::advance(Duration::from_nanos(1)).await;
12645 actor.probe().await;
12646 }
12647
12648 #[tokio::test(start_paused = true)]
12649 async fn disconnect_disarms_and_reconnect_rearms_health() {
12650 let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12651 actor.hello(1, true);
12652 actor
12653 .wait_for_select(|checkpoint| {
12654 checkpoint.registered_connection == Some(ConnectionId::new(1))
12655 && checkpoint.next_probe_at.is_some()
12656 })
12657 .await;
12658 let turns = actor.turns();
12659 let tick = Instant::now();
12660 actor
12661 .registry
12662 .deregister_connection(ConnectionId::new(1))
12663 .unwrap();
12664 let disarmed = actor
12665 .wait_for_select(|checkpoint| {
12666 checkpoint.turn > turns && checkpoint.registered_connection.is_none()
12667 })
12668 .await;
12669 assert_eq!(disarmed.next_probe_at, None);
12670 assert_eq!(disarmed.wake_after, None);
12671 assert_eq!(
12672 Instant::now(),
12673 tick,
12674 "disconnect must disarm in the notification tick"
12675 );
12676 tokio::time::advance(Duration::from_secs(60)).await;
12677 actor.no_probe();
12678 let reconnect_tick = Instant::now();
12679 actor.hello(2, true);
12680 let rearmed = actor
12681 .wait_for_select(|checkpoint| {
12682 checkpoint.registered_connection == Some(ConnectionId::new(2))
12683 && checkpoint.next_probe_at.is_some()
12684 })
12685 .await;
12686 let delay = jittered_health_delay(&actor.spec.module_id, 0, actor.health.cadence);
12687 assert_eq!(rearmed.next_probe_at, Some(reconnect_tick + delay));
12688 tokio::time::advance(delay).await;
12689 actor.probe().await;
12690 }
12691
12692 #[tokio::test(start_paused = true)]
12693 async fn reregistration_without_health_disarms_the_old_schedule() {
12694 let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12695 actor.hello(1, true);
12696 actor
12697 .wait_for_select(|checkpoint| {
12698 checkpoint.registered_connection == Some(ConnectionId::new(1))
12699 && checkpoint.next_probe_at.is_some()
12700 })
12701 .await;
12702 actor
12703 .registry
12704 .deregister_connection(ConnectionId::new(1))
12705 .unwrap();
12706 actor.hello(2, false);
12707 let disarmed = actor
12708 .wait_for_select(|checkpoint| {
12709 checkpoint.registered_connection == Some(ConnectionId::new(2))
12710 })
12711 .await;
12712 assert_eq!(disarmed.next_probe_at, None);
12713 assert_eq!(disarmed.wake_after, None);
12714 tokio::time::advance(Duration::from_secs(60)).await;
12715 actor.no_probe();
12716 }
12717
12718 #[tokio::test(start_paused = true)]
12719 async fn restart_waits_for_its_own_hello_before_rearming_health() {
12720 let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12721 actor.hello(1, true);
12722 let armed = actor
12723 .wait_for_select(|checkpoint| {
12724 checkpoint.registered_connection == Some(ConnectionId::new(1))
12725 && checkpoint.next_probe_at.is_some()
12726 })
12727 .await;
12728 actor
12732 .registry
12733 .deregister_connection(ConnectionId::new(1))
12734 .unwrap();
12735 actor.module.restart(Some(0)).await.unwrap();
12736 let parked = actor
12737 .wait_for_select(|checkpoint| {
12738 checkpoint.generation > armed.generation
12739 && checkpoint.registered_connection.is_none()
12740 && checkpoint.next_probe_at.is_none()
12741 })
12742 .await;
12743 assert_eq!(parked.wake_after, None);
12744 actor.rx.close();
12745 while actor.rx.try_recv().is_ok() {}
12748 let turns = actor.turns();
12749 tokio::time::advance(Duration::from_secs(60)).await;
12750 let after = actor.turns();
12751 eprintln!(
12752 "restart park turns: before={turns} after={after} delta={}",
12753 after - turns
12754 );
12755 assert_eq!(after, turns, "replacement must park before HELLO");
12756 let hello_tick = Instant::now();
12757 actor.hello(2, true);
12758 let rearmed = actor
12759 .wait_for_select(|checkpoint| {
12760 checkpoint.registered_connection == Some(ConnectionId::new(2))
12761 && checkpoint.next_probe_at.is_some()
12762 })
12763 .await;
12764 let delay = jittered_health_delay(&actor.spec.module_id, 0, actor.health.cadence);
12765 assert_eq!(rearmed.next_probe_at, Some(hello_tick + delay));
12766 tokio::time::advance(delay).await;
12767 actor.probe().await;
12768 }
12769
12770 #[tokio::test(start_paused = true)]
12771 async fn rescan_adds_and_removes_http_health_without_polling() {
12772 use tokio::{
12773 io::{AsyncReadExt, AsyncWriteExt},
12774 net::TcpListener,
12775 };
12776 let mut actor = Actor::start(ModuleProtocol::None, Duration::from_secs(30)).await;
12777 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
12778 actor.health.http = Some(format!("http://{}/health", listener.local_addr().unwrap()));
12779 actor.health.cadence = Duration::from_millis(1);
12781 let (started_tx, mut started_rx) = mpsc::channel(8);
12782 let peer = tokio::spawn(async move {
12783 loop {
12784 let (mut socket, _) = listener.accept().await.unwrap();
12785 let mut request = [0; 1024];
12786 assert!(socket.read(&mut request).await.unwrap() > 0);
12787 started_tx.send(()).await.unwrap();
12788 socket
12789 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")
12790 .await
12791 .unwrap();
12792 }
12793 });
12794 let tick = Instant::now();
12795 let turns = actor.turns();
12796 actor
12797 .module
12798 .update_configuration(actor.spec.clone(), actor.health.clone(), None)
12799 .await
12800 .unwrap();
12801 let armed = actor
12802 .wait_for_select(|checkpoint| {
12803 checkpoint.turn > turns && checkpoint.next_probe_at.is_some()
12804 })
12805 .await;
12806 assert_eq!(armed.next_probe_at, Some(tick + Duration::from_millis(1)));
12807 assert_eq!(Instant::now(), tick);
12808 tokio::time::advance(Duration::from_millis(1)).await;
12809 let mut started = false;
12810 wait_for("successful HTTP probe", || {
12811 started |= started_rx.try_recv().is_ok();
12812 (started && actor.module.status().unwrap().health.status == SupervisorHealthStatus::Ok)
12813 .then_some(())
12814 })
12815 .await;
12816 assert!(started, "added HTTP check must start at its first deadline");
12817 assert_eq!(
12818 actor.module.status().unwrap().health.status,
12819 SupervisorHealthStatus::Ok
12820 );
12821 assert_eq!(Instant::now(), tick + Duration::from_millis(1));
12822 actor.health.http = None;
12823 let turns = actor.turns();
12824 actor
12825 .module
12826 .update_configuration(actor.spec.clone(), actor.health.clone(), None)
12827 .await
12828 .unwrap();
12829 let parked = actor
12830 .wait_for_select(|checkpoint| {
12831 checkpoint.turn > turns && checkpoint.next_probe_at.is_none()
12832 })
12833 .await;
12834 assert_eq!(parked.wake_after, None);
12835 let turns = actor.turns();
12836 tokio::time::advance(Duration::from_secs(60)).await;
12837 assert_eq!(
12838 actor.turns(),
12839 turns,
12840 "removed HTTP check must park the actor"
12841 );
12842 assert!(
12843 started_rx.try_recv().is_err(),
12844 "removed HTTP check must stay disarmed"
12845 );
12846 assert_eq!(
12847 actor.module.status().unwrap().health,
12848 ModuleHealthStatus::default()
12849 );
12850 peer.abort();
12851 }
12852}
12853
12854#[cfg(test)]
12855mod health_evidence_tests {
12856 use super::{HealthProbeError, HealthProbeEvidence};
12857 use std::collections::HashSet;
12858
12859 #[test]
12867 fn only_a_dead_lane_is_proof_of_death() {
12868 assert!(HealthProbeError::lane_dead("gone").is_proof_of_death());
12869 assert!(!HealthProbeError::no_answer("timed out").is_proof_of_death());
12873 assert!(!HealthProbeError::bad_answer("garbage").is_proof_of_death());
12874 assert!(!HealthProbeError::misconfigured("no table").is_proof_of_death());
12875 }
12876
12877 #[test]
12883 fn every_evidence_class_has_a_distinct_label() {
12884 let labels = [
12885 HealthProbeError::lane_dead("").label(),
12886 HealthProbeError::no_answer("").label(),
12887 HealthProbeError::bad_answer("").label(),
12888 HealthProbeError::misconfigured("").label(),
12889 ];
12890 let unique: HashSet<_> = labels.iter().collect();
12891 assert_eq!(unique.len(), labels.len(), "labels collided: {labels:?}");
12892 }
12893
12894 #[test]
12900 fn classification_preserves_the_original_message() {
12901 let err = HealthProbeError::no_answer("module did not answer within 5s");
12902 assert_eq!(err.to_string(), "module did not answer within 5s");
12903 assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
12904 }
12905}
12906
12907#[cfg(test)]
12908mod health_tombstone_tests {
12909 use std::{path::PathBuf, sync::Arc, time::Duration};
12910
12911 use subc_protocol::{
12912 manifest::Concurrency,
12913 session::{HealthStatus, ModuleControlResponse},
12914 };
12915 use tokio::sync::mpsc;
12916
12917 use super::{
12918 probe_module_health, HealthAction, HealthConfig, HealthProbeEvidence, ModuleProtocol,
12919 ModuleSpec, RestartPolicy, Supervisor, SupervisorRuntimeConfig,
12920 };
12921 use crate::{
12922 control::ControlHandler,
12923 forwarding::{ForwardingTable, ModuleControlRpcCompletion, ModuleControlRpcOutcome},
12924 registry::{ConnectionId, Registry},
12925 router::FrameSink,
12926 };
12927
12928 struct ProbeHarness {
12929 spec: ModuleSpec,
12930 runtime: SupervisorRuntimeConfig,
12931 forwarding: Arc<ForwardingTable>,
12932 module_connection: ConnectionId,
12933 module_rx: mpsc::Receiver<crate::router::OutboundFrame>,
12934 handler: ControlHandler,
12935 module: super::SupervisedModule,
12936 }
12937
12938 fn probe_harness() -> ProbeHarness {
12939 let registry = Arc::new(Registry::default());
12940 let forwarding = Arc::new(ForwardingTable::default());
12941 let supervisor_handle = super::SupervisorHandle::new();
12942 let health = HealthConfig {
12943 http: None,
12944 cadence: Duration::from_secs(30),
12945 deadline: Duration::from_secs(5),
12946 failure_threshold: 3,
12947 on_degraded: HealthAction::Report,
12948 on_failing: HealthAction::Report,
12949 critical: false,
12950 };
12951 let supervisor = Supervisor::new_for_test(Arc::clone(®istry), RestartPolicy::default())
12952 .with_forwarding(Arc::clone(&forwarding))
12953 .with_handle(supervisor_handle.clone())
12954 .with_health_config(health);
12955 let spec = ModuleSpec {
12956 module_id: "late-health-module".to_string(),
12957 program: PathBuf::from("disabled-module"),
12958 args: Vec::new(),
12959 env: Vec::new(),
12960 reserved: false,
12961 reserved_prefixes: Vec::new(),
12962 protocol: ModuleProtocol::Subc,
12963 overlap: Default::default(),
12964 };
12965 let module = supervisor
12966 .supervise_configured(spec.clone(), false)
12967 .unwrap();
12968 let runtime = supervisor.runtime_config();
12969 let handler = ControlHandler::with_forwarding(registry, Arc::clone(&forwarding))
12970 .with_supervisor(supervisor_handle);
12971 let module_connection = ConnectionId::new(700);
12972 let (module_tx, module_rx) = mpsc::channel(8);
12973 forwarding
12974 .register_module_connection(
12975 module_connection,
12976 spec.module_id.clone(),
12977 subc_protocol::PROTOCOL_VERSION,
12978 Concurrency::ModuleManaged,
12979 FrameSink::new(module_tx),
12980 )
12981 .unwrap();
12982
12983 ProbeHarness {
12984 spec,
12985 runtime,
12986 forwarding,
12987 module_connection,
12988 module_rx,
12989 handler,
12990 module,
12991 }
12992 }
12993
12994 async fn finish_after(
12995 harness: &mut ProbeHarness,
12996 stall: Duration,
12997 ) -> ModuleControlRpcCompletion {
12998 assert!(stall > harness.runtime.health.deadline);
12999 let deadline = harness.runtime.health.deadline;
13000 let probe = probe_module_health(&harness.spec.module_id, &harness.runtime, None);
13001 let answer = async {
13002 let frame = harness.module_rx.recv().await.expect("health.check frame");
13003 tokio::time::advance(deadline).await;
13004 tokio::task::yield_now().await;
13005 tokio::time::advance(stall - deadline).await;
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: None,
13016 }),
13017 )
13018 .unwrap()
13019 };
13020 let (probe_result, completion) = tokio::join!(probe, answer);
13021 let err = probe_result.expect_err("probe must miss its deadline");
13022 assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
13023 completion
13024 }
13025
13026 async fn time_out_without_answer(harness: &mut ProbeHarness) {
13027 let deadline = harness.runtime.health.deadline;
13028 let probe = probe_module_health(&harness.spec.module_id, &harness.runtime, None);
13029 let exhaust_deadline = async {
13030 let _frame = harness.module_rx.recv().await.expect("health.check frame");
13031 tokio::time::advance(deadline).await;
13032 tokio::task::yield_now().await;
13033 };
13034 let (probe_result, ()) = tokio::join!(probe, exhaust_deadline);
13035 let err = probe_result.expect_err("probe must miss its deadline");
13036 assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
13037 }
13038
13039 async fn run_probe_cycle(harness: &mut ProbeHarness, answer: bool) {
13040 let registry = Arc::clone(&harness.module.inner.registry);
13041 let snapshot = Arc::clone(&harness.module.inner.snapshot);
13042 let process_liveness = super::SupervisorProcessLiveness::default();
13043 let mut child = None;
13044 let cycle = super::run_health_probe_cycle(
13045 &harness.spec,
13046 &harness.runtime,
13047 ®istry,
13048 &process_liveness,
13049 &snapshot,
13050 &mut child,
13051 );
13052 let peer = async {
13053 let frame = harness.module_rx.recv().await.expect("health.check frame");
13054 if answer {
13055 harness
13056 .forwarding
13057 .complete_module_control_rpc(
13058 harness.module_connection,
13059 frame.header.corr,
13060 Some("health.check"),
13061 ModuleControlRpcOutcome::Response(ModuleControlResponse::HealthCheck {
13062 status: HealthStatus::Ok,
13063 detail: None,
13064 metrics: Some(serde_json::json!({"ready": true})),
13065 }),
13066 )
13067 .unwrap();
13068 } else {
13069 tokio::time::advance(harness.runtime.health.deadline).await;
13070 tokio::task::yield_now().await;
13071 }
13072 };
13073 tokio::join!(cycle, peer);
13074 }
13075
13076 #[tokio::test(start_paused = true)]
13077 async fn unanswered_probe_is_unknown_until_threshold_and_ok_report_recovers() {
13078 let mut harness = probe_harness();
13079 let monitor = harness.module.inner.monitor.lock().unwrap().take().unwrap();
13083 monitor.abort();
13084 let _ = monitor.await;
13085 super::update_snapshot(&harness.module.inner.snapshot, None, |state| {
13086 state.enabled = true;
13087 state.state = super::ModuleState::Running;
13088 state.process_alive = true;
13089 })
13090 .unwrap();
13091
13092 run_probe_cycle(&mut harness, true).await;
13093 assert_eq!(
13094 harness.module.status().unwrap().health.status,
13095 super::SupervisorHealthStatus::Ok
13096 );
13097
13098 for failures in 1..harness.runtime.health.failure_threshold {
13099 run_probe_cycle(&mut harness, false).await;
13100 let status = harness.module.status().unwrap();
13101 assert_eq!(status.health.status, super::SupervisorHealthStatus::Unknown);
13102 assert_eq!(status.health.consecutive_failures, failures);
13103 assert!(status.health.last_probe_ms.is_some());
13104 assert!(status.health.detail.unwrap().starts_with("[no-answer]"));
13105 assert!(status.health.metrics.is_none());
13106 assert_eq!(status.state, super::ModuleState::Running);
13107 assert!(status.process_alive);
13108 assert_eq!(status.restart_count, 0);
13109 assert_eq!(status.lifetime_restarts, 0);
13110 assert!(status.health.last_action.is_none());
13111 assert!(harness.runtime.scheduled_respawn.lock().unwrap().is_none());
13112 }
13113
13114 run_probe_cycle(&mut harness, true).await;
13115 let recovered = harness.module.status().unwrap();
13116 assert_eq!(recovered.health.status, super::SupervisorHealthStatus::Ok);
13117 assert_eq!(recovered.health.consecutive_failures, 0);
13118 assert!(recovered.health.detail.is_none());
13119 assert_eq!(
13120 recovered.health.metrics,
13121 Some(serde_json::json!({"ready": true}))
13122 );
13123 assert_eq!(recovered.lifetime_restarts, 0);
13124
13125 for failures in 1..=harness.runtime.health.failure_threshold {
13126 run_probe_cycle(&mut harness, false).await;
13127 let status = harness.module.status().unwrap();
13128 assert_eq!(status.health.consecutive_failures, failures);
13129 if failures < harness.runtime.health.failure_threshold {
13130 assert_eq!(status.health.status, super::SupervisorHealthStatus::Unknown);
13131 assert_eq!(status.state, super::ModuleState::Running);
13132 assert_eq!(status.lifetime_restarts, 0);
13133 assert!(harness.runtime.scheduled_respawn.lock().unwrap().is_none());
13134 } else {
13135 assert_eq!(
13136 status.health.status,
13137 super::SupervisorHealthStatus::Unresponsive
13138 );
13139 assert_eq!(status.state, super::ModuleState::Restarting);
13140 assert_eq!(status.restart_count, 1);
13141 assert_eq!(status.lifetime_restarts, 1);
13142 assert_eq!(status.health.last_action.as_deref(), Some("restart"));
13143 assert!(harness.runtime.scheduled_respawn.lock().unwrap().is_some());
13144 }
13145 }
13146 }
13147
13148 #[tokio::test(start_paused = true)]
13149 async fn late_health_answers_record_start_anchored_latency_for_two_stalls() {
13150 let mut harness = probe_harness();
13151
13152 let first = finish_after(&mut harness, Duration::from_secs(8)).await;
13153 let first_latency = match &first {
13154 ModuleControlRpcCompletion::LateHealthAnswer { latency, .. } => *latency,
13155 other => panic!("late answer was not retained: {other:?}"),
13156 };
13157 assert!(harness.handler.observe_module_control_completion(first));
13158
13159 let second = finish_after(&mut harness, Duration::from_secs(11)).await;
13160 let second_latency = match &second {
13161 ModuleControlRpcCompletion::LateHealthAnswer { latency, .. } => *latency,
13162 other => panic!("late answer was not retained: {other:?}"),
13163 };
13164 assert!(harness.handler.observe_module_control_completion(second));
13165
13166 assert_eq!(first_latency, Duration::from_secs(8));
13167 assert_eq!(
13168 second_latency - first_latency,
13169 Duration::from_secs(3),
13170 "latency must grow linearly with the additional stall"
13171 );
13172 let health = harness.module.status().unwrap().health;
13173 assert_eq!(health.late_answer_count, 2);
13174 assert_eq!(health.last_late_answer_latency_ms, Some(11_000));
13175 }
13176
13177 #[tokio::test(start_paused = true)]
13185 async fn late_answer_clears_the_consecutive_failure_streak() {
13186 let mut harness = probe_harness();
13187
13188 time_out_without_answer(&mut harness).await;
13190 harness
13191 .module
13192 .record_health_probe_failure_for_test("[no-answer] test miss")
13193 .unwrap();
13194 assert_eq!(
13195 harness.module.status().unwrap().health.consecutive_failures,
13196 1,
13197 "precondition: the miss must be on the streak before the late answer"
13198 );
13199
13200 let late = finish_after(&mut harness, Duration::from_secs(9)).await;
13202 assert!(matches!(
13203 late,
13204 ModuleControlRpcCompletion::LateHealthAnswer { .. }
13205 ));
13206 assert!(harness.handler.observe_module_control_completion(late));
13207
13208 let health = harness.module.status().unwrap().health;
13209 assert_eq!(
13210 health.consecutive_failures, 0,
13211 "a late answer is an answer: the streak must reset"
13212 );
13213 assert_eq!(health.late_answer_count, 1);
13214 }
13215
13216 #[tokio::test(start_paused = true)]
13217 async fn repeated_serial_probe_cycles_keep_one_tombstone_per_endpoint() {
13218 let mut harness = probe_harness();
13219
13220 for _ in 0..20 {
13221 time_out_without_answer(&mut harness).await;
13222 assert_eq!(
13223 harness.forwarding.health_probe_tombstone_count().unwrap(),
13224 1
13225 );
13226 }
13227 }
13228}
13229
13230#[cfg(test)]
13231mod child_env_tests {
13232 #[cfg(windows)]
13233 use super::{
13234 clear_current_process_facts, observed_launch_nonce_source, ModuleState, SupervisorSnapshot,
13235 };
13236 #[cfg(windows)]
13237 use std::sync::{Arc, OnceLock};
13238 #[cfg(windows)]
13239 #[test]
13240 fn windows_wire_spawn_prepares_pipe_and_keeps_phase_one_env() {
13241 let spec = spec(Vec::new());
13242 let mut command = Command::new("unused");
13243 let handoff = apply_wire_spawn_args(&mut command, &spec, None, None)
13244 .unwrap()
13245 .unwrap();
13246 let name = command
13247 .as_std()
13248 .get_envs()
13249 .find(|(key, _)| *key == OsStr::new(subc_os::launch_nonce::LAUNCH_NONCE_PIPE_ENV))
13250 .and_then(|(_, value)| value)
13251 .unwrap();
13252 assert_eq!(name, OsStr::new(handoff.name()));
13253 assert!(handoff.name().starts_with(r"\\.\pipe\subc-launch-"));
13254 assert!(command.as_std().get_envs().any(|(key, value)| key
13255 == OsStr::new(subc_os::LAUNCH_NONCE_PIPE_FALLBACK_ENV)
13256 && value == Some(OsStr::new("env"))));
13257 assert!(command
13258 .as_std()
13259 .get_envs()
13260 .any(|(key, value)| key == OsStr::new(SUBC_LAUNCH_NONCE_ENV) && value.is_some()));
13261 }
13262
13263 #[cfg(windows)]
13264 #[test]
13265 fn windows_source_reporting_tracks_consumption_and_resets_per_spawn() {
13266 let mut snapshot = SupervisorSnapshot::new(ModuleState::Running, true);
13267 snapshot.process_alive = true;
13268 snapshot.spawned_protocol = Some(ModuleProtocol::Subc);
13269 assert_eq!(
13270 observed_launch_nonce_source(&snapshot).as_deref(),
13271 Some("env")
13272 );
13273 let consumed = Arc::new(OnceLock::new());
13274 snapshot.pipe_consumed = Some(consumed.clone());
13275 assert_eq!(
13276 observed_launch_nonce_source(&snapshot).as_deref(),
13277 Some("env")
13278 );
13279 consumed.set(()).unwrap();
13280 assert_eq!(
13281 observed_launch_nonce_source(&snapshot).as_deref(),
13282 Some("pipe")
13283 );
13284 snapshot.pipe_consumed = Some(Arc::new(OnceLock::new()));
13285 assert_eq!(
13286 observed_launch_nonce_source(&snapshot).as_deref(),
13287 Some("env")
13288 );
13289 clear_current_process_facts(&mut snapshot);
13290 assert_eq!(observed_launch_nonce_source(&snapshot), None);
13291 }
13292 use super::{
13293 apply_child_env, apply_spawn_role, apply_wire_spawn_args, ModuleProtocol, ModuleSpec,
13294 SpawnRole, SupervisorHandle, SPAWN_ROLE_SWAP_CANDIDATE, SUBC_ARG, SUBC_LAUNCH_NONCE_ENV,
13295 SUBC_MODULE_ID_ENV, SUBC_SPAWN_ROLE_ENV,
13296 };
13297 use std::{ffi::OsStr, path::PathBuf};
13298 use tokio::process::Command;
13299
13300 fn spec(env: Vec<(String, String)>) -> ModuleSpec {
13301 ModuleSpec {
13302 module_id: "env-plan".to_string(),
13303 program: PathBuf::from("/nonexistent"),
13304 args: Vec::new(),
13305 env,
13306 reserved: false,
13307 reserved_prefixes: Vec::new(),
13308 protocol: ModuleProtocol::Subc,
13309 overlap: Default::default(),
13310 }
13311 }
13312
13313 #[test]
13327 fn ambient_ck_log_is_removed_and_a_configured_one_survives() {
13328 let mut command = Command::new("/nonexistent");
13329 apply_child_env(&mut command, &spec(Vec::new()));
13330 let removed = command
13331 .as_std()
13332 .get_envs()
13333 .any(|(key, value)| key == OsStr::new("CK_LOG") && value.is_none());
13334 assert!(
13335 removed,
13336 "ambient CK_LOG must be explicitly removed for an unconfigured module"
13337 );
13338
13339 let mut configured = Command::new("/nonexistent");
13340 apply_child_env(
13341 &mut configured,
13342 &spec(vec![("CK_LOG".to_string(), "debug".to_string())]),
13343 );
13344 let effective = configured
13345 .as_std()
13346 .get_envs()
13347 .filter(|(key, _)| *key == OsStr::new("CK_LOG"))
13348 .last()
13349 .map(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()));
13350 assert_eq!(
13351 effective,
13352 Some(Some("debug".to_string())),
13353 "a module's configured CK_LOG must survive the ambient removal"
13354 );
13355 }
13356
13357 #[test]
13366 fn protocol_none_spawn_carries_no_subc_argument_and_no_nonce() {
13367 let connection_file = std::path::Path::new("/run/subc-connection.json");
13368 let handle = SupervisorHandle::new();
13369
13370 let mut none_spec = spec(Vec::new());
13371 none_spec.protocol = ModuleProtocol::None;
13372 let mut none = Command::new("/nonexistent");
13373 let none_handoff =
13374 apply_wire_spawn_args(&mut none, &none_spec, Some(connection_file), Some(&handle))
13375 .expect("protocol-none spawn args apply");
13376 assert!(
13377 none_handoff.is_none(),
13378 "protocol:none spawn must not receive a nonce descriptor"
13379 );
13380 #[cfg(windows)]
13381 assert!(!none.as_std().get_envs().any(|(key, value)| key
13382 == OsStr::new(subc_os::LAUNCH_NONCE_PIPE_FALLBACK_ENV)
13383 && value.is_some()));
13384 assert!(
13385 !none.as_std().get_envs().any(|(key, value)| key
13386 == OsStr::new(subc_os::LAUNCH_NONCE_FD_ENV)
13387 && value.is_some()),
13388 "protocol:none spawn must not name a nonce descriptor"
13389 );
13390 let none_args: Vec<String> = none
13391 .as_std()
13392 .get_args()
13393 .map(|a| a.to_string_lossy().into_owned())
13394 .collect();
13395 assert!(
13396 !none_args.iter().any(|a| a == SUBC_ARG),
13397 "protocol:none argv must not carry --subc; got {none_args:?}"
13398 );
13399 let none_has_nonce = none
13400 .as_std()
13401 .get_envs()
13402 .any(|(key, value)| key == OsStr::new(SUBC_LAUNCH_NONCE_ENV) && value.is_some());
13403 assert!(
13404 !none_has_nonce,
13405 "protocol:none spawn must not receive a launch nonce"
13406 );
13407 let none_has_module_id = none
13408 .as_std()
13409 .get_envs()
13410 .any(|(key, value)| key == OsStr::new(SUBC_MODULE_ID_ENV) && value.is_some());
13411 assert!(
13412 none_has_module_id,
13413 "SUBC_MODULE_ID is inert and stays on every path"
13414 );
13415 assert!(
13416 handle.spawn_nonce(&none_spec.module_id).is_none(),
13417 "no nonce record for a process that will never present one"
13418 );
13419
13420 let wire_spec = spec(Vec::new());
13422 let mut wire = Command::new("/nonexistent");
13423 let wire_handoff =
13424 apply_wire_spawn_args(&mut wire, &wire_spec, Some(connection_file), Some(&handle))
13425 .expect("subc-wire spawn args apply");
13426 let wire_fd_env = wire
13427 .as_std()
13428 .get_envs()
13429 .find(|(key, _)| *key == OsStr::new(subc_os::LAUNCH_NONCE_FD_ENV))
13430 .and_then(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()));
13431 #[cfg(unix)]
13432 assert_eq!(
13433 wire_fd_env,
13434 Some(wire_handoff.expect("a descriptor handoff").fd_env_value()),
13435 "a subc-wire spawn names the pipe it will receive at descriptor 3"
13436 );
13437 #[cfg(windows)]
13438 assert!(wire_handoff.is_some() && wire_fd_env.is_none());
13439 #[cfg(not(any(unix, windows)))]
13440 assert!(wire_handoff.is_none() && wire_fd_env.is_none());
13441 let wire_args: Vec<String> = wire
13442 .as_std()
13443 .get_args()
13444 .map(|a| a.to_string_lossy().into_owned())
13445 .collect();
13446 assert_eq!(
13447 wire_args,
13448 vec![
13449 SUBC_ARG.to_string(),
13450 connection_file.to_string_lossy().into_owned()
13451 ],
13452 "a subc-wire spawn still carries --subc <path>"
13453 );
13454 assert_eq!(
13455 wire.as_std()
13456 .get_envs()
13457 .any(|(key, value)| key == OsStr::new(SUBC_LAUNCH_NONCE_ENV) && value.is_some()),
13458 !cfg!(unix),
13459 "only Windows supplies the environment nonce"
13460 );
13461 assert!(handle.spawn_nonce(&wire_spec.module_id).is_some());
13462 }
13463
13464 #[test]
13474 fn plain_spawn_removes_the_spawn_role_even_when_the_spec_sets_it() {
13475 let role = |command: &Command| {
13476 command
13477 .as_std()
13478 .get_envs()
13479 .filter(|(key, _)| *key == OsStr::new(SUBC_SPAWN_ROLE_ENV))
13480 .last()
13481 .map(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()))
13482 };
13483 let forged = spec(vec![(
13484 SUBC_SPAWN_ROLE_ENV.to_string(),
13485 SPAWN_ROLE_SWAP_CANDIDATE.to_string(),
13486 )]);
13487
13488 let mut plain = Command::new("/nonexistent");
13489 apply_child_env(&mut plain, &forged);
13490 apply_spawn_role(&mut plain, SpawnRole::Plain);
13491 assert_eq!(
13492 role(&plain),
13493 Some(None),
13494 "a plain spawn must remove SUBC_SPAWN_ROLE, whatever the spec says"
13495 );
13496
13497 let mut candidate = Command::new("/nonexistent");
13498 apply_child_env(&mut candidate, &spec(Vec::new()));
13499 apply_spawn_role(&mut candidate, SpawnRole::SwapCandidate);
13500 assert_eq!(
13501 role(&candidate),
13502 Some(Some(SPAWN_ROLE_SWAP_CANDIDATE.to_string()))
13503 );
13504 }
13505
13506 #[test]
13512 fn daemon_private_capture_keys_are_not_passed_to_the_child() {
13513 let mut command = Command::new("/nonexistent");
13514 apply_child_env(
13515 &mut command,
13516 &spec(vec![
13517 (super::CAPTURE_KEEP_ENV.to_string(), "5".to_string()),
13518 ("KEPT".to_string(), "yes".to_string()),
13519 ]),
13520 );
13521 let keys: Vec<String> = command
13522 .as_std()
13523 .get_envs()
13524 .filter(|(_, value)| value.is_some())
13525 .map(|(key, _)| key.to_string_lossy().into_owned())
13526 .collect();
13527 assert!(keys.contains(&"KEPT".to_string()), "got {keys:?}");
13528 assert!(
13529 !keys.contains(&super::CAPTURE_KEEP_ENV.to_string()),
13530 "daemon-private capture key leaked to the child: {keys:?}"
13531 );
13532 }
13533}
13534
13535#[cfg(test)]
13536mod jitter_tests {
13537 use super::jittered_health_delay;
13538 use std::{collections::HashSet, time::Duration};
13539
13540 const FLEET: [&str; 14] = [
13549 "aft",
13550 "alfonso-core",
13551 "magic-context",
13552 "broca",
13553 "thalamus",
13554 "quota",
13555 "engram",
13556 "plexus",
13557 "cerebellum",
13558 "astrocyte",
13559 "synapse",
13560 "subc-mcp",
13561 "cortexkit-credentials",
13562 "subc-federation",
13563 ];
13564
13565 #[test]
13573 fn probe_delays_disperse_across_the_fleet() {
13574 let cadence = Duration::from_secs(30);
13575 let delays: HashSet<Duration> = FLEET
13576 .iter()
13577 .map(|id| jittered_health_delay(id, 0, cadence))
13578 .collect();
13579 assert_eq!(
13580 delays.len(),
13581 FLEET.len(),
13582 "every supervised module must land on its own probe offset"
13583 );
13584 }
13585
13586 #[test]
13592 fn jitter_only_delays_and_stays_within_one_tenth_of_cadence() {
13593 let cadence = Duration::from_secs(30);
13594 let span = cadence / 10;
13595 for id in FLEET {
13596 for probe_index in 0..8 {
13597 let delay = jittered_health_delay(id, probe_index, cadence);
13598 assert!(
13599 delay >= cadence,
13600 "{id}#{probe_index}: jitter must not shorten the cadence"
13601 );
13602 assert!(
13603 delay < cadence + span,
13604 "{id}#{probe_index}: jitter must stay inside one tenth of the cadence"
13605 );
13606 }
13607 }
13608 }
13609
13610 #[test]
13616 fn a_module_offset_is_stable_across_restarts() {
13617 let cadence = Duration::from_secs(30);
13618 for id in FLEET {
13619 assert_eq!(
13620 jittered_health_delay(id, 0, cadence),
13621 jittered_health_delay(id, 0, cadence),
13622 "{id}: the same module and probe index must produce the same offset"
13623 );
13624 }
13625 }
13626
13627 #[test]
13629 fn zero_cadence_yields_zero_delay() {
13630 assert_eq!(
13631 jittered_health_delay("aft", 0, Duration::ZERO),
13632 Duration::ZERO
13633 );
13634 }
13635}
13636
13637#[cfg(all(test, target_os = "linux"))]
13638mod cgroup_placement_tests {
13639 use super::{
13640 apply_cgroup_placement, remove_module_cgroup, ModuleProtocol, ModuleSpec, SuperviseError,
13641 SupervisedChild,
13642 };
13643 use crate::stderr_tail::{StderrRing, StderrTailConfig};
13644 use cortexkit_test_support::ScratchDir;
13645 use std::{
13646 fs, io,
13647 path::{Path, PathBuf},
13648 sync::{Arc, Mutex},
13649 };
13650 use tokio::process::Command;
13651
13652 #[tokio::test]
13653 async fn unique_spawn_cgroups_clean_up_across_restarts_and_shutdown() {
13654 use super::*;
13655 let dir = ScratchDir::new("unique-spawn-cgroups");
13656 let root = PathBuf::from(format!(
13657 "/sys/fs/cgroup/subc-unique-test-{}-{}",
13658 std::process::id(),
13659 unix_ms_now()
13660 ));
13661 if let Err(error) = fs::create_dir(&root) {
13662 assert!(
13663 std::env::var_os("SUBC_REQUIRE_CGROUP_TEST").is_none(),
13664 "required cgroup test cannot execute: {error}"
13665 );
13666 eprintln!("SKIP unique_spawn_cgroups_clean_up_across_restarts_and_shutdown: {error}");
13667 return;
13668 }
13669 let placement = subc_cgroup::prepare_at(&root).unwrap().unwrap();
13670 let group_count = || {
13671 fs::read_dir(root.join("subc-modules"))
13672 .unwrap()
13673 .map(|entry| entry.unwrap().file_type().unwrap())
13674 .filter(|kind| kind.is_dir())
13675 .count()
13676 };
13677 let supervisor =
13678 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
13679 .with_cgroup_placement(Some(placement.clone()));
13680 let runtime = supervisor.runtime_config();
13681 let mut spec = ModuleSpec {
13682 module_id: "unique-spawn".into(),
13683 program: PathBuf::from("/bin/sleep"),
13684 args: vec!["60".into()],
13685 env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
13686 .into_iter()
13687 .map(|key| (key.into(), dir.to_string_lossy().into_owned()))
13688 .collect(),
13689 reserved: false,
13690 reserved_prefixes: vec![],
13691 protocol: ModuleProtocol::None,
13692 overlap: Default::default(),
13693 };
13694 let spawn = |spec: &ModuleSpec| {
13695 spawn_child(
13696 spec,
13697 None,
13698 None,
13699 &runtime.stderr_ring,
13700 None,
13701 &runtime.child_roster,
13702 Some(&placement),
13703 )
13704 .unwrap()
13705 };
13706 let mut live = spawn(&spec);
13707 for _ in 0..3 {
13708 let next = spawn(&spec);
13710 assert_ne!(live.module_id, next.module_id);
13711 live.start_kill().unwrap();
13712 live.wait().await.unwrap();
13713 live = next;
13714 assert!(
13715 live.child.try_wait().unwrap().is_none(),
13716 "retiring the old slot must not kill the replacement"
13717 );
13718 assert_eq!(
13719 group_count(),
13720 1,
13721 "only the live spawn's cgroup should remain"
13722 );
13723 }
13724 supervisor.begin_daemon_shutdown();
13725 let reap = tokio::spawn(async move {
13726 live.wait().await.unwrap();
13727 });
13728 supervisor
13729 .end_children_for_daemon_shutdown(false, std::future::pending())
13730 .await;
13731 reap.await.unwrap();
13732 assert_eq!(group_count(), 0);
13733 spec.program = PathBuf::from("/bin/true");
13735 spec.args.clear();
13736 let fresh_roster = ChildRoster::default();
13737 let mut short = spawn_child(
13738 &spec,
13739 None,
13740 None,
13741 &runtime.stderr_ring,
13742 None,
13743 &fresh_roster,
13744 Some(&placement),
13745 )
13746 .unwrap();
13747 short.wait().await.unwrap();
13748 assert_eq!(group_count(), 0);
13749 spec.module_id = "_".repeat(255);
13750 let mut long_id = spawn_child(
13751 &spec,
13752 None,
13753 None,
13754 &runtime.stderr_ring,
13755 None,
13756 &fresh_roster,
13757 Some(&placement),
13758 )
13759 .unwrap();
13760 long_id.wait().await.unwrap();
13761 assert_eq!(
13762 group_count(),
13763 0,
13764 "valid long module IDs must not exceed cgroup NAME_MAX"
13765 );
13766 fs::remove_dir(root.join("subc-modules")).unwrap();
13767 fs::remove_dir(root).unwrap();
13768 eprintln!("EXECUTED unique_spawn_cgroups_clean_up_across_restarts_and_shutdown");
13769 }
13770
13771 #[test]
13772 fn failed_parent_cgroup_open_is_a_cgroup_supervision_error() {
13773 let path = Path::new("/definitely-missing-subc-cgroup");
13774 let mut command = Command::new("true");
13775 let error = apply_cgroup_placement(
13776 &mut command,
13777 &ModuleSpec {
13778 module_id: "broken-cgroup".to_string(),
13779 program: PathBuf::from("true"),
13780 args: Vec::new(),
13781 env: Vec::new(),
13782 reserved: false,
13783 reserved_prefixes: Vec::new(),
13784 protocol: ModuleProtocol::Subc,
13785 overlap: Default::default(),
13786 },
13787 path,
13788 )
13789 .expect_err("a parent cgroup open failure must reject the supervised spawn");
13790 let reason = error.to_string();
13791
13792 assert!(
13793 matches!(error, SuperviseError::Cgroup { .. }),
13794 "parent cgroup open must be reported as a cgroup supervision error: {reason}"
13795 );
13796 assert!(
13797 reason.contains("/definitely-missing-subc-cgroup/cgroup.procs"),
13798 "parent cgroup open failure must name cgroup.procs: {reason}"
13799 );
13800 }
13801
13802 #[tokio::test]
13803 async fn reaping_a_child_removes_its_empty_module_cgroup() {
13804 let root = ScratchDir::new("supervisor-reap-cgroup");
13805 fs::write(root.join("cgroup.procs"), b"").expect("write scratch cgroup marker");
13806 let placement = subc_cgroup::prepare_at(&root)
13807 .expect("prepare scratch cgroup root")
13808 .expect("scratch root has a cgroup.procs marker");
13809 let module_id = "reaped-module";
13810 let module = placement
13811 .module_path(module_id)
13812 .expect("create scratch module cgroup");
13813 let child = Command::new("true")
13814 .env("XDG_DATA_HOME", root.path())
13815 .env("XDG_RUNTIME_DIR", root.path())
13816 .env("XDG_CONFIG_HOME", root.path())
13817 .spawn()
13818 .expect("spawn short-lived child");
13819 let pid = child.id().expect("spawned child has pid");
13820 let mut child = SupervisedChild {
13821 child,
13822 protocol: ModuleProtocol::Subc,
13823 module_id: module_id.to_string(),
13824 cgroup_placement: Some(placement),
13825 stdout_pump: None,
13826 stderr_pump: None,
13827 stderr_ring: Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default()))),
13828 spawned_at_ms: 0,
13829 spawned_from: PathBuf::from("true"),
13830 spawned_file_identity: None,
13831 process_start_time: None,
13832 process_identity: None,
13833 pid,
13834 roster_guard: None,
13835 #[cfg(target_os = "macos")]
13836 privacy_exec: None,
13837 spawn_failure: None,
13838 };
13839
13840 child.wait().await.expect("reap short-lived child");
13841
13842 assert!(
13843 !module.exists(),
13844 "reaping the supervised child must remove its empty cgroup"
13845 );
13846 }
13847
13848 #[test]
13849 fn non_empty_cgroup_removal_is_reported_without_blocking_teardown() {
13850 let root = ScratchDir::new("supervisor-non-empty-cgroup");
13851 fs::write(root.join("cgroup.procs"), b"").expect("write scratch cgroup marker");
13852 let placement = subc_cgroup::prepare_at(&root)
13853 .expect("prepare scratch cgroup root")
13854 .expect("scratch root has a cgroup.procs marker");
13855 let module = placement
13856 .module_path("surviving-module")
13857 .expect("create scratch module cgroup");
13858 fs::write(module.join("surviving-process"), b"still present")
13859 .expect("make scratch cgroup non-empty");
13860 let (logs, _guard) = crate::router::test_log::log_capture(tracing::Level::WARN);
13861
13862 remove_module_cgroup(&placement, "surviving-module");
13863
13864 let logs = crate::router::test_log::captured_logs(&logs);
13865 assert!(
13866 module.exists(),
13867 "failed removal must leave the cgroup intact"
13868 );
13869 assert!(
13870 logs.contains("could not remove module cgroup after process exit; continuing teardown")
13871 && logs.contains("surviving-module"),
13872 "best-effort removal must report the failure without returning it: {logs}"
13873 );
13874 }
13875
13876 #[test]
13877 fn cgroup_pre_exec_spawn_failure_names_the_cgroup_path() {
13878 let cgroup_path = PathBuf::from("/sys/fs/cgroup/subc-modules/broken-module");
13879 let reason = SuperviseError::Spawn {
13880 program: PathBuf::from("/bin/true"),
13881 source: io::Error::from_raw_os_error(13),
13882 cgroup_path: Some(cgroup_path.clone()),
13883 }
13884 .to_string();
13885
13886 assert!(
13887 reason.contains(&cgroup_path.display().to_string()),
13888 "a pre_exec spawn failure must name the cgroup path: {reason}"
13889 );
13890 }
13891}
13892
13893#[cfg(test)]
13894mod spawn_subscriber_lag_tests {
13895 use super::*;
13896
13897 #[tokio::test]
13902 async fn lagged_spawn_subscriber_receives_a_terminal_lagged_error_after_its_queued_frames() {
13903 let feed = SpawnEventFeed::default();
13904 feed.configure_incarnation("lag-incarnation".to_string());
13905 let (tx, mut rx) = mpsc::channel(1);
13908 feed.subscribe(ConnectionId::new(1), 7, 1, None, FrameSink::new(tx))
13909 .expect("subscribe");
13910 let emitted = SPAWN_SUBSCRIBER_BUFFER + 16;
13911 for index in 0..emitted {
13912 feed.emit_spawned(&format!("lag-module-{index}"), 1000, 0);
13913 tokio::task::yield_now().await;
13916 }
13917 assert_eq!(
13918 feed.subscriber_count(),
13919 0,
13920 "the lagged subscriber must be removed"
13921 );
13922
13923 let mut data = Vec::new();
13924 let mut last = None;
13925 loop {
13926 let next = tokio::time::timeout(Duration::from_secs(5), rx.recv())
13927 .await
13928 .expect("the forwarder must finish once the subscriber is dropped");
13929 let Some(outbound) = next else { break };
13930 let frame = outbound.frame;
13931 if frame.header.ty == FrameType::StreamData {
13932 assert!(last.is_none(), "no data may follow the terminal frame");
13933 let event: SpawnEvent = serde_json::from_slice(&frame.body).unwrap();
13934 data.push(event.cursor.seq);
13935 } else {
13936 assert!(last.is_none(), "exactly one terminal frame");
13937 last = Some(frame);
13938 }
13939 }
13940 assert!(!data.is_empty(), "queued frames drain before the terminal");
13941 for pair in data.windows(2) {
13942 assert_eq!(
13943 pair[1],
13944 pair[0] + 1,
13945 "queued frames arrive dense and in order"
13946 );
13947 }
13948 let terminal = last.expect("a lagged subscriber must receive a terminal frame");
13949 assert_eq!(terminal.header.ty, FrameType::Error);
13950 assert_eq!(terminal.header.corr, 7);
13951 let body: subc_protocol::ErrorBody = serde_json::from_slice(&terminal.body).unwrap();
13952 assert_eq!(body.code, SPAWN_SUBSCRIBER_LAGGED_CODE);
13953 let detail = body.detail.expect("lagged error carries detail");
13954 assert_eq!(
13955 detail["first_undelivered_cursor"]["seq"],
13956 data.last().unwrap() + 1,
13957 "the named cursor is the first event the subscriber did not receive"
13958 );
13959 assert_eq!(
13960 detail["first_undelivered_cursor"]["daemon_incarnation"],
13961 "lag-incarnation"
13962 );
13963 }
13964}
13965
13966#[cfg(test)]
13967mod terminal_history_read_concurrency_tests {
13968 use super::*;
13969 use crate::terminal_journal::read_pause;
13970 use cortexkit_test_support::ScratchDir;
13971 use std::sync::mpsc as std_mpsc;
13972
13973 fn journaled_ring(
13974 journal: &Arc<crate::terminal_journal::TerminalJournal>,
13975 ) -> Arc<Mutex<TerminalRing>> {
13976 Arc::new(Mutex::new(
13977 TerminalRing::new(TerminalRingConfig::default(), 1)
13978 .with_journal(Some(Arc::clone(journal))),
13979 ))
13980 }
13981
13982 fn crash(at_ms: u64) -> ExitReport {
13983 ExitReport {
13984 kind: ExitKind::Crash,
13985 code: Some(1),
13986 signal: None,
13987 at_ms,
13988 }
13989 }
13990
13991 fn record_within(
13994 module_id: &'static str,
13995 ring: &Arc<Mutex<TerminalRing>>,
13996 at_ms: u64,
13997 bound: Duration,
13998 ) -> bool {
13999 let ring = Arc::clone(ring);
14000 let (done, done_rx) = std_mpsc::channel();
14001 std::thread::spawn(move || {
14002 record_terminal(
14003 module_id,
14004 &ring,
14005 &SpawnEventFeed::default(),
14006 &crash(at_ms),
14007 TerminalDisposition::Restarting,
14008 );
14009 let _ = done.send(());
14010 });
14011 done_rx.recv_timeout(bound).is_ok()
14012 }
14013
14014 #[test]
14019 fn exits_recorded_during_a_paused_history_read_are_not_blocked_or_half_merged() {
14020 let dir = ScratchDir::new("terminal-history-concurrent-read");
14021 let path = dir.join("terminals.jsonl");
14022 let journal = Arc::new(crate::terminal_journal::TerminalJournal::open(
14023 path.clone(),
14024 "daemon".into(),
14025 ));
14026 let reader_ring = journaled_ring(&journal);
14027 let other_ring = journaled_ring(&journal);
14028 assert!(record_within(
14029 "reader-module",
14030 &reader_ring,
14031 10,
14032 Duration::from_secs(5)
14033 ));
14034
14035 let (started, release) = read_pause::install(&path);
14036 let reading = {
14037 let ring = Arc::clone(&reader_ring);
14038 std::thread::spawn(move || durable_terminal_history_of(&ring, "reader-module"))
14039 };
14040 started
14041 .recv_timeout(Duration::from_secs(5))
14042 .expect("the history read reached its pause");
14043
14044 let bound = Duration::from_secs(1);
14045 assert!(
14046 record_within("other-module", &other_ring, 20, bound),
14047 "another module's exit waited on a history read (journal writer held)"
14048 );
14049 assert!(
14050 record_within("reader-module", &reader_ring, 30, bound),
14051 "the read module's own exit waited on its history read (ring held)"
14052 );
14053
14054 drop(release);
14055 let paused = reading.join().unwrap();
14056 assert_eq!(
14057 paused.entries.iter().map(|e| e.at_ms).collect::<Vec<_>>(),
14058 vec![10],
14059 "an exit recorded after the read began lands in neither half of it"
14060 );
14061 assert_eq!(paused.journal_skipped_lines, 0);
14062 assert_eq!(paused.journal_read_errors, 0);
14063
14064 let after = durable_terminal_history_of(&reader_ring, "reader-module");
14065 assert_eq!(
14066 after.entries.iter().map(|e| e.at_ms).collect::<Vec<_>>(),
14067 vec![10, 30],
14068 "the next read merges ring and journal with no duplicate"
14069 );
14070 assert_eq!(after.journal_skipped_lines, 0);
14071 }
14072}
14073
14074#[cfg(test)]
14079mod stderr_settle_tests {
14080 use std::{
14081 future::Future,
14082 io,
14083 pin::Pin,
14084 sync::{Arc, Mutex},
14085 task::{Context, Poll},
14086 time::Duration,
14087 };
14088
14089 use tokio::{
14090 io::{AsyncRead, ReadBuf},
14091 sync::oneshot,
14092 time::Instant,
14093 };
14094
14095 use super::{settle_stderr_pump, settle_stdout_pump, StderrPump};
14096 use crate::stderr_tail::{
14097 pump_stderr_to, pump_stdout_to, split_capture_stamp, untimed, CaptureState,
14098 ChildOutputSink, OutputSink, StderrRing, StderrTailConfig, TailEntry,
14099 };
14100
14101 const BOUND: Duration = Duration::from_millis(250);
14102
14103 struct HeldReader {
14107 before: Option<Vec<u8>>,
14108 gate: Option<oneshot::Receiver<()>>,
14109 after: io::Cursor<Vec<u8>>,
14110 }
14111
14112 impl AsyncRead for HeldReader {
14113 fn poll_read(
14114 mut self: Pin<&mut Self>,
14115 cx: &mut Context<'_>,
14116 buf: &mut ReadBuf<'_>,
14117 ) -> Poll<io::Result<()>> {
14118 if let Some(bytes) = self.before.take() {
14119 buf.put_slice(&bytes);
14120 return Poll::Ready(Ok(()));
14121 }
14122 if let Some(gate) = self.gate.as_mut() {
14123 match Pin::new(gate).poll(cx) {
14124 Poll::Pending => return Poll::Pending,
14125 Poll::Ready(_) => self.gate = None,
14126 }
14127 }
14128 Pin::new(&mut self.after).poll_read(cx, buf)
14129 }
14130 }
14131
14132 struct DiscardSink;
14133
14134 impl OutputSink for DiscardSink {
14135 fn write_line(&mut self, _line: &[u8]) {}
14136 }
14137
14138 fn line(text: &str) -> TailEntry {
14139 TailEntry::Line {
14140 text: text.to_string(),
14141 truncated: false,
14142 at_ms: None,
14143 }
14144 }
14145
14146 fn lock(ring: &Arc<Mutex<StderrRing>>) -> std::sync::MutexGuard<'_, StderrRing> {
14147 ring.lock().unwrap()
14148 }
14149
14150 fn held_pump(
14154 ring: &Arc<Mutex<StderrRing>>,
14155 before: &str,
14156 after: &str,
14157 ) -> (StderrPump, oneshot::Sender<()>) {
14158 let generation = lock(ring).begin_process();
14159 let (release, gate) = oneshot::channel();
14160 let reader = HeldReader {
14161 before: Some(before.as_bytes().to_vec()),
14162 gate: Some(gate),
14163 after: io::Cursor::new(after.as_bytes().to_vec()),
14164 };
14165 let task = tokio::spawn(pump_stderr_to(
14166 reader,
14167 Arc::clone(ring),
14168 generation,
14169 DiscardSink,
14170 ));
14171 (StderrPump { task, generation }, release)
14172 }
14173
14174 async fn wait_until(ring: &Arc<Mutex<StderrRing>>, done: impl Fn(&StderrRing) -> bool) {
14175 for _ in 0..1000 {
14176 if done(&lock(ring)) {
14177 return;
14178 }
14179 tokio::time::sleep(Duration::from_millis(1)).await;
14180 }
14181 panic!(
14182 "ring never reached the expected state: {:?}",
14183 lock(ring).snapshot(None, None)
14184 );
14185 }
14186
14187 #[tokio::test(start_paused = true)]
14188 async fn a_crash_line_the_reader_had_not_reached_by_the_bound_is_kept_before_the_restart() {
14189 let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
14190 let (pump, release) = held_pump(&ring, "booting\n", "config error: missing storage\n");
14191
14192 settle_stderr_pump("crasher", &ring, pump, BOUND).await;
14193 let before_release = lock(&ring).snapshot(None, None);
14194 assert!(
14195 matches!(before_release.capture, CaptureState::Incomplete { .. }),
14196 "a reader that has not reached EOF cannot claim a whole tail: {before_release:?}"
14197 );
14198
14199 let next = lock(&ring).begin_process();
14202 lock(&ring).push_line_from(next, "next process booting");
14203 release.send(()).unwrap();
14204 wait_until(&ring, |ring| {
14205 ring.snapshot(None, None).capture == CaptureState::Captured
14206 })
14207 .await;
14208
14209 assert_eq!(
14210 untimed(lock(&ring).snapshot(None, None).entries),
14211 vec![
14212 line("booting"),
14213 line("config error: missing storage"),
14214 TailEntry::ProcessStart,
14215 line("next process booting"),
14216 ],
14217 "the crash's last line must survive a slow reader and stay in the crashed process's section"
14218 );
14219 }
14220
14221 #[tokio::test(start_paused = true)]
14222 async fn a_pipe_held_open_by_a_descendant_reads_incomplete_without_delaying_the_restart_past_the_bound(
14223 ) {
14224 let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
14225 let (pump, _held) = held_pump(&ring, "parent exiting\n", "");
14228
14229 let started = Instant::now();
14230 settle_stderr_pump("orphaning", &ring, pump, BOUND).await;
14231 assert_eq!(
14232 started.elapsed(),
14233 BOUND,
14234 "the restart must wait exactly the bound for a pipe that stays open, no longer"
14235 );
14236
14237 let next = lock(&ring).begin_process();
14238 lock(&ring).push_line_from(next, "next process booting");
14239 tokio::time::sleep(Duration::from_secs(60)).await;
14240
14241 let snapshot = lock(&ring).snapshot(None, None);
14242 match &snapshot.capture {
14243 CaptureState::Incomplete { reason } => assert!(
14244 reason.contains("had not reached EOF") && reason.contains("250ms"),
14245 "the reason must say what is missing and after how long: {reason}"
14246 ),
14247 other => panic!("expected Incomplete while the pipe is held open, got {other:?}"),
14248 }
14249 assert_eq!(
14250 untimed(snapshot.entries),
14251 vec![
14252 line("parent exiting"),
14253 TailEntry::ProcessStart,
14254 line("next process booting"),
14255 ]
14256 );
14257 }
14258
14259 #[tokio::test(start_paused = true)]
14260 async fn a_reader_that_reaches_eof_within_the_bound_leaves_the_tail_captured() {
14261 let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
14262 let (pump, release) = held_pump(&ring, "one\n", "two\n");
14263 release.send(()).unwrap();
14264
14265 settle_stderr_pump("clean", &ring, pump, BOUND).await;
14266
14267 let snapshot = lock(&ring).snapshot(None, None);
14268 assert_eq!(snapshot.capture, CaptureState::Captured);
14269 assert_eq!(untimed(snapshot.entries), vec![line("one"), line("two")]);
14270 }
14271
14272 #[tokio::test(start_paused = true)]
14273 async fn stdout_buffered_past_the_exit_drain_bound_reaches_the_capture_file() {
14274 const BURST: usize = 150;
14275 let capture = cortexkit_test_support::ScratchDir::new("stdout-exit-drain");
14276 let path = capture.join("two-pipe.stderr.log");
14277 let sink = ChildOutputSink::open(&path, cortexkit_log::Retention::default()).unwrap();
14278 let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
14279 let generation = lock(&ring).begin_process();
14280 let stdout = (0..BURST)
14281 .map(|i| format!("out-{i:04}-{}\n", "o".repeat(64)))
14282 .collect::<String>();
14283 let stderr = (0..BURST)
14284 .map(|i| format!("err-{i:04}-{}\n", "e".repeat(64)))
14285 .collect::<String>();
14286 let first_chunk = stdout.find('\n').unwrap() + 1 + "out-0001-".len();
14291 let (release, gate) = oneshot::channel();
14292 let reader = HeldReader {
14293 before: Some(stdout.as_bytes()[..first_chunk].to_vec()),
14294 gate: Some(gate),
14295 after: io::Cursor::new(stdout.as_bytes()[first_chunk..stdout.len() - 1].to_vec()),
14296 };
14297 let (finished, completion) = oneshot::channel();
14298 let stdout_sink = sink.clone();
14299 let pump = tokio::spawn(async move {
14300 pump_stdout_to(reader, stdout_sink).await;
14301 finished.send(()).unwrap();
14302 });
14303 pump_stderr_to(io::Cursor::new(stderr.into_bytes()), ring, generation, sink).await;
14304
14305 let started = Instant::now();
14306 settle_stdout_pump("two-pipe", pump, BOUND).await;
14307 assert_eq!(
14308 started.elapsed(),
14309 BOUND,
14310 "a late reader must not hold up restart"
14311 );
14312 assert_eq!(
14313 std::fs::read_to_string(&path).unwrap().lines().count(),
14314 BURST + 1
14315 );
14316
14317 release
14318 .send(())
14319 .expect("stdout reader must survive the exit drain bound");
14320 tokio::time::timeout(Duration::from_secs(1), completion)
14321 .await
14322 .expect("released stdout reader must reach EOF")
14323 .expect("stdout capture must finish at EOF, not be cancelled");
14324 let contents = std::fs::read_to_string(&path).unwrap();
14325 let mut lines = contents
14326 .lines()
14327 .map(|line| split_capture_stamp(line).expect("capture stamp").1)
14328 .collect::<Vec<_>>();
14329 let mut expected = (0..BURST)
14330 .flat_map(|i| {
14331 [
14332 format!("out-{i:04}-{}", "o".repeat(64)),
14333 format!("err-{i:04}-{}", "e".repeat(64)),
14334 ]
14335 })
14336 .collect::<Vec<_>>();
14337 lines.sort_unstable();
14338 expected.sort_unstable();
14339 assert_eq!(
14340 lines, expected,
14341 "both pipes must retain every complete line exactly once"
14342 );
14343 }
14344}
14345
14346#[cfg(all(test, windows))]
14360mod job_containment_tests {
14361 use super::*;
14362 use cortexkit_test_support::ScratchDir;
14363 use std::{
14364 path::{Path, PathBuf},
14365 sync::{Arc, Mutex},
14366 time::{Duration, Instant},
14367 };
14368
14369 fn stub_path() -> PathBuf {
14375 let mut path = std::env::current_exe().expect("current_exe available in tests");
14376 path.pop();
14377 path.pop();
14378 path.push("fake-aft-stub.exe");
14379 assert!(
14380 path.exists(),
14381 "fake-aft-stub not built at {}: run `cargo test -p subc-core` (which builds \
14382 [[bin]] targets) rather than `cargo test -p subc-core --lib` (which does not)",
14383 path.display()
14384 );
14385 path
14386 }
14387
14388 fn read_grandchild_pid(path: &Path) -> u32 {
14390 let deadline = Instant::now() + Duration::from_secs(10);
14391 loop {
14392 if let Ok(contents) = std::fs::read_to_string(path) {
14393 if let Ok(pid) = contents.trim().parse() {
14394 return pid;
14395 }
14396 }
14397 assert!(
14398 Instant::now() < deadline,
14399 "the stub never recorded a grandchild pid at {}",
14400 path.display()
14401 );
14402 std::thread::sleep(Duration::from_millis(10));
14403 }
14404 }
14405
14406 struct Fixture {
14409 _dir: ScratchDir,
14410 module_id: String,
14411 grandchild: u32,
14412 child: Option<SupervisedChild>,
14413 registry: Arc<Registry>,
14414 snapshot: Arc<Mutex<SupervisorSnapshot>>,
14415 terminal_ring: Arc<Mutex<TerminalRing>>,
14416 spawn_events: SpawnEventFeed,
14417 }
14418
14419 fn fixture(label: &str, module_id: &str) -> Fixture {
14420 let dir = ScratchDir::new(label);
14421 let pid_file = dir.join("grandchild.pid");
14422 let supervisor = Supervisor::new_for_test(
14423 Arc::new(Registry::default()),
14424 RestartPolicy::new(3, Duration::ZERO),
14425 );
14426 let runtime = supervisor.runtime_config();
14427 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
14428 let spec = ModuleSpec {
14429 module_id: module_id.to_string(),
14430 program: stub_path(),
14431 args: Vec::new(),
14435 env: vec![
14436 ("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string()),
14437 (
14438 "FAKE_AFT_GRANDCHILD_PID_FILE".to_string(),
14439 pid_file.display().to_string(),
14440 ),
14441 ],
14442 reserved: false,
14443 reserved_prefixes: Vec::new(),
14444 protocol: ModuleProtocol::Subc,
14445 overlap: Default::default(),
14446 };
14447 let child = spawn_and_mark_running(&spec, &runtime, &snapshot)
14448 .expect("spawn the supervised fixture");
14449 let grandchild = read_grandchild_pid(&pid_file);
14450 Fixture {
14451 _dir: dir,
14452 module_id: module_id.to_string(),
14453 grandchild,
14454 child: Some(child),
14455 registry: Arc::new(Registry::default()),
14456 snapshot,
14457 terminal_ring: Arc::clone(&runtime.terminal_ring),
14458 spawn_events: SpawnEventFeed::default(),
14459 }
14460 }
14461
14462 impl Fixture {
14463 async fn drain(&mut self) {
14465 let child = self
14466 .child
14467 .take()
14468 .expect("the fixture child is still present");
14469 drain_child_to_state(
14470 &self.module_id,
14471 ModuleProtocol::Subc,
14472 StopNotice::NotSent,
14475 &self.registry,
14476 None,
14477 &self.snapshot,
14478 &self.terminal_ring,
14479 &self.spawn_events,
14480 child,
14481 Duration::from_millis(500),
14482 ModuleState::Stopped,
14483 Some(false),
14484 )
14485 .await
14486 .expect("drain the supervised fixture");
14487 }
14488 }
14489
14490 #[tokio::test]
14496 async fn teardown_reaps_the_grandchild() {
14497 let mut fixture = fixture("teardown-grandchild", "tree-teardown");
14498 let grandchild = fixture.grandchild;
14499
14500 assert!(
14501 subc_jobobject::process_exists(grandchild),
14502 "grandchild {grandchild} must be alive before teardown, or this proves nothing"
14503 );
14504
14505 fixture.drain().await;
14506
14507 assert!(
14508 subc_jobobject::wait_for_process_exit(grandchild, Duration::from_secs(10)),
14509 "grandchild {grandchild} outlived module teardown: the tree was not contained"
14510 );
14511 }
14512
14513 #[test]
14526 fn an_uncontained_grandchild_survives_a_direct_child_kill() {
14527 let dir = ScratchDir::new("teardown-uncontained");
14528 let pid_file = dir.join("grandchild.pid");
14529 let mut child = std::process::Command::new(stub_path())
14530 .env("FAKE_AFT_NEVER_CONNECT", "1")
14531 .env(
14532 "FAKE_AFT_GRANDCHILD_PID_FILE",
14533 pid_file.display().to_string(),
14534 )
14535 .stdin(std::process::Stdio::null())
14536 .stdout(std::process::Stdio::null())
14537 .stderr(std::process::Stdio::null())
14538 .spawn()
14539 .expect("spawn the uncontained fixture");
14540 let grandchild = read_grandchild_pid(&pid_file);
14541
14542 child.kill().expect("kill the direct child");
14544 let _ = child.wait();
14545
14546 assert!(
14547 subc_jobobject::process_exists(grandchild),
14548 "grandchild {grandchild} died with the direct child, so this control no longer \
14549 distinguishes contained from uncontained teardown and the regression test is \
14550 passing vacuously"
14551 );
14552
14553 kill_tree(grandchild);
14556 }
14557
14558 #[tokio::test]
14567 async fn dropping_containment_reaps_the_grandchild() {
14568 let mut fixture = fixture("drop-containment", "tree-drop");
14569 let grandchild = fixture.grandchild;
14570
14571 assert!(subc_jobobject::process_exists(grandchild));
14572
14573 fixture.child.as_mut().expect("child present").job = None;
14575
14576 assert!(
14577 subc_jobobject::wait_for_process_exit(grandchild, Duration::from_secs(10)),
14578 "grandchild {grandchild} survived the containment handle closing, so a daemon \
14579 crash would leave the tree behind"
14580 );
14581 }
14582
14583 fn kill_tree(pid: u32) {
14585 let _ = std::process::Command::new("taskkill.exe")
14586 .args(["/PID", &pid.to_string(), "/T", "/F"])
14587 .stdin(std::process::Stdio::null())
14588 .stdout(std::process::Stdio::null())
14589 .stderr(std::process::Stdio::null())
14590 .status();
14591 assert!(
14592 subc_jobobject::wait_for_process_exit(pid, Duration::from_secs(10)),
14593 "could not clean up grandchild {pid}"
14594 );
14595 }
14596}
14597
14598#[cfg(test)]
14599mod privacy_trampoline_configuration_tests {
14600 #[tokio::test]
14601 #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
14602 async fn macos_spawn_without_a_configured_trampoline_refuses_by_name() {
14603 #[cfg(target_os = "macos")]
14604 {
14605 let supervisor = super::Supervisor::new(
14606 std::sync::Arc::new(crate::Registry::default()),
14607 super::RestartPolicy::default(),
14608 );
14609 let error = supervisor.spawn(spec()).unwrap_err();
14610 assert!(
14611 error
14612 .to_string()
14613 .contains("no privacy trampoline configured"),
14614 "{error}"
14615 );
14616 }
14617 }
14618
14619 #[tokio::test]
14620 #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
14621 async fn macos_wrong_trampoline_is_rejected_by_the_startup_probe() {
14622 #[cfg(target_os = "macos")]
14623 {
14624 let supervisor = super::Supervisor::new(
14625 std::sync::Arc::new(crate::Registry::default()),
14626 super::RestartPolicy::default(),
14627 )
14628 .with_privacy_trampoline(std::env::current_exe().unwrap());
14629 let error = supervisor.spawn(spec()).unwrap_err();
14630 assert!(
14631 error
14632 .to_string()
14633 .contains("binary does not implement the privacy trampoline protocol"),
14634 "{error}"
14635 );
14636 }
14637 }
14638
14639 #[cfg(target_os = "macos")]
14640 fn spec() -> super::ModuleSpec {
14641 super::ModuleSpec {
14642 module_id: "privacy-configuration".into(),
14643 program: "/bin/sleep".into(),
14644 args: vec!["30".into()],
14645 env: vec![],
14646 reserved: false,
14647 reserved_prefixes: vec![],
14648 protocol: subc_control::ModuleProtocol::None,
14649 overlap: super::ModuleOverlap::Exclusive,
14650 }
14651 }
14652}
14653
14654#[cfg(test)]
14655mod privacy_exec_boundary_tests {
14656 #[cfg(target_os = "macos")]
14657 use super::*;
14658 #[cfg(target_os = "macos")]
14659 use std::{
14660 io::{Read, Write},
14661 net::{TcpListener, TcpStream},
14662 };
14663
14664 #[cfg(target_os = "macos")]
14667 pub(super) fn before_image_sample(spec: &ModuleSpec, pid: u32) {
14668 if let Some((_, path)) = spec
14669 .env
14670 .iter()
14671 .find(|(key, _)| key == "SUBC_TEST_PRIVACY_SAMPLE_BARRIER")
14672 {
14673 let mut barrier = TcpStream::connect(path).unwrap();
14674 barrier
14675 .set_read_timeout(Some(Duration::from_secs(30)))
14676 .unwrap();
14677 barrier.write_all(&pid.to_ne_bytes()).unwrap();
14678 let mut release = [0];
14679 barrier.read_exact(&mut release).unwrap();
14680 assert_eq!(&release, b"X");
14681 }
14682 }
14683
14684 #[cfg(target_os = "macos")]
14685 fn spec(program: &str, args: &[&str]) -> ModuleSpec {
14686 ModuleSpec {
14687 module_id: "privacy-boundary".into(),
14688 program: program.into(),
14689 args: args.iter().map(|arg| (*arg).into()).collect(),
14690 env: vec![],
14691 reserved: false,
14692 reserved_prefixes: vec![],
14693 protocol: ModuleProtocol::None,
14694 overlap: ModuleOverlap::Exclusive,
14695 }
14696 }
14697
14698 #[cfg(target_os = "macos")]
14699 async fn accept(listener: TcpListener) -> TcpStream {
14700 let listener = tokio::net::TcpListener::from_std({
14702 listener.set_nonblocking(true).unwrap();
14703 listener
14704 })
14705 .unwrap();
14706 let (stream, _) = tokio::time::timeout(Duration::from_secs(30), listener.accept())
14707 .await
14708 .unwrap()
14709 .unwrap();
14710 let stream = stream.into_std().unwrap();
14711 stream.set_nonblocking(false).unwrap();
14712 stream
14713 .set_read_timeout(Some(Duration::from_secs(30)))
14714 .unwrap();
14715 stream
14716 }
14717
14718 #[tokio::test]
14719 #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
14720 async fn macos_roster_withholds_a_nonnull_trampoline_image_until_exec_confirmation() {
14721 #[cfg(target_os = "macos")]
14722 {
14723 let root = cortexkit_test_support::ScratchDir::new("privacy-roster-barrier");
14724 let exec_listener = TcpListener::bind("127.0.0.1:0").unwrap();
14727 let sample_listener = TcpListener::bind("127.0.0.1:0").unwrap();
14728 let record = root.join("live-children.json");
14729 let supervisor =
14730 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
14731 .with_live_children_record(&record);
14732 let runtime = supervisor.runtime_config();
14733 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
14734 let mut spec = spec("/bin/sleep", &["30"]);
14735 spec.env = vec![
14736 (
14737 "SUBC_TEST_PRIVACY_EXEC_BARRIER".into(),
14738 exec_listener.local_addr().unwrap().to_string(),
14739 ),
14740 (
14741 "SUBC_TEST_PRIVACY_SAMPLE_BARRIER".into(),
14742 sample_listener.local_addr().unwrap().to_string(),
14743 ),
14744 ];
14745 let spawn = tokio::task::spawn_blocking(move || {
14746 spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap()
14747 });
14748 let mut sample = accept(sample_listener).await;
14749 let mut pid = [0; 4];
14750 sample.read_exact(&mut pid).unwrap();
14751 let pid = u32::from_ne_bytes(pid);
14752 let mut exec = accept(exec_listener).await;
14753 let mut ready = [0];
14754 exec.read_exact(&mut ready).unwrap();
14755 assert_eq!(&ready, b"R");
14756 let trampoline = subc_os::file_identity(&test_privacy_trampoline()).unwrap();
14759 assert_eq!(
14760 observe_spawned_image(pid).unwrap().executable,
14761 Some(trampoline)
14762 );
14763 sample.write_all(b"X").unwrap();
14764 let mut child = spawn.await.unwrap();
14765 let early = crate::live_children::read_record(&record).unwrap();
14766 assert_eq!(early.len(), 1);
14767 assert_eq!(early[0].pid, pid);
14768 assert_eq!(
14769 early[0].executable, None,
14770 "unconfirmed trampoline image entered the roster"
14771 );
14772 assert!(child.report_ready.get().is_none());
14773 child.privacy_exec.as_mut().unwrap().deadline =
14776 tokio::time::Instant::now() + Duration::from_secs(30);
14777 exec.write_all(b"X").unwrap();
14778 child.confirm_privacy_exec().await;
14779 assert_eq!(child.spawn_failure, None);
14780 assert!(child.report_ready.get().is_some());
14781 let confirmed = crate::live_children::read_record(&record).unwrap();
14782 let module = subc_os::file_identity(std::path::Path::new("/bin/sleep")).unwrap();
14783 assert_ne!(module, trampoline);
14784 assert_eq!(confirmed[0].executable, Some(module.into()));
14785 child.start_kill().unwrap();
14786 child.wait().await.unwrap();
14787 child.release_roster();
14788 }
14789 }
14790
14791 #[tokio::test]
14792 #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
14793 async fn macos_already_exited_121_in_try_wait_is_a_module_exit_not_a_trampoline_refusal() {
14794 #[cfg(target_os = "macos")]
14795 {
14796 let registry = Arc::new(Registry::default());
14797 let policy = RestartPolicy::new(0, Duration::ZERO);
14798 let supervisor = Supervisor::new_for_test(Arc::clone(®istry), policy);
14799 let runtime = supervisor.runtime_config();
14800 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
14801 let spec = spec("/bin/sh", &["-c", "exit 121"]);
14802 let mut child = spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap();
14806 let pid = child.pid;
14807 tokio::task::spawn_blocking(move || {
14808 subc_os::fork_exec_test::wait_for_child_exit_without_reaping(pid)
14809 })
14810 .await
14811 .unwrap()
14812 .unwrap();
14813 child.privacy_exec.as_mut().unwrap().deadline =
14814 tokio::time::Instant::now() + Duration::from_secs(30);
14815 let status = child.wait().await.unwrap();
14816 assert_eq!(status.code(), Some(121));
14817 assert!(child.privacy_exec.is_none());
14818 assert!(
14819 child.report_ready.get().is_none(),
14820 "an exited module must not publish a live pid"
14821 );
14822 let report = classify_reaped_child_exit(&snapshot, &child, &status);
14823 on_child_exit(
14824 &spec,
14825 policy,
14826 ®istry,
14827 &snapshot,
14828 &runtime.terminal_ring,
14829 &runtime.spawn_events,
14830 &runtime.child_roster,
14831 report,
14832 )
14833 .await;
14834 let state = lock_snapshot(&snapshot).unwrap();
14835 assert_eq!(state.state, ModuleState::Failed);
14836 assert_eq!(state.last_exit.as_ref().unwrap().code, Some(121));
14837 assert_eq!(state.reported_pid(), None);
14838 drop(state);
14839 let history = runtime.terminal_ring.lock().unwrap().snapshot();
14840 assert_eq!(history.entries.len(), 1);
14841 let terminal = &history.entries[0];
14842 assert_eq!(terminal.exit_code, Some(121));
14843 assert_eq!(terminal.exit_kind, subc_control::TerminalExitKind::Crash);
14844 assert_eq!(terminal.disposition, TerminalDisposition::Failed);
14845 assert_eq!(
14846 terminal.disposition_detail.as_deref(),
14847 Some(policy.budget_exhausted_detail().as_str()),
14848 "module exit 121 was classified as a trampoline refusal: {terminal:?}"
14849 );
14850 assert_eq!(child.spawn_failure, None);
14851 child.release_roster();
14852 }
14853 }
14854}
14855
14856#[cfg(all(test, unix))]
14860mod launch_nonce_descriptor_tests {
14861 use super::{ChildRoster, ModuleProtocol, ModuleSpec, SupervisorHandle};
14862 use crate::stderr_tail::{StderrRing, StderrTailConfig};
14863 use cortexkit_test_support::ScratchDir;
14864 use std::{
14865 path::PathBuf,
14866 sync::{Arc, Mutex},
14867 time::{Duration, Instant},
14868 };
14869
14870 async fn probe(role: super::SpawnRole) {
14871 let scratch = ScratchDir::new("launch-nonce-descriptor");
14872 let fd_copy = scratch.join("from-descriptor");
14873 let env_copy = scratch.join("environment");
14874 let script = format!(
14875 "env > '{env}.tmp' && mv '{env}.tmp' '{env}'; cat <&3 > '{fd}.tmp' && mv '{fd}.tmp' '{fd}'; sleep 30",
14876 fd = fd_copy.display(), env = env_copy.display(),
14877 );
14878 let xdg = |name: &str| (name.to_string(), scratch.join(name).display().to_string());
14879 let spec = ModuleSpec {
14880 module_id: "nonce-descriptor-probe".to_string(),
14881 program: PathBuf::from("/bin/sh"),
14882 args: vec!["-c".to_string(), script],
14883 env: vec![
14884 xdg("XDG_DATA_HOME"),
14885 xdg("XDG_RUNTIME_DIR"),
14886 xdg("XDG_CONFIG_HOME"),
14887 ("SUBC_LAUNCH_NONCE".to_string(), "must-not-leak".to_string()),
14888 ],
14889 reserved: true,
14890 reserved_prefixes: Vec::new(),
14891 protocol: ModuleProtocol::Subc,
14892 overlap: Default::default(),
14893 };
14894 let handle = SupervisorHandle::new();
14895 let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
14896 let roster = ChildRoster::default();
14897 #[cfg(target_os = "macos")]
14898 {
14899 let path = super::test_privacy_trampoline();
14900 roster.set_privacy_trampoline(super::probe_privacy_trampoline(&path).map(|()| path));
14901 }
14902 let child = super::spawn_child_in_slot(
14903 &spec,
14904 None,
14905 Some(&handle),
14906 &ring,
14907 None,
14908 &roster,
14909 #[cfg(target_os = "linux")]
14910 None,
14911 role,
14912 matches!(role, super::SpawnRole::SwapCandidate),
14913 )
14914 .expect("spawn probe");
14915 let deadline = Instant::now() + Duration::from_secs(10);
14916 while !(fd_copy.exists() && env_copy.exists()) {
14917 assert!(Instant::now() < deadline, "probe never wrote its copies");
14918 tokio::time::sleep(Duration::from_millis(20)).await;
14919 }
14920 let nonce = std::fs::read_to_string(fd_copy).unwrap();
14921 assert!(!nonce.is_empty());
14922 let environment = std::fs::read_to_string(env_copy).unwrap();
14923 assert!(environment
14924 .lines()
14925 .any(|line| line.starts_with("SUBC_LAUNCH_NONCE_FD=3:")));
14926 let copy = environment
14927 .lines()
14928 .find_map(|line| line.strip_prefix("SUBC_LAUNCH_NONCE="));
14929 assert_eq!(
14930 copy, None,
14931 "Unix children must never receive the environment nonce"
14932 );
14933 if matches!(role, super::SpawnRole::Plain) {
14934 assert_eq!(
14935 handle.spawn_nonce(&spec.module_id).as_deref(),
14936 Some(nonce.as_str())
14937 );
14938 }
14939 drop(child);
14940 }
14941
14942 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
14943 async fn a_spawned_module_receives_its_nonce_only_on_descriptor_3() {
14944 probe(super::SpawnRole::Plain).await;
14945 }
14946
14947 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
14948 async fn swap_candidate_receives_its_nonce_only_on_descriptor_3() {
14949 probe(super::SpawnRole::SwapCandidate).await;
14950 }
14951}
14952
14953#[cfg(all(test, target_os = "linux"))]
14954mod cgroup_containment_tests {
14955 use super::*;
14956 use cortexkit_test_support::ScratchDir;
14957
14958 fn running(pid: u32) -> bool {
14959 std::fs::read_to_string(format!("/proc/{pid}/stat"))
14961 .ok()
14962 .and_then(|stat| {
14963 stat.rsplit_once(") ")
14964 .map(|(_, rest)| rest.starts_with('Z'))
14965 })
14966 .is_some_and(|zombie| !zombie)
14967 }
14968
14969 #[tokio::test]
14970 async fn linux_teardown_reaps_the_grandchild() {
14971 teardown_tree("linux_teardown_reaps_the_grandchild", false).await;
14972 }
14973
14974 #[tokio::test]
14975 async fn linux_shutdown_straggler_reaps_the_grandchild() {
14976 teardown_tree("linux_shutdown_straggler_reaps_the_grandchild", true).await;
14977 }
14978
14979 async fn teardown_tree(test_name: &str, shutdown: bool) {
14980 let dir = ScratchDir::new(test_name);
14981 let root = PathBuf::from(format!(
14982 "/sys/fs/cgroup/subc-tree-test-{test_name}-{}-{}",
14983 std::process::id(),
14984 unix_ms_now()
14985 ));
14986 if let Err(error) = std::fs::create_dir(&root) {
14987 assert!(
14988 std::env::var_os("SUBC_REQUIRE_CGROUP_TEST").is_none(),
14989 "required cgroup test cannot execute: {error}"
14990 );
14991 eprintln!(
14992 "SKIP {test_name}: no writable delegated cgroup at {}: {error}",
14993 root.display()
14994 );
14995 return;
14996 }
14997 let placement = subc_cgroup::prepare_at(&root)
14998 .expect("prepare isolated kernel cgroup")
14999 .expect("isolated cgroup is delegated");
15000 let module_id = "tree-teardown";
15001 let module = placement
15002 .module_path(module_id)
15003 .expect("create isolated module cgroup");
15004 if !module.join("cgroup.kill").exists() {
15005 std::fs::remove_dir(&module).unwrap();
15006 std::fs::remove_dir(root.join("subc-modules")).unwrap();
15007 std::fs::remove_dir(&root).unwrap();
15008 assert!(
15009 std::env::var_os("SUBC_REQUIRE_CGROUP_TEST").is_none(),
15010 "required cgroup.kill interface unavailable"
15011 );
15012 eprintln!("SKIP {test_name}: cgroup.kill unavailable (kernel < 5.14)");
15013 return;
15014 }
15015 let supervisor = Supervisor::new_for_test(
15016 Arc::new(Registry::default()),
15017 RestartPolicy::new(3, Duration::ZERO),
15018 )
15019 .with_cgroup_placement(Some(placement));
15020 let mut runtime = supervisor.runtime_config();
15021 runtime.child_roster = runtime
15022 .child_roster
15023 .for_module(Arc::new(Mutex::new(Duration::from_millis(100))));
15024 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
15025 let pid_file = dir.join("grandchild.pid");
15026 let spec = ModuleSpec {
15027 module_id: module_id.to_string(),
15028 program: PathBuf::from("/bin/sh"),
15029 args: vec![
15030 "-c".into(),
15031 "trap '' TERM; sleep 600 & echo $! > \"$1\"; wait".into(),
15032 "fixture".into(),
15033 pid_file.display().to_string(),
15034 ],
15035 env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
15036 .into_iter()
15037 .map(|key| (key.to_string(), dir.display().to_string()))
15038 .collect(),
15039 reserved: false,
15040 reserved_prefixes: Vec::new(),
15041 protocol: ModuleProtocol::None,
15042 overlap: Default::default(),
15043 };
15044 let child =
15045 spawn_and_mark_running(&spec, &runtime, &snapshot).expect("spawn supervised tree");
15046 let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
15047 let grandchild: u32 = loop {
15048 if let Ok(contents) = std::fs::read_to_string(&pid_file) {
15049 if let Ok(pid) = contents.trim().parse() {
15050 break pid;
15051 }
15052 }
15053 assert!(
15054 tokio::time::Instant::now() < deadline,
15055 "grandchild pid was not recorded"
15056 );
15057 tokio::time::sleep(Duration::from_millis(10)).await;
15058 };
15059 assert!(
15060 running(grandchild),
15061 "grandchild must be alive before teardown"
15062 );
15063 if shutdown {
15064 let mut child = child;
15065 crate::child_roster::end_children_for_daemon_shutdown(
15066 &runtime.child_roster,
15067 false,
15068 std::future::pending(),
15069 )
15070 .await;
15071 child.wait().await.expect("reap shutdown straggler");
15072 } else {
15073 drain_child_to_state(
15074 module_id,
15075 ModuleProtocol::None,
15076 StopNotice::NotSent,
15077 &Registry::default(),
15078 None,
15079 &snapshot,
15080 &runtime.terminal_ring,
15081 &SpawnEventFeed::default(),
15082 child,
15083 Duration::from_millis(100),
15084 ModuleState::Stopped,
15085 Some(false),
15086 )
15087 .await
15088 .expect("real supervisor teardown");
15089 }
15090 let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
15091 while running(grandchild) && tokio::time::Instant::now() < deadline {
15092 tokio::time::sleep(Duration::from_millis(10)).await;
15093 }
15094 let survived = running(grandchild);
15095 if survived {
15097 let pid = rustix::process::Pid::from_raw(grandchild as i32).unwrap();
15098 let _ = rustix::process::kill_process(pid, rustix::process::Signal::KILL);
15099 tokio::time::sleep(Duration::from_millis(100)).await;
15100 }
15101 if module.exists() {
15102 std::fs::remove_dir(&module).expect("remove empty module cgroup");
15103 }
15104 std::fs::remove_dir(root.join("subc-modules")).unwrap();
15105 std::fs::remove_dir(&root).unwrap();
15106 assert!(
15107 !survived,
15108 "grandchild {grandchild} outlived module teardown"
15109 );
15110 eprintln!("EXECUTED {test_name}: grandchild {grandchild} killed in isolated cgroup");
15111 }
15112}