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 #[cfg(feature = "test-support")]
9361 forwarding.wait_before_route_closing_for_test().await;
9362 let routes = forwarding
9363 .endpoint_routes(target.endpoint)
9364 .map_err(SuperviseError::Forwarding)?;
9365 let routes_notified = routes.len();
9366 crate::control::send_route_control_pushes(
9367 forwarding,
9368 routes.clone(),
9369 ClientControlPush::RouteClosing {
9370 module_id: spec.module_id.clone(),
9371 channels: Vec::new(),
9372 reason,
9373 },
9374 );
9375 send_route_goodbyes(forwarding, target.abandoned_bindings.clone());
9376
9377 let wait_result = wait_for_forwarding_quiescence(
9383 forwarding,
9384 &spec.module_id,
9385 runtime,
9386 target.endpoint,
9387 drain_deadline,
9388 &busy_gauges,
9389 scope,
9390 )
9391 .await;
9392 let drained = drained_after_quiescence_wait(&wait_result);
9393 if let Err(err) = &wait_result {
9394 error!(
9395 module_id = %spec.module_id,
9396 ?reason,
9397 error = %err,
9398 "forwarding quiescence wait failed after route.closing; forcing route.closed(drained: false) so the client is not left waiting on an unfulfilled promise"
9399 );
9400 } else if !drained {
9401 let holdouts = forwarding
9407 .endpoint_drain_holdouts(target.endpoint)
9408 .unwrap_or_default();
9409 warn!(
9410 module_id = %spec.module_id,
9411 waited = ?drain_timeout,
9412 ?reason,
9413 held_requests = holdouts.requests,
9414 held_routes = holdouts.routes,
9415 total_routes = holdouts.total_routes,
9416 top_connections = ?holdouts.top_connections,
9417 held = %holdouts
9420 .held
9421 .iter()
9422 .map(|(channel, corr)| format!("{channel}:{corr}"))
9423 .collect::<Vec<_>>()
9424 .join(","),
9425 "route drain timed out before request quiescence; forcing teardown"
9426 );
9427 }
9428 crate::control::send_route_control_pushes(
9429 forwarding,
9430 routes,
9431 ClientControlPush::RouteClosed {
9432 module_id: spec.module_id.clone(),
9433 channels: Vec::new(),
9434 reason,
9435 drained,
9436 abandoned: target.abandoned_bindings.len() as u32,
9437 excluded_subscriptions: target.excluded_subscriptions,
9438 terminal: Some(terminal),
9439 },
9440 );
9441 wait_result?;
9442
9443 let released_routes = match forwarding.release_module_endpoint_routes(target.endpoint) {
9449 Ok(routes) => routes,
9450 Err(err) => {
9451 warn!(
9452 module_id = %spec.module_id,
9453 ?reason,
9454 error = %err,
9455 "failed to release module endpoint routes after route.closed; module GOODBYE will still be sent"
9456 );
9457 send_module_goodbye(&spec.module_id, forwarding, target);
9458 return Err(SuperviseError::Forwarding(err));
9459 }
9460 };
9461 let route_goodbye_count = released_routes.len();
9462 send_route_goodbyes(forwarding, released_routes);
9463 send_module_goodbye(&spec.module_id, forwarding, target);
9464
9465 info!(
9471 module_id = %spec.module_id,
9472 ?reason,
9473 routes_notified,
9474 route_goodbyes = route_goodbye_count,
9475 abandoned_reservations = target.abandoned_bindings.len(),
9476 excluded_subscriptions = target.excluded_subscriptions,
9477 drained,
9478 "module drain complete; consumers notified via route.closing/route.closed pushes and per-route GOODBYE frames"
9479 );
9480 }
9481
9482 Ok(StopNotice::SentOverConnection)
9483}
9484
9485async fn wait_for_registration_after_reload(
9488 registry: &Registry,
9489 module_id: &str,
9490 snapshot: &SharedSnapshot,
9491 child: &mut SupervisedChild,
9492 wait: Duration,
9493) -> Result<RegistrationWaitOutcome, SuperviseError> {
9494 wait_for_slot_registration(
9495 registry,
9496 crate::registry::RegistrationSlot::Active(module_id),
9497 module_id,
9498 snapshot,
9499 child,
9500 wait,
9501 )
9502 .await
9503}
9504
9505async fn wait_for_slot_registration(
9513 registry: &Registry,
9514 slot: crate::registry::RegistrationSlot<'_>,
9515 module_id: &str,
9516 snapshot: &SharedSnapshot,
9517 child: &mut SupervisedChild,
9518 wait: Duration,
9519) -> Result<RegistrationWaitOutcome, SuperviseError> {
9520 let deadline = Instant::now() + wait;
9521 loop {
9522 if registry
9523 .registration(slot)
9524 .map_err(SuperviseError::Registry)?
9525 .is_some()
9526 {
9527 return Ok(RegistrationWaitOutcome::Registered);
9528 }
9529
9530 let now = Instant::now();
9531 if now >= deadline {
9532 return Ok(RegistrationWaitOutcome::TimedOut);
9533 }
9534 let remaining = deadline.saturating_duration_since(now);
9535 let poll = remaining.min(REGISTRY_RELEASE_POLL);
9536
9537 tokio::select! {
9538 wait_result = child.wait() => {
9539 let status = wait_result.map_err(|source| SuperviseError::Wait {
9540 module_id: module_id.to_string(),
9541 source,
9542 })?;
9543 return Ok(RegistrationWaitOutcome::Exited(classify_reaped_child_exit(
9544 snapshot,
9545 child,
9546 &status,
9547 )));
9548 }
9549 _ = sleep(poll) => {}
9550 }
9551 }
9552}
9553
9554fn registration_failure_exit_report(mut exit_report: ExitReport) -> ExitReport {
9555 if exit_report.kind != ExitKind::DeliberateSeverance {
9558 exit_report.kind = ExitKind::Crash;
9559 }
9560 exit_report
9561}
9562
9563async fn handle_reload_child_registration_failure(
9564 spec: &ModuleSpec,
9565 runtime: &SupervisorRuntimeConfig,
9566 registry: &Registry,
9567 process_liveness: &SupervisorProcessLiveness,
9568 snapshot: &SharedSnapshot,
9569 _child: &mut Option<SupervisedChild>,
9570 failure: ReloadRegistrationFailure,
9571) -> Result<(), SuperviseError> {
9572 let ReloadRegistrationFailure {
9573 exit_report,
9574 reason,
9575 } = failure;
9576 match on_child_exit(
9577 spec,
9578 runtime.restart_policy,
9579 registry,
9580 snapshot,
9581 &runtime.terminal_ring,
9582 &runtime.spawn_events,
9583 &runtime.child_roster,
9584 exit_report,
9585 )
9586 .await
9587 {
9588 NextAction::Stop {
9589 registration_released,
9590 } => {
9591 if registration_released {
9592 process_liveness.untrack_if_current(&spec.module_id, snapshot);
9593 }
9594 }
9595 NextAction::Restart { schedule } => {
9596 let delay = schedule.map_or(runtime.restart_policy.delay_for_restart(0), |schedule| {
9597 schedule.delay
9598 });
9599 if let Some(schedule) = schedule {
9600 log_crash_respawn(&spec.module_id, schedule);
9601 }
9602 schedule_respawn(
9603 runtime,
9604 snapshot,
9605 &spec.module_id,
9606 delay,
9607 RespawnKind::Spawn,
9608 )?;
9609 }
9610 }
9611 Err(SuperviseError::ReloadFailed {
9612 module_id: spec.module_id.clone(),
9613 reason,
9614 })
9615}
9616
9617async fn handle_reload_spawn_failure(
9618 spec: &ModuleSpec,
9619 runtime: &SupervisorRuntimeConfig,
9620 process_liveness: &SupervisorProcessLiveness,
9621 snapshot: &SharedSnapshot,
9622 _child: &mut Option<SupervisedChild>,
9623 reason: String,
9624) -> Result<(), SuperviseError> {
9625 let now = Instant::now();
9626 let mut schedule = None;
9627 update_snapshot(snapshot, Some(&spec.module_id), |state| {
9628 clear_current_process_facts(state);
9629 if state.enabled {
9630 schedule = state.next_crash_restart(&runtime.restart_policy, now);
9631 state.state = if schedule.is_some() {
9632 ModuleState::Restarting
9633 } else {
9634 ModuleState::Failed
9635 };
9636 } else {
9637 state.state = ModuleState::Disabled;
9638 }
9639 })?;
9640 if let Some(schedule) = schedule {
9641 schedule_respawn(
9642 runtime,
9643 snapshot,
9644 &spec.module_id,
9645 schedule.delay,
9646 RespawnKind::Spawn,
9647 )?;
9648 } else {
9649 process_liveness.untrack_if_current(&spec.module_id, snapshot);
9650 }
9651 Err(SuperviseError::ReloadFailed {
9652 module_id: spec.module_id.clone(),
9653 reason,
9654 })
9655}
9656
9657fn control_flags() -> Flags {
9658 Flags::new(false, Priority::Passive, false)
9659}
9660
9661#[allow(clippy::too_many_arguments)]
9662async fn drain_optional_child(
9663 module_id: &str,
9664 protocol: ModuleProtocol,
9665 stop_notice: StopNotice,
9666 registry: &Registry,
9667 forwarding: Option<&ForwardingTable>,
9668 snapshot: &SharedSnapshot,
9669 terminal_ring: &Arc<Mutex<TerminalRing>>,
9670 spawn_events: &SpawnEventFeed,
9671 child: &mut Option<SupervisedChild>,
9672 drain_timeout: Duration,
9673 final_state: ModuleState,
9674 enabled: Option<bool>,
9675) -> Result<(), SuperviseError> {
9676 if let Some(child) = child.take() {
9677 drain_child_to_state(
9678 module_id,
9679 protocol,
9680 stop_notice,
9681 registry,
9682 forwarding,
9683 snapshot,
9684 terminal_ring,
9685 spawn_events,
9686 child,
9687 drain_timeout,
9688 final_state,
9689 enabled,
9690 )
9691 .await
9692 } else {
9693 update_snapshot(snapshot, Some(module_id), |state| {
9694 state.state = final_state;
9695 if let Some(enabled) = enabled {
9696 state.enabled = enabled;
9697 }
9698 clear_current_process_facts(state);
9699 })?;
9700 release_dead_registration(registry, forwarding, snapshot, module_id).await
9701 }
9702}
9703
9704#[allow(clippy::too_many_arguments)]
9705async fn drain_child_to_state(
9706 module_id: &str,
9707 _protocol: ModuleProtocol,
9708 stop_notice: StopNotice,
9709 registry: &Registry,
9710 forwarding: Option<&ForwardingTable>,
9711 snapshot: &SharedSnapshot,
9712 terminal_ring: &Arc<Mutex<TerminalRing>>,
9713 spawn_events: &SpawnEventFeed,
9714 mut child: SupervisedChild,
9715 drain_timeout: Duration,
9716 final_state: ModuleState,
9717 enabled: Option<bool>,
9718) -> Result<(), SuperviseError> {
9719 let protocol = child.protocol;
9720 update_snapshot(snapshot, Some(module_id), |state| {
9721 state.state = ModuleState::Draining;
9722 state.draining_to_replace = final_state == ModuleState::Restarting;
9723 if let Some(enabled) = enabled {
9724 state.enabled = enabled;
9725 }
9726 })?;
9727
9728 if stop_notice != StopNotice::SentOverConnection {
9739 if protocol == ModuleProtocol::Subc && stop_notice == StopNotice::NoConnection {
9740 info!(
9741 module_id,
9742 pid = child.pid,
9743 budget_ms = u64::try_from(drain_timeout.as_millis()).unwrap_or(u64::MAX),
9744 "module has no connection yet; requesting stop by signal"
9745 );
9746 }
9747 request_graceful_stop(module_id, &child);
9748 }
9749
9750 let exit_report = match timeout(drain_timeout, child.wait()).await {
9751 Ok(Ok(status)) => classify_reaped_child_exit(snapshot, &child, &status),
9752 Ok(Err(source)) => {
9753 fail_after_wait_error(
9754 module_id,
9755 snapshot,
9756 terminal_ring,
9757 spawn_events,
9758 format!("failed to wait during drain: {source}"),
9759 );
9760 return Err(SuperviseError::Wait {
9761 module_id: module_id.to_string(),
9762 source,
9763 });
9764 }
9765 Err(_) => {
9766 warn!(
9779 module_id,
9780 pid = child.pid,
9781 budget_ms = u64::try_from(drain_timeout.as_millis()).unwrap_or(u64::MAX),
9782 reason = ?final_state,
9783 ?stop_notice,
9784 "drain budget expired before the module exited; killing it"
9785 );
9786 child.start_kill().map_err(|source| {
9787 fail_after_wait_error(
9788 module_id,
9789 snapshot,
9790 terminal_ring,
9791 spawn_events,
9792 format!("failed to kill during drain: {source}"),
9793 );
9794 SuperviseError::Kill {
9795 module_id: module_id.to_string(),
9796 source,
9797 }
9798 })?;
9799 let status = child.wait().await.map_err(|source| {
9800 fail_after_wait_error(
9801 module_id,
9802 snapshot,
9803 terminal_ring,
9804 spawn_events,
9805 format!("failed to wait after drain kill: {source}"),
9806 );
9807 SuperviseError::Wait {
9808 module_id: module_id.to_string(),
9809 source,
9810 }
9811 })?;
9812 classify_reaped_child_exit(snapshot, &child, &status)
9813 }
9814 };
9815
9816 let detail = lock_snapshot(snapshot)?.drain_disposition_detail.take();
9817 publish_after_terminal_record(
9818 module_id,
9819 || {
9820 record_terminal_with_detail(
9821 module_id,
9822 terminal_ring,
9823 spawn_events,
9824 &exit_report,
9825 terminal_disposition(final_state),
9826 detail,
9827 )
9828 },
9829 || {
9830 update_snapshot(snapshot, Some(module_id), |state| {
9831 state.state = final_state;
9832 if let Some(enabled) = enabled {
9833 state.enabled = enabled;
9834 }
9835 clear_current_process_facts(state);
9836 state.last_exit = Some(exit_report.clone());
9837 if exit_report.kind == ExitKind::DeliberateSeverance {
9838 state.lifetime_restarts += 1;
9839 }
9840 })
9841 },
9842 )?;
9843 child.drain_stderr(module_id).await;
9844
9845 release_dead_registration(registry, forwarding, snapshot, module_id).await
9846}
9847
9848#[cfg(unix)]
9868fn request_graceful_stop(module_id: &str, child: &SupervisedChild) {
9869 let Some(pid) = child
9870 .id()
9871 .and_then(|pid| i32::try_from(pid).ok())
9872 .and_then(rustix::process::Pid::from_raw)
9873 else {
9874 debug!(
9875 module_id,
9876 "no pid to signal for teardown; falling through to the drain wait"
9877 );
9878 return;
9879 };
9880 match rustix::process::kill_process(pid, rustix::process::Signal::TERM) {
9881 Ok(()) => debug!(
9882 module_id,
9883 "sent SIGTERM to a module nothing else asked to stop"
9884 ),
9885 Err(err) => debug!(
9886 module_id,
9887 error = %err,
9888 "SIGTERM to module failed; the drain wait and kill still apply"
9889 ),
9890 }
9891}
9892
9893#[cfg(not(unix))]
9901fn request_graceful_stop(module_id: &str, _child: &SupervisedChild) {
9902 debug!(
9903 module_id,
9904 "no graceful stop signal exists on this platform; teardown of a module nothing asked to stop waits, then kills"
9905 );
9906}
9907
9908fn terminal_disposition(final_state: ModuleState) -> TerminalDisposition {
9909 match final_state {
9910 ModuleState::Stopped => TerminalDisposition::Stopped,
9911 ModuleState::Disabled => TerminalDisposition::Disabled,
9912 ModuleState::Restarting => TerminalDisposition::Restarting,
9913 ModuleState::Failed => TerminalDisposition::Failed,
9914 ModuleState::Starting
9915 | ModuleState::Running
9916 | ModuleState::Unresponsive
9917 | ModuleState::Draining => {
9918 unreachable!("terminal exits only finish in terminal or restarting states")
9919 }
9920 }
9921}
9922
9923async fn release_dead_registration(
9931 registry: &Registry,
9932 forwarding: Option<&ForwardingTable>,
9933 snapshot: &SharedSnapshot,
9934 module_id: &str,
9935) -> Result<(), SuperviseError> {
9936 let result = async {
9937 let registration = registry
9938 .get_module(module_id)
9939 .map_err(SuperviseError::Registry)?;
9940 match wait_for_registration_release(registry, module_id, REGISTRY_RELEASE_TIMEOUT).await {
9941 Ok(()) => return Ok(()),
9942 Err(SuperviseError::RegistrationStillActive { .. }) => {}
9943 Err(err) => return Err(err),
9944 }
9945 let pid = lock_snapshot(snapshot)?.reaped_pid;
9946 if let (Some(registration), Some(forwarding), Some(pid)) = (registration, forwarding, pid) {
9947 warn!(
9948 module_id,
9949 pid,
9950 connection_id = registration.connection_id.get(),
9951 "reaped module registration outlived release grace; closing dead connection"
9952 );
9953 forwarding.request_connection_close(
9954 registration.connection_id,
9955 CloseReason::new(
9956 "supervised_process_reaped",
9957 format!("module '{module_id}' pid {pid} exited"),
9958 ),
9959 );
9960 wait_for_slot_registration_release(
9961 registry,
9962 crate::registry::RegistrationSlot::Connection(registration.connection_id),
9963 REGISTRY_RELEASE_TIMEOUT,
9964 )
9965 .await?;
9966 }
9967 wait_for_registration_release(registry, module_id, Duration::ZERO).await
9968 }
9969 .await;
9970 if let Err(err) = &result {
9971 fail_snapshot(snapshot, Some(module_id), None);
9972 error!(module_id, error = %err, "registration release failed after child exit; module is failed and start can retry");
9973 }
9974 result
9975}
9976
9977async fn wait_for_registration_release(
9980 registry: &Registry,
9981 module_id: &str,
9982 wait: Duration,
9983) -> Result<(), SuperviseError> {
9984 wait_for_slot_registration_release(
9985 registry,
9986 crate::registry::RegistrationSlot::Active(module_id),
9987 wait,
9988 )
9989 .await
9990}
9991
9992async fn wait_for_slot_registration_release(
10000 registry: &Registry,
10001 slot: crate::registry::RegistrationSlot<'_>,
10002 wait: Duration,
10003) -> Result<(), SuperviseError> {
10004 let deadline = Instant::now() + wait;
10005 let mut release_events = registration_release_events().subscribe();
10006 let still_active = |registration: &crate::registry::ModuleRegistration| {
10007 SuperviseError::RegistrationStillActive {
10008 module_id: registration.manifest.module_id.clone(),
10009 waited: wait,
10010 }
10011 };
10012 loop {
10013 let _observed_generation = *release_events.borrow_and_update();
10014 let Some(registration) = registry
10015 .registration(slot)
10016 .map_err(SuperviseError::Registry)?
10017 else {
10018 return Ok(());
10019 };
10020
10021 let now = Instant::now();
10022 if now >= deadline {
10023 return Err(still_active(®istration));
10024 }
10025
10026 let remaining = deadline.saturating_duration_since(now);
10027 match timeout(remaining, release_events.changed()).await {
10028 Ok(Ok(())) | Ok(Err(_)) => {}
10029 Err(_) => return Err(still_active(®istration)),
10030 }
10031 }
10032}
10033
10034#[cfg(test)]
10035mod slot_registration_wait_tests {
10036 use super::*;
10037 use crate::registry::{ConnectionId, RegistrationSlot};
10038 use subc_protocol::manifest::ModuleManifest;
10039
10040 #[tokio::test]
10041 async fn enable_release_failure_is_failed_and_a_second_enable_retries() {
10042 let registry = Arc::new(Registry::default());
10043 let supervisor = Supervisor::new_for_test(Arc::clone(®istry), RestartPolicy::default());
10044 let runtime = supervisor.runtime_config();
10045 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::disabled()));
10046 let spec = ModuleSpec {
10047 module_id: "enable-stale-registration".to_string(),
10048 program: PathBuf::from("/missing/enable-retry-test"),
10049 args: Vec::new(),
10050 env: Vec::new(),
10051 reserved: false,
10052 reserved_prefixes: Vec::new(),
10053 protocol: ModuleProtocol::Subc,
10054 overlap: Default::default(),
10055 };
10056 let connection = ConnectionId::new(90);
10057 registry
10058 .register_with_control_ops(
10059 ModuleManifest::builder(&spec.module_id, "0.1.0").build(),
10060 1,
10061 connection,
10062 Vec::new(),
10063 )
10064 .unwrap();
10065 let mut child = None;
10066 let err = set_child_enabled(
10067 &spec,
10068 &runtime,
10069 ®istry,
10070 &supervisor.process_liveness,
10071 &snapshot,
10072 &mut child,
10073 true,
10074 )
10075 .await
10076 .unwrap_err();
10077 assert!(matches!(
10078 err,
10079 SuperviseError::RegistrationStillActive { .. }
10080 ));
10081 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
10082 assert!(child.is_none());
10083 registry.deregister_connection(connection).unwrap();
10084 let err = set_child_enabled(
10085 &spec,
10086 &runtime,
10087 ®istry,
10088 &supervisor.process_liveness,
10089 &snapshot,
10090 &mut child,
10091 true,
10092 )
10093 .await
10094 .unwrap_err();
10095 assert!(
10096 matches!(err, SuperviseError::Spawn { .. }),
10097 "second enable must attempt a spawn: {err}"
10098 );
10099 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
10100 }
10101
10102 const INCUMBENT: u64 = 1;
10103 const CANDIDATE: u64 = 2;
10104
10105 fn swapped_registry() -> Arc<Registry> {
10106 let registry = Arc::new(Registry::default());
10107 let manifest = ModuleManifest::builder("m", "0.1.0").build();
10108 registry
10109 .register_with_control_ops(
10110 manifest.clone(),
10111 1,
10112 ConnectionId::new(INCUMBENT),
10113 Vec::new(),
10114 )
10115 .unwrap();
10116 registry
10117 .register_candidate_with_control_ops(
10118 manifest,
10119 1,
10120 ConnectionId::new(CANDIDATE),
10121 Vec::new(),
10122 )
10123 .unwrap();
10124 registry
10125 }
10126
10127 #[tokio::test]
10131 async fn incumbent_release_is_awaited_by_connection_not_by_module_id() {
10132 let registry = swapped_registry();
10133 registry.promote_candidate("m").unwrap().unwrap();
10134
10135 assert!(matches!(
10136 wait_for_registration_release(®istry, "m", Duration::from_millis(50)).await,
10137 Err(SuperviseError::RegistrationStillActive { .. })
10138 ));
10139
10140 assert!(matches!(
10142 wait_for_slot_registration_release(
10143 ®istry,
10144 RegistrationSlot::Connection(ConnectionId::new(INCUMBENT)),
10145 Duration::from_millis(50),
10146 )
10147 .await,
10148 Err(SuperviseError::RegistrationStillActive { .. })
10149 ));
10150
10151 let releaser = Arc::clone(®istry);
10152 let release = tokio::spawn(async move {
10153 sleep(Duration::from_millis(20)).await;
10154 releaser
10155 .deregister_connection(ConnectionId::new(INCUMBENT))
10156 .unwrap();
10157 notify_registration_release();
10158 });
10159 wait_for_slot_registration_release(
10160 ®istry,
10161 RegistrationSlot::Connection(ConnectionId::new(INCUMBENT)),
10162 Duration::from_secs(5),
10163 )
10164 .await
10165 .expect("the incumbent's own registration is released");
10166 release.await.unwrap();
10167 assert!(registry.get_module("m").unwrap().is_some());
10168 }
10169
10170 #[tokio::test]
10173 async fn candidate_slot_wait_ignores_the_incumbents_registration() {
10174 let registry = swapped_registry();
10175 assert!(matches!(
10176 wait_for_slot_registration_release(
10177 ®istry,
10178 RegistrationSlot::Candidate("m"),
10179 Duration::from_millis(50),
10180 )
10181 .await,
10182 Err(SuperviseError::RegistrationStillActive { .. })
10183 ));
10184 registry
10185 .deregister_connection(ConnectionId::new(CANDIDATE))
10186 .unwrap();
10187 wait_for_slot_registration_release(
10188 ®istry,
10189 RegistrationSlot::Candidate("m"),
10190 Duration::from_millis(50),
10191 )
10192 .await
10193 .expect("a candidate slot with no candidate is released");
10194 assert!(registry
10195 .registration(RegistrationSlot::Active("m"))
10196 .unwrap()
10197 .is_some());
10198 }
10199}
10200
10201fn classify_exit(status: &ExitStatus) -> ExitReport {
10202 ExitReport {
10203 kind: if status.success() {
10204 ExitKind::Clean
10205 } else {
10206 ExitKind::Crash
10207 },
10208 code: status.code(),
10209 signal: exit_signal(status),
10210 at_ms: unix_ms_now(),
10211 }
10212}
10213
10214fn wait_error_exit_report() -> ExitReport {
10220 ExitReport {
10221 kind: ExitKind::Crash,
10222 code: None,
10223 signal: None,
10224 at_ms: unix_ms_now(),
10225 }
10226}
10227
10228#[cfg(unix)]
10229fn exit_signal(status: &ExitStatus) -> Option<i32> {
10230 use std::os::unix::process::ExitStatusExt;
10231
10232 status.signal()
10233}
10234
10235#[cfg(not(unix))]
10236fn exit_signal(_status: &ExitStatus) -> Option<i32> {
10237 None
10238}
10239
10240fn reset_restart_count(snapshot: &SharedSnapshot, module_id: &str) -> Result<(), SuperviseError> {
10246 update_snapshot(snapshot, Some(module_id), |state| {
10247 state.clear_crash_restarts();
10248 })
10249}
10250
10251fn set_running(
10252 snapshot: &SharedSnapshot,
10253 child: &SupervisedChild,
10254 module_id: &str,
10255 spawn_events: &SpawnEventFeed,
10256) -> Result<(), SuperviseError> {
10257 let mut state = snapshot.lock().map_err(|_| SuperviseError::StatePoisoned {
10258 module_id: Some(module_id.to_string()),
10259 })?;
10260 state.spawn_generation = spawn_events.emit_spawned(module_id, child.pid, child.spawned_at_ms);
10261 if std::mem::take(&mut state.coalesced_restart_pending) {
10262 let generation = state.spawn_generation;
10263 info!(module_id, "restart already satisfied by generation {generation}; coalesced pending request completed");
10264 }
10265 state.drain_disposition_detail = None;
10266 state.spawn_failure = None;
10267 state.waiting_for_previous_pid = None;
10268 state.in_alternate_slot = false;
10271 state.configuration_updated_since_spawn = false;
10272 state.spawned_protocol = Some(child.protocol);
10273 state.state = ModuleState::Running;
10274 state.enabled = true;
10275 state.process_alive = true;
10276 state.pid = child.id();
10277 #[cfg(windows)]
10278 {
10279 state.pipe_consumed = child
10280 .nonce_delivery
10281 .as_ref()
10282 .map(|delivery| delivery.consumed());
10283 }
10284 #[cfg(target_os = "macos")]
10285 {
10286 state.report_ready = Some(Arc::clone(&child.report_ready));
10287 }
10288 state.spawned_at_ms = Some(child.spawned_at_ms);
10289 state.spawned_from = Some(child.spawned_from.clone());
10290 state.spawned_file_identity = child.spawned_file_identity.clone();
10291 state.process_start_time = child.process_start_time;
10292 Ok(())
10293}
10294
10295fn clear_current_process_facts(state: &mut SupervisorSnapshot) {
10296 state.waiting_for_previous_pid = None;
10297 state.process_alive = false;
10298 state.spawned_protocol = None;
10299 state.pid = None;
10300 #[cfg(windows)]
10301 {
10302 state.pipe_consumed = None;
10303 }
10304 #[cfg(target_os = "macos")]
10305 {
10306 state.report_ready = None;
10307 }
10308 state.spawned_at_ms = None;
10309 state.spawned_from = None;
10310 state.spawned_file_identity = None;
10311 state.process_start_time = None;
10312 state.deliberate_severance = None;
10313}
10314
10315#[cfg(test)]
10316fn record_deliberate_severance(
10317 snapshot: &SharedSnapshot,
10318 identity: ProcessIdentity,
10319) -> Result<(), SuperviseError> {
10320 update_snapshot(snapshot, None, |state| {
10321 state.deliberate_severance = Some(identity);
10322 })
10323}
10324
10325fn apply_deliberate_severance_marker(
10326 snapshot: &SharedSnapshot,
10327 exited_identity: Option<ProcessIdentity>,
10328 mut exit_report: ExitReport,
10329) -> ExitReport {
10330 let marker = lock_snapshot(snapshot)
10331 .ok()
10332 .and_then(|mut state| state.deliberate_severance.take());
10333 if marker.is_some() && marker == exited_identity {
10334 exit_report.kind = ExitKind::DeliberateSeverance;
10335 }
10336 exit_report
10337}
10338
10339fn classify_reaped_child_exit(
10340 snapshot: &SharedSnapshot,
10341 child: &SupervisedChild,
10342 status: &ExitStatus,
10343) -> ExitReport {
10344 let _ = update_snapshot(snapshot, None, |state| {
10345 state.reaped_pid = Some(child.pid);
10346 state.spawn_failure = child.spawn_failure.clone();
10347 });
10348 apply_deliberate_severance_marker(snapshot, child.process_identity(), classify_exit(status))
10349}
10350
10351fn fail_snapshot(
10352 snapshot: &SharedSnapshot,
10353 module_id: Option<&str>,
10354 last_exit: Option<ExitReport>,
10355) {
10356 if let Err(err) = update_snapshot(snapshot, module_id, |state| {
10357 state.state = ModuleState::Failed;
10358 clear_current_process_facts(state);
10359 if let Some(last_exit) = last_exit {
10360 state.last_exit = Some(last_exit);
10361 }
10362 }) {
10363 error!(error = %err, "failed to mark supervisor state failed");
10364 }
10365}
10366
10367fn update_snapshot(
10368 snapshot: &SharedSnapshot,
10369 module_id: Option<&str>,
10370 update: impl FnOnce(&mut SupervisorSnapshot),
10371) -> Result<(), SuperviseError> {
10372 let mut state = snapshot.lock().map_err(|_| SuperviseError::StatePoisoned {
10373 module_id: module_id.map(ToOwned::to_owned),
10374 })?;
10375 update(&mut state);
10376 Ok(())
10377}
10378
10379const SLOW_SNAPSHOT_LOCK_THRESHOLD: Duration = Duration::from_millis(250);
10380
10381fn lock_snapshot_for_control<'a>(
10382 snapshot: &'a SharedSnapshot,
10383 module_id: &str,
10384 caller: &'static str,
10385) -> Result<std::sync::MutexGuard<'a, SupervisorSnapshot>, SuperviseError> {
10386 let started_at = Instant::now();
10387 let guard = lock_snapshot(snapshot)?;
10388 let waited = started_at.elapsed();
10389 if waited >= SLOW_SNAPSHOT_LOCK_THRESHOLD {
10390 warn!(
10391 module_id = %module_id,
10392 waited_ms = waited.as_millis() as u64,
10393 caller = %caller,
10394 "slow snapshot lock"
10395 );
10396 }
10397 Ok(guard)
10398}
10399
10400fn lock_snapshot(
10401 snapshot: &SharedSnapshot,
10402) -> Result<std::sync::MutexGuard<'_, SupervisorSnapshot>, SuperviseError> {
10403 snapshot
10404 .lock()
10405 .map_err(|_| SuperviseError::StatePoisoned { module_id: None })
10406}
10407
10408#[cfg(test)]
10409mod terminal_history_tests {
10410 use std::{
10411 path::PathBuf,
10412 sync::Arc,
10413 time::{Duration, Instant},
10414 };
10415
10416 use tokio::{sync::mpsc, time::sleep};
10417
10418 use super::{
10419 apply_deliberate_severance_marker, daemon_will_restart, drain_child_to_state,
10420 drained_after_quiescence_wait, handle_reload_spawn_failure, health_restart_child,
10421 lock_snapshot, on_child_exit, record_deliberate_severance, record_wait_error_terminal,
10422 reset_restart_count, spawn_and_mark_running, update_snapshot, wait_error_exit_report,
10423 ExitKind, ExitReport, ModuleProtocol, ModuleSpec, ModuleState, NextAction, ProcessIdentity,
10424 RestartPolicy, SpawnEventKind, StopNotice, SuperviseError, SupervisedConfiguration,
10425 SupervisedModule, SupervisedModuleInner, Supervisor, SupervisorHandle,
10426 SupervisorHealthStatus, SupervisorSnapshot,
10427 };
10428 use super::Instant as ClockInstant;
10433 use crate::{
10434 registry::Registry,
10435 terminal_ring::{TerminalRing, TerminalRingConfig},
10436 };
10437 use std::sync::Mutex;
10438 use subc_control::TerminalDisposition;
10439
10440 pub(super) fn fake_aft_stub_path() -> PathBuf {
10445 let mut path = std::env::current_exe().expect("current_exe available in tests");
10446 path.pop();
10447 path.pop();
10448 path.push(if cfg!(windows) {
10449 "fake-aft-stub.exe"
10450 } else {
10451 "fake-aft-stub"
10452 });
10453 assert!(
10454 path.exists(),
10455 "fake-aft-stub not built at {}: run `cargo test -p subc-core` (which builds \
10456 [[bin]] targets) rather than `cargo test -p subc-core --lib` (which does not)",
10457 path.display()
10458 );
10459 path
10460 }
10461
10462 #[test]
10463 fn reserved_never_spawned_refuses_every_hello() {
10464 let supervisor = SupervisorHandle::default();
10469 supervisor.apply_identity_configuration(&ModuleSpec {
10470 module_id: "never-spawned".to_string(),
10471 program: PathBuf::from("/usr/bin/false"),
10472 args: Vec::new(),
10473 env: Vec::new(),
10474 reserved: true,
10475 reserved_prefixes: Vec::new(),
10476 protocol: ModuleProtocol::Subc,
10477 overlap: Default::default(),
10478 });
10479 assert!(
10480 supervisor
10481 .reserved_hello_rejection("never-spawned", Some("any-forged-nonce"))
10482 .is_some(),
10483 "forged nonce must refuse on a reserved never-spawned id"
10484 );
10485 assert!(
10486 supervisor
10487 .reserved_hello_rejection("never-spawned", None)
10488 .is_some(),
10489 "absent nonce must refuse on a reserved never-spawned id"
10490 );
10491 supervisor.set_spawn_nonce("never-spawned", "minted".to_string());
10493 supervisor.apply_identity_configuration(&ModuleSpec {
10494 module_id: "never-spawned".to_string(),
10495 program: PathBuf::from("/usr/bin/false"),
10496 args: Vec::new(),
10497 env: Vec::new(),
10498 reserved: true,
10499 reserved_prefixes: Vec::new(),
10500 protocol: ModuleProtocol::Subc,
10501 overlap: Default::default(),
10502 });
10503 assert!(supervisor
10504 .reserved_hello_rejection("never-spawned", Some("minted"))
10505 .is_none());
10506 assert!(supervisor
10507 .reserved_hello_rejection("never-spawned", Some("forged"))
10508 .is_some());
10509 }
10510
10511 fn seed_crash_restarts(state: &mut SupervisorSnapshot, count: u32) {
10514 let now = ClockInstant::now();
10515 for _ in 0..count {
10516 state.crash_restarts.push_back(now);
10517 }
10518 }
10519
10520 fn age_oldest_crash_restart_out_of_window(state: &mut SupervisorSnapshot, window: Duration) {
10524 let aged = state
10525 .crash_restarts
10526 .front()
10527 .expect("a crash restart must be recorded before it can be aged")
10528 .checked_sub(window + Duration::from_secs(1))
10529 .expect("the test clock is far enough from its origin to age an instant");
10530 state.crash_restarts[0] = aged;
10531 }
10532
10533 fn snapshot_with_restarts(enabled: bool, count: u32) -> SupervisorSnapshot {
10534 let mut state = SupervisorSnapshot::new(ModuleState::Running, enabled);
10535 seed_crash_restarts(&mut state, count);
10536 state
10537 }
10538
10539 #[test]
10540 fn daemon_owned_recovery_predicate_uses_the_pre_increment_budget() {
10541 let policy = RestartPolicy::new(3, Duration::ZERO);
10542 let now = ClockInstant::now();
10543 assert!(daemon_will_restart(
10544 &mut snapshot_with_restarts(true, 2),
10545 &policy,
10546 now
10547 ));
10548 assert!(!daemon_will_restart(
10549 &mut snapshot_with_restarts(true, 3),
10550 &policy,
10551 now
10552 ));
10553 assert!(!daemon_will_restart(
10554 &mut snapshot_with_restarts(false, 0),
10555 &policy,
10556 now
10557 ));
10558 }
10559
10560 #[test]
10561 fn crash_restart_backoff_escalates_with_in_window_count() {
10562 let policy = RestartPolicy::new(4, Duration::from_millis(100))
10563 .with_max_backoff(Duration::from_secs(30));
10564 let now = ClockInstant::now();
10565 let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
10566 let schedules = (0..4)
10567 .map(|_| {
10568 state
10569 .next_crash_restart(&policy, now)
10570 .expect("the test policy allows four crash restarts")
10571 })
10572 .collect::<Vec<_>>();
10573
10574 assert_eq!(
10575 schedules
10576 .iter()
10577 .map(|schedule| schedule.restart_in_window)
10578 .collect::<Vec<_>>(),
10579 vec![0, 1, 2, 3]
10580 );
10581 assert_eq!(
10582 schedules
10583 .iter()
10584 .map(|schedule| schedule.delay)
10585 .collect::<Vec<_>>(),
10586 vec![
10587 Duration::from_millis(100),
10588 Duration::from_secs(1),
10589 Duration::from_secs(10),
10590 Duration::from_secs(30),
10591 ]
10592 );
10593 }
10594
10595 #[test]
10596 fn crash_restart_backoff_resets_after_ring_clear() {
10597 let policy = RestartPolicy::new(3, Duration::from_millis(100));
10598 let now = ClockInstant::now();
10599 let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
10600 assert_eq!(
10601 state.next_crash_restart(&policy, now).unwrap().delay,
10602 Duration::from_millis(100)
10603 );
10604 assert_eq!(
10605 state.next_crash_restart(&policy, now).unwrap().delay,
10606 Duration::from_secs(1)
10607 );
10608
10609 state.clear_crash_restarts();
10610 let schedule = state
10611 .next_crash_restart(&policy, now)
10612 .expect("a cleared ring must allow another restart");
10613 assert_eq!(schedule.restart_in_window, 0);
10614 assert_eq!(schedule.delay, Duration::from_millis(100));
10615 }
10616
10617 #[test]
10618 fn crash_restart_backoff_ignores_aged_restarts() {
10619 let policy = RestartPolicy::new(3, Duration::from_millis(100));
10620 let now = ClockInstant::now();
10621 let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
10622 state
10623 .next_crash_restart(&policy, now)
10624 .expect("the first restart is allowed");
10625 state
10626 .next_crash_restart(&policy, now)
10627 .expect("the second restart is allowed");
10628 state.crash_restarts[0] = now
10629 .checked_sub(policy.window + Duration::from_secs(1))
10630 .expect("the fake clock can age a restart past the window");
10631
10632 let schedule = state
10633 .next_crash_restart(&policy, now)
10634 .expect("an aged restart must release its slot");
10635 assert_eq!(schedule.restart_in_window, 1);
10636 assert_eq!(schedule.delay, Duration::from_secs(1));
10637 assert_eq!(state.crash_restarts.len(), 2);
10638 }
10639
10640 #[test]
10644 fn a_budget_spent_before_the_window_no_longer_refuses() {
10645 let policy = RestartPolicy::new(3, Duration::ZERO);
10646 let mut state = snapshot_with_restarts(true, 3);
10647 let now = ClockInstant::now();
10648 assert!(!daemon_will_restart(&mut state, &policy, now));
10649
10650 assert!(daemon_will_restart(
10651 &mut state,
10652 &policy,
10653 now + policy.window + Duration::from_secs(1)
10654 ));
10655 assert!(
10656 state.crash_restarts.is_empty(),
10657 "reading the budget must drop the instants that left the window"
10658 );
10659 }
10660
10661 fn module_with_recovery_snapshot(
10662 state: ModuleState,
10663 enabled: bool,
10664 restart_count: u32,
10665 ) -> SupervisedModule {
10666 let registry = Arc::new(Registry::default());
10667 let supervisor =
10668 Supervisor::new_for_test(Arc::clone(®istry), RestartPolicy::new(3, Duration::ZERO));
10669 let runtime = supervisor.runtime_config();
10670 let spec = ModuleSpec {
10671 module_id: "recovery-snapshot".to_string(),
10672 program: fake_aft_stub_path(),
10673 args: Vec::new(),
10674 env: Vec::new(),
10675 reserved: false,
10676 reserved_prefixes: Vec::new(),
10677 protocol: ModuleProtocol::Subc,
10678 overlap: Default::default(),
10679 };
10680 let mut snapshot = SupervisorSnapshot::new(state, enabled);
10681 seed_crash_restarts(&mut snapshot, restart_count);
10682 let (commands, _rx) = mpsc::channel(4);
10685 SupervisedModule {
10686 inner: Arc::new(SupervisedModuleInner {
10687 module_id: spec.module_id.clone(),
10688 registry,
10689 snapshot: Arc::new(Mutex::new(snapshot)),
10690 configuration: Arc::new(Mutex::new(SupervisedConfiguration {
10691 spec,
10692 health: runtime.health,
10693 })),
10694 stderr_ring: runtime.stderr_ring,
10695 terminal_ring: runtime.terminal_ring,
10696 commands,
10697 monitor: Mutex::new(None),
10698 restart_policy: runtime.restart_policy,
10699 effective_drain_timeout: runtime.effective_drain_timeout,
10700 provenance_probe: supervisor.provenance_probe.clone(),
10701 }),
10702 }
10703 }
10704
10705 #[cfg(target_os = "linux")]
10706 #[tokio::test]
10707 async fn no_cgroup_placement_does_not_block_fake_aft_stub_spawn() {
10708 let supervisor =
10709 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
10710 .with_cgroup_placement(None);
10711 let result = supervisor.spawn(ModuleSpec {
10712 module_id: "no-cgroup-placement".to_string(),
10713 program: fake_aft_stub_path(),
10714 args: Vec::new(),
10715 env: Vec::new(),
10716 reserved: false,
10717 reserved_prefixes: Vec::new(),
10718 protocol: ModuleProtocol::Subc,
10719 overlap: Default::default(),
10720 });
10721
10722 assert!(
10723 result.is_ok(),
10724 "no delegation must not turn an otherwise valid spawn into a failure: {result:?}"
10725 );
10726 }
10727
10728 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10729 async fn undecided_snapshot_uses_shared_restart_predicate() {
10730 assert!(module_with_recovery_snapshot(ModuleState::Running, true, 2)
10731 .will_recover_after_connection_loss()
10732 .unwrap());
10733 assert!(
10734 !module_with_recovery_snapshot(ModuleState::Running, true, 3)
10735 .will_recover_after_connection_loss()
10736 .unwrap()
10737 );
10738 }
10739
10740 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10741 async fn restarting_snapshot_at_exhausted_budget_is_non_terminal() {
10742 assert!(
10743 module_with_recovery_snapshot(ModuleState::Restarting, true, 3)
10744 .will_recover_after_connection_loss()
10745 .unwrap()
10746 );
10747 }
10748
10749 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10750 async fn terminal_phase_snapshots_are_terminal_before_budget_exhaustion() {
10751 assert!(!module_with_recovery_snapshot(ModuleState::Failed, true, 0)
10752 .will_recover_after_connection_loss()
10753 .unwrap());
10754 assert!(
10755 !module_with_recovery_snapshot(ModuleState::Disabled, true, 0)
10756 .will_recover_after_connection_loss()
10757 .unwrap()
10758 );
10759 }
10760
10761 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10762 async fn warming_snapshot_is_limited_to_startup_phases() {
10763 for state in [
10764 ModuleState::Starting,
10765 ModuleState::Running,
10766 ModuleState::Restarting,
10767 ] {
10768 assert!(
10769 module_with_recovery_snapshot(state, true, 0)
10770 .is_warming()
10771 .unwrap(),
10772 "{state:?} should be warming"
10773 );
10774 }
10775 for state in [
10776 ModuleState::Unresponsive,
10777 ModuleState::Draining,
10778 ModuleState::Stopped,
10779 ModuleState::Failed,
10780 ModuleState::Disabled,
10781 ] {
10782 assert!(
10783 !module_with_recovery_snapshot(state, true, 0)
10784 .is_warming()
10785 .unwrap(),
10786 "{state:?} should not be warming"
10787 );
10788 }
10789 }
10790
10791 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10792 async fn terminal_history_survives_respawn_and_keeps_both_crashes_in_order() {
10793 let registry = Arc::new(Registry::default());
10794 let supervisor =
10795 Supervisor::new_for_test(Arc::clone(®istry), RestartPolicy::new(1, Duration::ZERO));
10796 let module = supervisor
10797 .spawn(ModuleSpec {
10798 module_id: "terminal-history".to_string(),
10799 program: fake_aft_stub_path(),
10800 args: Vec::new(),
10801 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
10802 reserved: false,
10803 reserved_prefixes: Vec::new(),
10804 protocol: ModuleProtocol::Subc,
10805 overlap: Default::default(),
10806 })
10807 .unwrap();
10808
10809 let deadline = Instant::now() + Duration::from_secs(5);
10810 loop {
10811 let history = module.terminal_history();
10812 if history.entries.len() == 2 {
10813 assert_eq!(module.status().unwrap().state, ModuleState::Failed);
10814 assert_eq!(history.dropped, 0);
10815 assert_eq!(
10816 history
10817 .entries
10818 .iter()
10819 .map(|entry| entry.exit_code)
10820 .collect::<Vec<_>>(),
10821 vec![Some(23), Some(23)]
10822 );
10823 assert!(history.entries[0].at_ms <= history.entries[1].at_ms);
10824 return;
10825 }
10826 assert!(
10827 Instant::now() < deadline,
10828 "module did not retain two terminal exits: {history:?}"
10829 );
10830 sleep(Duration::from_millis(10)).await;
10831 }
10832 }
10833
10834 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10838 async fn disable_during_crash_backoff_cancels_pending_respawn() {
10839 let backoff = Duration::from_secs(2);
10840 let supervisor = Supervisor::new_for_test(
10841 Arc::new(Registry::default()),
10842 RestartPolicy::new(10, backoff),
10843 );
10844 let module = supervisor
10845 .spawn(ModuleSpec {
10846 module_id: "disable-during-backoff".to_string(),
10847 program: fake_aft_stub_path(),
10848 args: Vec::new(),
10849 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
10850 reserved: false,
10851 reserved_prefixes: Vec::new(),
10852 protocol: ModuleProtocol::Subc,
10853 overlap: Default::default(),
10854 })
10855 .unwrap();
10856
10857 let deadline = Instant::now() + Duration::from_secs(5);
10859 loop {
10860 if module.status().unwrap().state == ModuleState::Restarting {
10861 break;
10862 }
10863 assert!(
10864 Instant::now() < deadline,
10865 "module never entered the crash backoff"
10866 );
10867 sleep(Duration::from_millis(10)).await;
10868 }
10869
10870 let started = Instant::now();
10871 module.set_enabled(false).await.unwrap();
10872 let waited = started.elapsed();
10873
10874 assert!(
10875 waited < backoff / 2,
10876 "disable waited {waited:?} behind the {backoff:?} crash backoff; the operator command must preempt the pending respawn"
10877 );
10878 assert_eq!(module.status().unwrap().state, ModuleState::Disabled);
10879
10880 sleep(backoff + Duration::from_millis(500)).await;
10882 let status = module.status().unwrap();
10883 assert_eq!(status.state, ModuleState::Disabled);
10884 assert_eq!(
10885 status.spawn_generation, 1,
10886 "module respawned after the operator disabled it"
10887 );
10888 }
10889
10890 #[cfg(unix)]
10893 fn protocol_none_sigterm_exits_clean_spec(
10894 module_id: &str,
10895 dir: &std::path::Path,
10896 ) -> (ModuleSpec, PathBuf, PathBuf) {
10897 let ready = dir.join("ready");
10898 let marker = dir.join("sigterm");
10899 let spec = ModuleSpec {
10900 module_id: module_id.to_string(),
10901 program: fake_aft_stub_path(),
10902 args: Vec::new(),
10903 env: vec![
10904 ("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string()),
10905 (
10906 "FAKE_AFT_SIGTERM_MARKER_PATH".to_string(),
10907 marker.display().to_string(),
10908 ),
10909 (
10910 "FAKE_AFT_NEVER_CONNECT_READY_PATH".to_string(),
10911 ready.display().to_string(),
10912 ),
10913 ],
10914 reserved: false,
10915 reserved_prefixes: Vec::new(),
10916 protocol: ModuleProtocol::None,
10917 overlap: Default::default(),
10918 };
10919 (spec, ready, marker)
10920 }
10921
10922 #[cfg(unix)]
10926 async fn wait_for_file(path: &std::path::Path) {
10927 let deadline = Instant::now() + Duration::from_secs(10);
10928 while !path.exists() {
10929 assert!(
10930 Instant::now() < deadline,
10931 "{} never appeared",
10932 path.display()
10933 );
10934 sleep(Duration::from_millis(10)).await;
10935 }
10936 }
10937
10938 #[cfg(unix)]
10942 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10943 async fn protocol_none_unrequested_clean_exit_restarts_as_a_crash() {
10944 let dir = cortexkit_test_support::ScratchDir::new("none-unrequested-clean-exit");
10945 let (spec, ready, marker) =
10946 protocol_none_sigterm_exits_clean_spec("none-unrequested-clean-exit", dir.path());
10947 let supervisor = Supervisor::new_for_test(
10948 Arc::new(Registry::default()),
10949 RestartPolicy::new(3, Duration::ZERO),
10950 );
10951 let module = supervisor.spawn(spec).unwrap();
10952 wait_for_file(&ready).await;
10953 let deadline = Instant::now() + Duration::from_secs(10);
10957 let first_pid = loop {
10958 let status = module.status().unwrap();
10959 if status.state == ModuleState::Running {
10960 if let Some(pid) = status.pid {
10961 break pid;
10962 }
10963 }
10964 assert!(
10965 Instant::now() < deadline,
10966 "a running module must report its pid after exec confirmation: {status:?}"
10967 );
10968 sleep(Duration::from_millis(10)).await;
10969 };
10970
10971 rustix::process::kill_process(
10972 rustix::process::Pid::from_raw(i32::try_from(first_pid).unwrap()).unwrap(),
10973 rustix::process::Signal::TERM,
10974 )
10975 .unwrap();
10976
10977 let deadline = Instant::now() + Duration::from_secs(10);
10978 let respawned = loop {
10979 let status = module.status().unwrap();
10980 if status.state == ModuleState::Running
10981 && status.pid.is_some_and(|pid| pid != first_pid)
10982 {
10983 break status;
10984 }
10985 assert!(
10986 Instant::now() < deadline,
10987 "protocol-none module was not respawned after an unrequested clean exit: {status:?}"
10988 );
10989 sleep(Duration::from_millis(10)).await;
10990 };
10991 assert_eq!(respawned.spawn_generation, 2);
10992 assert!(
10993 marker.exists(),
10994 "the child must have exited through its SIGTERM handler (exit 0), or this proves nothing about clean exits"
10995 );
10996
10997 let history = module.terminal_history();
10998 assert_eq!(history.entries.len(), 1, "{history:?}");
10999 let entry = &history.entries[0];
11000 assert_eq!(entry.exit_code, Some(0));
11001 assert_eq!(entry.exit_kind, subc_control::TerminalExitKind::Clean);
11002 assert_eq!(entry.disposition, TerminalDisposition::Restarting);
11003
11004 module.stop().await.unwrap();
11005 }
11006
11007 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11011 async fn protocol_none_repeated_clean_exits_exhaust_the_restart_budget() {
11012 let supervisor = Supervisor::new_for_test(
11013 Arc::new(Registry::default()),
11014 RestartPolicy::new(1, Duration::ZERO),
11015 );
11016 let module = supervisor
11017 .spawn(ModuleSpec {
11018 module_id: "none-clean-exit-budget".to_string(),
11019 program: fake_aft_stub_path(),
11020 args: Vec::new(),
11021 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "0".to_string())],
11022 reserved: false,
11023 reserved_prefixes: Vec::new(),
11024 protocol: ModuleProtocol::None,
11025 overlap: Default::default(),
11026 })
11027 .unwrap();
11028
11029 let deadline = Instant::now() + Duration::from_secs(10);
11032 loop {
11033 let status = module.status().unwrap();
11034 if status.state == ModuleState::Failed {
11035 break;
11036 }
11037 assert!(
11038 Instant::now() < deadline,
11039 "module never exhausted its budget: {status:?} {:?}",
11040 module.terminal_history()
11041 );
11042 sleep(Duration::from_millis(10)).await;
11043 }
11044 let history = module.terminal_history();
11045 assert_eq!(
11046 history
11047 .entries
11048 .iter()
11049 .map(|entry| (entry.exit_code, entry.disposition.clone()))
11050 .collect::<Vec<_>>(),
11051 vec![
11052 (Some(0), TerminalDisposition::Restarting),
11053 (Some(0), TerminalDisposition::Failed),
11054 ]
11055 );
11056 let detail = history.entries[1]
11057 .disposition_detail
11058 .as_deref()
11059 .expect("a budget failure names the budget");
11060 assert!(detail.contains("max_restarts=1"), "{detail}");
11061 assert_eq!(module.status().unwrap().spawn_generation, 2);
11062 }
11063
11064 #[test]
11065 fn restart_budget_failure_is_published_after_its_terminal_record() {
11066 use std::io::{Read, Write};
11067 let supervisor = Supervisor::new_for_test(
11068 Arc::new(Registry::default()),
11069 RestartPolicy::new(0, Duration::ZERO),
11070 );
11071 let runtime = supervisor.runtime_config();
11072 let spec = ModuleSpec {
11073 protocol: ModuleProtocol::None,
11074 ..windowed_crash_spec("budget-publication-order")
11075 };
11076 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(
11077 ModuleState::Running,
11078 true,
11079 )));
11080 lock_snapshot(&snapshot).unwrap().process_alive = true;
11081 let reaped_snapshot = snapshot.clone();
11082 let mut barrier = terminal_record_barrier(&runtime.spawn_events);
11083 let history = runtime.terminal_ring.clone();
11084 let reap = std::thread::spawn(move || {
11085 tokio::runtime::Builder::new_current_thread()
11086 .enable_all()
11087 .build()
11088 .unwrap()
11089 .block_on(on_child_exit(
11090 &spec,
11091 runtime.restart_policy,
11092 &supervisor.registry,
11093 &reaped_snapshot,
11094 &runtime.terminal_ring,
11095 &runtime.spawn_events,
11096 &runtime.child_roster,
11097 ExitReport {
11098 kind: ExitKind::Clean,
11099 code: Some(0),
11100 signal: None,
11101 at_ms: 1,
11102 },
11103 ))
11104 });
11105 let mut reached = [0];
11106 barrier.read_exact(&mut reached).unwrap();
11107 assert_eq!(&reached, b"R");
11108 let (before_state, before_alive) = {
11109 let state = lock_snapshot(&snapshot).unwrap();
11110 (state.state, state.process_alive)
11111 };
11112 let before_history = history.lock().unwrap().snapshot();
11113 barrier.write_all(b"X").unwrap();
11114 assert!(matches!(reap.join().unwrap(), NextAction::Stop { .. }));
11115 assert!(before_history.entries.is_empty());
11116 assert_ne!(
11117 before_state,
11118 ModuleState::Failed,
11119 "Failed was visible before its terminal record could be written"
11120 );
11121 assert!(before_alive);
11122 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
11123 let history = history.lock().unwrap().snapshot();
11124 assert_eq!(history.entries.len(), 1);
11125 assert_eq!(history.entries[0].exit_code, Some(0));
11126 assert_eq!(history.entries[0].disposition, TerminalDisposition::Failed);
11127 }
11128
11129 #[test]
11130 fn crash_restart_at_budget_limit_remains_recoverable_while_recording() {
11131 use std::io::{Read, Write};
11132
11133 let supervisor = Supervisor::new_for_test(
11134 Arc::new(Registry::default()),
11135 RestartPolicy::new(3, Duration::ZERO),
11136 );
11137 let mut runtime = supervisor.runtime_config();
11138 let module = module_with_recovery_snapshot(ModuleState::Running, true, 2);
11139 let snapshot = module.inner.snapshot.clone();
11140 lock_snapshot(&snapshot).unwrap().process_alive = true;
11141 runtime.terminal_ring = module.inner.terminal_ring.clone();
11142 let spec = windowed_crash_spec(module.module_id());
11143 let mut barrier = terminal_record_barrier(&runtime.spawn_events);
11144 let reaped_snapshot = snapshot.clone();
11145 let reap = std::thread::spawn(move || {
11146 tokio::runtime::Builder::new_current_thread()
11147 .enable_all()
11148 .build()
11149 .unwrap()
11150 .block_on(on_child_exit(
11151 &spec,
11152 runtime.restart_policy,
11153 &supervisor.registry,
11154 &reaped_snapshot,
11155 &runtime.terminal_ring,
11156 &runtime.spawn_events,
11157 &runtime.child_roster,
11158 ExitReport {
11159 kind: ExitKind::Crash,
11160 code: Some(2),
11161 signal: None,
11162 at_ms: 1,
11163 },
11164 ))
11165 });
11166
11167 let mut reached = [0];
11171 barrier.read_exact(&mut reached).unwrap();
11172 assert_eq!(&reached, b"R");
11173 let before_status = module.status().unwrap();
11174 let before_history = module.terminal_history();
11175 let before_recovers = module.will_recover_after_connection_loss().unwrap();
11176 barrier.write_all(b"X").unwrap();
11177 assert!(matches!(reap.join().unwrap(), NextAction::Restart { .. }));
11178
11179 assert_eq!(before_status.state, ModuleState::Running);
11180 assert!(before_status.process_alive);
11181 assert_eq!(before_status.restart_count, 3);
11182 assert!(before_history.entries.is_empty());
11183 assert!(
11184 before_recovers,
11185 "connection cleanup would report a terminal close for a scheduled crash restart"
11186 );
11187 let restarting = module.status().unwrap();
11188 assert_eq!(restarting.state, ModuleState::Restarting);
11189 assert_eq!(restarting.restart_count, 3);
11190 assert!(module.will_recover_after_connection_loss().unwrap());
11191 let history = module.terminal_history();
11192 assert_eq!(history.entries.len(), 1);
11193 assert_eq!(
11194 history.entries[0].disposition,
11195 TerminalDisposition::Restarting
11196 );
11197
11198 update_snapshot(&snapshot, Some(module.module_id()), |state| {
11201 state.state = ModuleState::Running;
11202 state.respawn_pending = false;
11203 })
11204 .unwrap();
11205 assert!(!module.will_recover_after_connection_loss().unwrap());
11206 update_snapshot(&snapshot, Some(module.module_id()), |state| {
11207 state.enabled = false;
11208 state.respawn_pending = true;
11209 })
11210 .unwrap();
11211 assert!(!module.will_recover_after_connection_loss().unwrap());
11212 }
11213
11214 fn terminal_record_barrier(events: &super::SpawnEventFeed) -> std::net::TcpStream {
11215 use std::net::{TcpListener, TcpStream};
11216 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
11217 let writer = TcpStream::connect(listener.local_addr().unwrap()).unwrap();
11218 let (barrier, _) = listener.accept().unwrap();
11219 for stream in [&writer, &barrier] {
11220 stream
11221 .set_read_timeout(Some(Duration::from_secs(10)))
11222 .unwrap();
11223 stream
11224 .set_write_timeout(Some(Duration::from_secs(10)))
11225 .unwrap();
11226 }
11227 *events.1.lock().unwrap() = Some(writer);
11228 barrier
11229 }
11230
11231 pub(super) fn before_terminal_record(events: &super::SpawnEventFeed) {
11232 use std::io::{Read, Write};
11233 let barrier = events.1.lock().unwrap().take();
11234 if let Some(mut barrier) = barrier {
11235 barrier.write_all(b"R").unwrap();
11236 let mut release = [0];
11237 barrier.read_exact(&mut release).unwrap();
11238 assert_eq!(&release, b"X");
11239 }
11240 }
11241
11242 #[test]
11243 fn health_restart_budget_failure_is_published_after_its_terminal_record() {
11244 use std::io::{Read, Write};
11245
11246 let supervisor = Supervisor::new_for_test(
11247 Arc::new(Registry::default()),
11248 RestartPolicy::new(0, Duration::ZERO),
11249 );
11250 let mut runtime = supervisor.runtime_config();
11251 let module = module_with_recovery_snapshot(ModuleState::Running, true, 0);
11252 let snapshot = module.inner.snapshot.clone();
11253 runtime.terminal_ring = module.inner.terminal_ring.clone();
11254 let spec = ModuleSpec {
11255 module_id: module.module_id().into(),
11256 program: fake_aft_stub_path(),
11257 protocol: ModuleProtocol::None,
11258 env: vec![("FAKE_AFT_EXIT_CODE".into(), "17".into())],
11259 ..windowed_crash_spec(module.module_id())
11260 };
11261 let mut barrier = terminal_record_barrier(&runtime.spawn_events);
11262 let reap = std::thread::spawn(move || {
11263 tokio::runtime::Builder::new_current_thread()
11264 .enable_all()
11265 .build()
11266 .unwrap()
11267 .block_on(async {
11268 let mut child =
11269 Some(spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap());
11270 health_restart_child(
11271 &spec,
11272 &runtime,
11273 &supervisor.registry,
11274 &supervisor.process_liveness,
11275 &snapshot,
11276 &mut child,
11277 SupervisorHealthStatus::Failing,
11278 Some("health probe did not answer"),
11279 1,
11280 )
11281 .await
11282 .unwrap();
11283 assert!(child.is_none());
11284 });
11285 });
11286 let mut reached = [0];
11291 barrier.read_exact(&mut reached).unwrap();
11292 assert_eq!(&reached, b"R");
11293 let before_status = module.status().unwrap();
11294 let before_history = module.terminal_history();
11295 barrier.write_all(b"X").unwrap();
11296 reap.join().unwrap();
11297 assert!(before_history.entries.is_empty());
11298 assert_ne!(
11299 before_status.state,
11300 ModuleState::Failed,
11301 "Failed was visible before its terminal record could be written"
11302 );
11303 assert!(
11304 before_status.process_alive,
11305 "process death was visible before its terminal record could be written"
11306 );
11307 let failed = module.status().unwrap();
11308 assert_eq!(failed.state, ModuleState::Failed);
11309 assert!(!failed.process_alive);
11310 let history = module.terminal_history();
11311 assert_eq!(history.entries.len(), 1);
11312 assert_eq!(history.entries[0].disposition, TerminalDisposition::Failed);
11313 assert_eq!(
11314 history.entries[0].disposition_detail.as_deref(),
11315 Some("crash budget exhausted: max_restarts=0 within window_secs=600")
11316 );
11317 }
11318
11319 #[test]
11320 fn observed_exits_publish_process_death_after_terminal_history() {
11321 use std::io::{Read, Write};
11322 for (kind, enabled, shutdown, wait_error, expected_state) in [
11323 (ExitKind::Clean, true, false, false, ModuleState::Stopped),
11324 (ExitKind::Crash, true, false, false, ModuleState::Restarting),
11325 (ExitKind::Crash, false, false, false, ModuleState::Disabled),
11326 (
11327 ExitKind::DeliberateSeverance,
11328 true,
11329 false,
11330 false,
11331 ModuleState::Restarting,
11332 ),
11333 #[cfg(unix)]
11334 (ExitKind::Crash, true, true, false, ModuleState::Stopped),
11335 (ExitKind::Crash, true, false, true, ModuleState::Failed),
11336 ] {
11337 let supervisor = Supervisor::new_for_test(
11338 Arc::new(Registry::default()),
11339 RestartPolicy::new(1, Duration::ZERO),
11340 );
11341 let runtime = supervisor.runtime_config();
11342 #[cfg(unix)]
11343 if shutdown {
11344 runtime.child_roster.close();
11345 }
11346 #[cfg(not(unix))]
11347 let _ = shutdown;
11348 let spec = windowed_crash_spec("exit-publication-order");
11349 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(
11350 ModuleState::Running,
11351 enabled,
11352 )));
11353 lock_snapshot(&snapshot).unwrap().process_alive = true;
11354 let reaped_snapshot = snapshot.clone();
11355 let history = runtime.terminal_ring.clone();
11356 let mut barrier = terminal_record_barrier(&runtime.spawn_events);
11357 let reap = std::thread::spawn(move || {
11358 if wait_error {
11359 super::fail_after_wait_error(
11360 &spec.module_id,
11361 &reaped_snapshot,
11362 &runtime.terminal_ring,
11363 &runtime.spawn_events,
11364 "wait failed".into(),
11365 );
11366 } else {
11367 tokio::runtime::Builder::new_current_thread()
11368 .enable_all()
11369 .build()
11370 .unwrap()
11371 .block_on(on_child_exit(
11372 &spec,
11373 runtime.restart_policy,
11374 &supervisor.registry,
11375 &reaped_snapshot,
11376 &runtime.terminal_ring,
11377 &runtime.spawn_events,
11378 &runtime.child_roster,
11379 ExitReport {
11380 kind,
11381 code: Some(0),
11382 signal: None,
11383 at_ms: 1,
11384 },
11385 ));
11386 }
11387 });
11388 let mut reached = [0];
11389 barrier.read_exact(&mut reached).unwrap();
11390 assert_eq!(&reached, b"R");
11391 let before_alive = lock_snapshot(&snapshot).unwrap().process_alive;
11392 let before_history = history.lock().unwrap().snapshot();
11393 barrier.write_all(b"X").unwrap();
11394 reap.join().unwrap();
11395 assert!(
11396 before_alive,
11397 "{kind:?}: death was published before recording"
11398 );
11399 assert!(before_history.entries.is_empty());
11400 assert_eq!(lock_snapshot(&snapshot).unwrap().state, expected_state);
11401 assert!(!lock_snapshot(&snapshot).unwrap().process_alive);
11402 assert_eq!(history.lock().unwrap().snapshot().entries.len(), 1);
11403 }
11404 }
11405
11406 #[tokio::test]
11407 async fn health_restart_budget_failure_remains_failed_when_journal_append_fails() {
11408 let dir = cortexkit_test_support::ScratchDir::new("health-budget-journal-failure");
11409 let path = dir.join("terminals.jsonl");
11410 std::fs::create_dir(&path).unwrap();
11411 let supervisor = Supervisor::new_for_test(
11412 Arc::new(Registry::default()),
11413 RestartPolicy::new(0, Duration::ZERO),
11414 )
11415 .with_terminal_journal(path, "health-budget-journal-failure".into());
11416 let runtime = supervisor.runtime_config();
11417 let spec = ModuleSpec {
11418 program: fake_aft_stub_path(),
11419 protocol: ModuleProtocol::None,
11420 env: vec![("FAKE_AFT_EXIT_CODE".into(), "17".into())],
11421 ..windowed_crash_spec("health-budget-journal-failure")
11422 };
11423 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11424 let mut child = Some(spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap());
11425 health_restart_child(
11426 &spec,
11427 &runtime,
11428 &supervisor.registry,
11429 &supervisor.process_liveness,
11430 &snapshot,
11431 &mut child,
11432 SupervisorHealthStatus::Failing,
11433 None,
11434 1,
11435 )
11436 .await
11437 .unwrap();
11438 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
11439 assert!(!lock_snapshot(&snapshot).unwrap().process_alive);
11440 let history = runtime
11441 .terminal_ring
11442 .lock()
11443 .unwrap()
11444 .durable_history(&spec.module_id);
11445 assert!(history.journal_write_failures > 0);
11446 assert_eq!(history.entries.len(), 1);
11447 assert_eq!(history.entries[0].disposition, TerminalDisposition::Failed);
11448 }
11449
11450 #[test]
11451 fn health_restart_budget_failure_remains_failed_when_exit_recording_panics() {
11452 let supervisor = Supervisor::new_for_test(
11453 Arc::new(Registry::default()),
11454 RestartPolicy::new(0, Duration::ZERO),
11455 );
11456 let runtime = supervisor.runtime_config();
11457 let spec = ModuleSpec {
11458 program: fake_aft_stub_path(),
11459 protocol: ModuleProtocol::None,
11460 env: vec![("FAKE_AFT_EXIT_CODE".into(), "17".into())],
11461 ..windowed_crash_spec("health-budget-recording-panic")
11462 };
11463 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11464 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
11465 tokio::runtime::Builder::new_current_thread()
11466 .enable_all()
11467 .build()
11468 .unwrap()
11469 .block_on(async {
11470 let mut child =
11471 Some(spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap());
11472 runtime.spawn_events.0.lock().unwrap().seq = u64::MAX;
11473 health_restart_child(
11474 &spec,
11475 &runtime,
11476 &supervisor.registry,
11477 &supervisor.process_liveness,
11478 &snapshot,
11479 &mut child,
11480 SupervisorHealthStatus::Failing,
11481 None,
11482 1,
11483 )
11484 .await
11485 .unwrap();
11486 });
11487 }));
11488 let panic = result.expect_err("recording must still unwind");
11489 assert_eq!(
11490 panic.downcast_ref::<String>().map(String::as_str),
11491 Some("spawn event sequence exhausted")
11492 );
11493 let state = lock_snapshot(&snapshot).unwrap();
11494 assert_eq!(state.state, ModuleState::Failed);
11495 assert!(!state.process_alive);
11496 assert!(state.last_exit.is_some());
11497 }
11498
11499 #[tokio::test]
11500 async fn restart_budget_failure_remains_failed_when_journal_append_fails() {
11501 let dir = cortexkit_test_support::ScratchDir::new("budget-journal-failure");
11502 let path = dir.join("terminals.jsonl");
11503 std::fs::create_dir(&path).unwrap();
11504 let supervisor = Supervisor::new_for_test(
11505 Arc::new(Registry::default()),
11506 RestartPolicy::new(0, Duration::ZERO),
11507 )
11508 .with_terminal_journal(path, "budget-journal-failure".into());
11509 let runtime = supervisor.runtime_config();
11510 let spec = windowed_crash_spec("budget-journal-failure");
11511 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11512 assert!(matches!(
11513 on_child_exit(
11514 &spec,
11515 runtime.restart_policy,
11516 &supervisor.registry,
11517 &snapshot,
11518 &runtime.terminal_ring,
11519 &runtime.spawn_events,
11520 &runtime.child_roster,
11521 crash_exit_report(1),
11522 )
11523 .await,
11524 NextAction::Stop { .. }
11525 ));
11526 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
11527 let history = runtime
11528 .terminal_ring
11529 .lock()
11530 .unwrap()
11531 .durable_history(&spec.module_id);
11532 assert!(history.journal_write_failures > 0);
11533 assert_eq!(history.entries.len(), 1);
11534 assert_eq!(history.entries[0].disposition, TerminalDisposition::Failed);
11535 }
11536
11537 #[test]
11538 fn restart_budget_failure_remains_failed_when_exit_recording_panics() {
11539 let supervisor = Supervisor::new_for_test(
11540 Arc::new(Registry::default()),
11541 RestartPolicy::new(0, Duration::ZERO),
11542 );
11543 let runtime = supervisor.runtime_config();
11544 let spec = windowed_crash_spec("budget-recording-panic");
11545 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11546 runtime.spawn_events.emit_spawned(&spec.module_id, 1, 1);
11547 runtime.spawn_events.0.lock().unwrap().seq = u64::MAX;
11550 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
11551 tokio::runtime::Builder::new_current_thread()
11552 .enable_all()
11553 .build()
11554 .unwrap()
11555 .block_on(on_child_exit(
11556 &spec,
11557 runtime.restart_policy,
11558 &supervisor.registry,
11559 &snapshot,
11560 &runtime.terminal_ring,
11561 &runtime.spawn_events,
11562 &runtime.child_roster,
11563 crash_exit_report(1),
11564 ))
11565 }));
11566 let panic = result.err().expect("recording must still unwind");
11567 assert_eq!(
11568 panic.downcast_ref::<String>().map(String::as_str),
11569 Some("spawn event sequence exhausted")
11570 );
11571 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
11572 }
11573
11574 #[cfg(unix)]
11577 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11578 async fn protocol_none_requested_stop_and_disable_do_not_respawn() {
11579 for disable in [false, true] {
11580 let label = if disable {
11581 "none-requested-disable"
11582 } else {
11583 "none-requested-stop"
11584 };
11585 let dir = cortexkit_test_support::ScratchDir::new(label);
11586 let (spec, ready, marker) = protocol_none_sigterm_exits_clean_spec(label, dir.path());
11587 let supervisor = Supervisor::new_for_test(
11588 Arc::new(Registry::default()),
11589 RestartPolicy::new(3, Duration::ZERO),
11590 );
11591 let module = supervisor.spawn(spec).unwrap();
11592 wait_for_file(&ready).await;
11593
11594 if disable {
11595 module.set_enabled(false).await.unwrap();
11596 } else {
11597 module.stop().await.unwrap();
11598 }
11599 assert!(
11600 marker.exists(),
11601 "{label}: the child must have left through its SIGTERM handler with exit 0"
11602 );
11603
11604 sleep(Duration::from_millis(500)).await;
11607 let status = module.status().unwrap();
11608 let expected = if disable {
11609 ModuleState::Disabled
11610 } else {
11611 ModuleState::Stopped
11612 };
11613 assert_eq!(status.state, expected, "{label}");
11614 assert_eq!(
11615 status.spawn_generation, 1,
11616 "{label}: respawned after a requested stop"
11617 );
11618 let history = module.terminal_history();
11619 assert_eq!(history.entries.len(), 1, "{label}: {history:?}");
11620 assert_eq!(history.entries[0].exit_code, Some(0), "{label}");
11621 assert_ne!(
11622 history.entries[0].disposition,
11623 TerminalDisposition::Restarting,
11624 "{label}"
11625 );
11626 }
11627 }
11628
11629 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11632 async fn subc_wire_clean_exit_is_still_a_stop() {
11633 let supervisor = Supervisor::new_for_test(
11634 Arc::new(Registry::default()),
11635 RestartPolicy::new(3, Duration::ZERO),
11636 );
11637 let module = supervisor
11638 .spawn(ModuleSpec {
11639 module_id: "wire-clean-exit".to_string(),
11640 program: fake_aft_stub_path(),
11641 args: Vec::new(),
11642 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "0".to_string())],
11643 reserved: false,
11644 reserved_prefixes: Vec::new(),
11645 protocol: ModuleProtocol::Subc,
11646 overlap: Default::default(),
11647 })
11648 .unwrap();
11649
11650 let deadline = Instant::now() + Duration::from_secs(10);
11651 while module.terminal_history().entries.is_empty() {
11652 assert!(Instant::now() < deadline, "module never exited");
11653 sleep(Duration::from_millis(10)).await;
11654 }
11655 sleep(Duration::from_millis(500)).await;
11657 let status = module.status().unwrap();
11658 assert_eq!(status.state, ModuleState::Stopped);
11659 assert_eq!(status.spawn_generation, 1);
11660 let history = module.terminal_history();
11661 assert_eq!(history.entries.len(), 1, "{history:?}");
11662 assert_eq!(history.entries[0].exit_code, Some(0));
11663 assert_eq!(history.entries[0].disposition, TerminalDisposition::Stopped);
11664 }
11665
11666 #[cfg(unix)]
11667 #[tokio::test]
11668 async fn reload_exit_keeps_roster_until_terminal_is_recorded_during_shutdown() {
11669 let dir = cortexkit_test_support::ScratchDir::new("reload-roster-terminal-order");
11670 let record = dir.join("live-children.json");
11671 let supervisor = Supervisor::new_for_test(
11672 Arc::new(Registry::default()),
11673 RestartPolicy::new(0, Duration::ZERO),
11674 );
11675 let mut runtime = supervisor.runtime_config();
11676 runtime.child_roster.record_to(record.clone());
11677 let gate = Arc::new(super::ReloadExitRecordGate::default());
11678 runtime.test_reload_exit_record_gate = Some(Arc::clone(&gate));
11679 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11680 let spec = ModuleSpec {
11681 module_id: "reload-exit-roster".into(),
11682 program: fake_aft_stub_path(),
11683 args: Vec::new(),
11684 env: vec![("FAKE_AFT_EXIT_CODE".into(), "23".into())],
11685 reserved: false,
11686 reserved_prefixes: Vec::new(),
11687 protocol: ModuleProtocol::Subc,
11688 overlap: Default::default(),
11689 };
11690 let mut child = None;
11691 let reload = super::finish_reload_child(
11692 &spec,
11693 &runtime,
11694 &supervisor.registry,
11695 &supervisor.process_liveness,
11696 &snapshot,
11697 &mut child,
11698 );
11699 tokio::pin!(reload);
11700 tokio::select! {
11701 result = &mut reload => panic!("reload missed the before-record gate: {result:?}"),
11702 _ = gate.reached.notified() => {}
11703 }
11704 assert!(runtime
11705 .terminal_ring
11706 .lock()
11707 .unwrap()
11708 .snapshot()
11709 .entries
11710 .is_empty());
11711 assert_eq!(
11712 crate::live_children::read_record(&record).unwrap().len(),
11713 1,
11714 "shutdown must still wait for the reaped child until its terminal record exists"
11715 );
11716 runtime.child_roster.close();
11717 gate.resume.notify_one();
11718 assert!(reload.await.is_err());
11719 assert!(crate::live_children::read_record(&record)
11720 .unwrap()
11721 .is_empty());
11722 let history = runtime.terminal_ring.lock().unwrap().snapshot();
11723 assert_eq!(history.entries.len(), 1);
11724 assert_eq!(
11725 history.entries[0].disposition,
11726 TerminalDisposition::DaemonShutdown
11727 );
11728 }
11729
11730 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11734 async fn every_restart_increment_path_advances_lifetime_count() {
11735 let supervisor = Supervisor::new_for_test(
11736 Arc::new(Registry::default()),
11737 RestartPolicy::new(1, Duration::ZERO),
11738 );
11739 let runtime = supervisor.runtime_config();
11740 let spec = ModuleSpec {
11741 module_id: "lifetime-increment-path".to_string(),
11742 program: PathBuf::from("/unused/lifetime-increment-path"),
11743 args: Vec::new(),
11744 env: Vec::new(),
11745 reserved: false,
11746 reserved_prefixes: Vec::new(),
11747 protocol: ModuleProtocol::Subc,
11748 overlap: Default::default(),
11749 };
11750
11751 let crash_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11752 assert!(matches!(
11753 on_child_exit(
11754 &spec,
11755 runtime.restart_policy,
11756 &supervisor.registry,
11757 &crash_snapshot,
11758 &runtime.terminal_ring,
11759 &runtime.spawn_events,
11760 &runtime.child_roster,
11761 ExitReport {
11762 kind: ExitKind::Crash,
11763 code: Some(1),
11764 signal: None,
11765 at_ms: 1,
11766 },
11767 )
11768 .await,
11769 NextAction::Restart { schedule: _ }
11770 ));
11771 let (crash_restarts, crash_lifetime) = {
11772 let state = lock_snapshot(&crash_snapshot).unwrap();
11773 (state.crash_restarts.len(), state.lifetime_restarts)
11774 };
11775 assert_eq!(crash_restarts, 1);
11776 assert_eq!(crash_lifetime, 1);
11777
11778 let health_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11779 let mut health_child = None;
11780 assert!(matches!(
11781 health_restart_child(
11782 &spec,
11783 &runtime,
11784 &supervisor.registry,
11785 &supervisor.process_liveness,
11786 &health_snapshot,
11787 &mut health_child,
11788 SupervisorHealthStatus::Failing,
11789 None,
11790 2,
11791 )
11792 .await,
11793 Ok(())
11794 ));
11795 assert!(health_child.is_none());
11796 assert!(lock_snapshot(&health_snapshot).unwrap().respawn_pending);
11797 assert!(runtime.scheduled_respawn.lock().unwrap().take().is_some());
11798 let (health_restarts, health_lifetime) = {
11799 let state = lock_snapshot(&health_snapshot).unwrap();
11800 (state.crash_restarts.len(), state.lifetime_restarts)
11801 };
11802 assert_eq!(health_restarts, 1);
11803 assert_eq!(health_lifetime, 1);
11804
11805 let reload_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11806 let mut reload_child = None;
11807 assert!(matches!(
11808 handle_reload_spawn_failure(
11809 &spec,
11810 &runtime,
11811 &supervisor.process_liveness,
11812 &reload_snapshot,
11813 &mut reload_child,
11814 "forced reload spawn failure".to_string(),
11815 )
11816 .await,
11817 Err(SuperviseError::ReloadFailed { .. })
11818 ));
11819 let (reload_restarts, reload_lifetime) = {
11820 let state = lock_snapshot(&reload_snapshot).unwrap();
11821 (state.crash_restarts.len(), state.lifetime_restarts)
11822 };
11823 assert_eq!(reload_restarts, 1);
11824 assert_eq!(reload_lifetime, 1);
11825 }
11826
11827 #[tokio::test]
11828 async fn deliberately_severed_live_child_records_lifetime_without_spending_restart_budget() {
11829 let supervisor = Supervisor::new_for_test(
11830 Arc::new(Registry::default()),
11831 RestartPolicy::new(3, Duration::ZERO),
11832 );
11833 let runtime = supervisor.runtime_config();
11834 let spec = ModuleSpec {
11835 module_id: "deliberately-severed".to_string(),
11836 program: PathBuf::from("/unused/deliberately-severed"),
11837 args: Vec::new(),
11838 env: Vec::new(),
11839 reserved: false,
11840 reserved_prefixes: Vec::new(),
11841 protocol: ModuleProtocol::Subc,
11842 overlap: Default::default(),
11843 };
11844 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11845 let process = ProcessIdentity {
11846 pid: 41,
11847 start_time: 101,
11848 };
11849 record_deliberate_severance(&snapshot, process).unwrap();
11850 let exit_report = apply_deliberate_severance_marker(
11851 &snapshot,
11852 Some(process),
11853 ExitReport {
11854 kind: ExitKind::Crash,
11855 code: Some(1),
11856 signal: None,
11857 at_ms: 1,
11858 },
11859 );
11860 assert_eq!(exit_report.kind, ExitKind::DeliberateSeverance);
11861
11862 assert!(matches!(
11863 on_child_exit(
11864 &spec,
11865 runtime.restart_policy,
11866 &supervisor.registry,
11867 &snapshot,
11868 &runtime.terminal_ring,
11869 &runtime.spawn_events,
11870 &runtime.child_roster,
11871 exit_report,
11872 )
11873 .await,
11874 NextAction::Restart { schedule: _ }
11875 ));
11876 let state = lock_snapshot(&snapshot).unwrap();
11877 assert_eq!(state.lifetime_restarts, 1);
11878 assert_eq!(state.crash_restarts.len(), 0);
11879 }
11880
11881 #[tokio::test]
11882 async fn genuine_crash_spends_restart_budget_and_records_lifetime() {
11883 let supervisor = Supervisor::new_for_test(
11884 Arc::new(Registry::default()),
11885 RestartPolicy::new(3, Duration::ZERO),
11886 );
11887 let runtime = supervisor.runtime_config();
11888 let spec = ModuleSpec {
11889 module_id: "genuine-crash".to_string(),
11890 program: PathBuf::from("/unused/genuine-crash"),
11891 args: Vec::new(),
11892 env: Vec::new(),
11893 reserved: false,
11894 reserved_prefixes: Vec::new(),
11895 protocol: ModuleProtocol::Subc,
11896 overlap: Default::default(),
11897 };
11898 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11899
11900 assert!(matches!(
11901 on_child_exit(
11902 &spec,
11903 runtime.restart_policy,
11904 &supervisor.registry,
11905 &snapshot,
11906 &runtime.terminal_ring,
11907 &runtime.spawn_events,
11908 &runtime.child_roster,
11909 ExitReport {
11910 kind: ExitKind::Crash,
11911 code: Some(1),
11912 signal: None,
11913 at_ms: 1,
11914 },
11915 )
11916 .await,
11917 NextAction::Restart { schedule: _ }
11918 ));
11919 let state = lock_snapshot(&snapshot).unwrap();
11920 assert_eq!(state.lifetime_restarts, 1);
11921 assert_eq!(state.crash_restarts.len(), 1);
11922 }
11923
11924 fn crash_exit_report(at_ms: u64) -> ExitReport {
11925 ExitReport {
11926 kind: ExitKind::Crash,
11927 code: Some(1),
11928 signal: None,
11929 at_ms,
11930 }
11931 }
11932
11933 fn windowed_crash_spec(module_id: &str) -> ModuleSpec {
11934 ModuleSpec {
11935 module_id: module_id.to_string(),
11936 program: PathBuf::from("/unused").join(module_id),
11937 args: Vec::new(),
11938 env: Vec::new(),
11939 reserved: false,
11940 reserved_prefixes: Vec::new(),
11941 protocol: ModuleProtocol::Subc,
11942 overlap: Default::default(),
11943 }
11944 }
11945
11946 #[tokio::test]
11952 async fn three_crashes_inside_the_window_stop_the_module_and_name_the_window() {
11953 let (logs, _guard) = crate::router::test_log::log_capture(tracing::Level::ERROR);
11954 let supervisor = Supervisor::new_for_test(
11955 Arc::new(Registry::default()),
11956 RestartPolicy::new(2, Duration::ZERO),
11957 );
11958 let runtime = supervisor.runtime_config();
11959 let spec = windowed_crash_spec("crash-loop-in-window");
11960 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11961
11962 for attempt in 1..=2 {
11963 assert!(
11964 matches!(
11965 on_child_exit(
11966 &spec,
11967 runtime.restart_policy,
11968 &supervisor.registry,
11969 &snapshot,
11970 &runtime.terminal_ring,
11971 &runtime.spawn_events,
11972 &runtime.child_roster,
11973 crash_exit_report(attempt),
11974 )
11975 .await,
11976 NextAction::Restart { schedule: _ }
11977 ),
11978 "crash {attempt} is inside the budget and must respawn"
11979 );
11980 }
11981
11982 assert!(matches!(
11983 on_child_exit(
11984 &spec,
11985 runtime.restart_policy,
11986 &supervisor.registry,
11987 &snapshot,
11988 &runtime.terminal_ring,
11989 &runtime.spawn_events,
11990 &runtime.child_roster,
11991 crash_exit_report(3),
11992 )
11993 .await,
11994 NextAction::Stop { .. }
11995 ));
11996
11997 {
11998 let state = lock_snapshot(&snapshot).unwrap();
11999 assert_eq!(state.state, ModuleState::Failed);
12000 assert_eq!(state.crash_restarts.len(), 2);
12001 assert_eq!(state.lifetime_restarts, 2);
12002 }
12003
12004 let history = runtime
12005 .terminal_ring
12006 .lock()
12007 .expect("terminal ring is not poisoned")
12008 .snapshot();
12009 let last = history
12010 .entries
12011 .last()
12012 .expect("the refused crash is retained");
12013 assert_eq!(last.disposition, TerminalDisposition::Failed);
12014 assert_eq!(
12015 last.disposition_detail.as_deref(),
12016 Some("crash budget exhausted: max_restarts=2 within window_secs=600")
12017 );
12018
12019 let captured = crate::router::test_log::captured_logs(&logs);
12020 assert!(
12021 captured.contains("crash budget exhausted: max_restarts=2 within window_secs=600"),
12022 "the stop must be logged with its window: {captured}"
12023 );
12024 }
12025
12026 #[tokio::test]
12034 async fn a_crash_older_than_the_window_frees_its_slot_for_a_later_crash() {
12035 let supervisor = Supervisor::new_for_test(
12036 Arc::new(Registry::default()),
12037 RestartPolicy::new(2, Duration::ZERO),
12038 );
12039 let runtime = supervisor.runtime_config();
12040 let spec = windowed_crash_spec("crash-across-windows");
12041 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
12042
12043 for attempt in 1..=2 {
12044 assert!(matches!(
12045 on_child_exit(
12046 &spec,
12047 runtime.restart_policy,
12048 &supervisor.registry,
12049 &snapshot,
12050 &runtime.terminal_ring,
12051 &runtime.spawn_events,
12052 &runtime.child_roster,
12053 crash_exit_report(attempt),
12054 )
12055 .await,
12056 NextAction::Restart { schedule: _ }
12057 ));
12058 }
12059
12060 update_snapshot(&snapshot, Some(&spec.module_id), |state| {
12063 age_oldest_crash_restart_out_of_window(state, runtime.restart_policy.window);
12064 })
12065 .unwrap();
12066
12067 assert!(
12068 matches!(
12069 on_child_exit(
12070 &spec,
12071 runtime.restart_policy,
12072 &supervisor.registry,
12073 &snapshot,
12074 &runtime.terminal_ring,
12075 &runtime.spawn_events,
12076 &runtime.child_roster,
12077 crash_exit_report(3),
12078 )
12079 .await,
12080 NextAction::Restart { schedule: _ }
12081 ),
12082 "a crash older than the window must not hold a budget slot"
12083 );
12084
12085 let state = lock_snapshot(&snapshot).unwrap();
12086 assert_eq!(state.state, ModuleState::Restarting);
12087 assert_eq!(
12088 state.crash_restarts.len(),
12089 2,
12090 "the aged instant is dropped and the new one takes its place"
12091 );
12092 assert_eq!(
12093 state.lifetime_restarts, 3,
12094 "the ledger counts every restart, including the ones the window forgot"
12095 );
12096 }
12097
12098 #[tokio::test]
12103 async fn an_operator_restart_clears_the_ring_and_leaves_the_ledger_alone() {
12104 let supervisor = Supervisor::new_for_test(
12105 Arc::new(Registry::default()),
12106 RestartPolicy::new(2, Duration::ZERO),
12107 );
12108 let runtime = supervisor.runtime_config();
12109 let spec = windowed_crash_spec("operator-cleared-budget");
12110 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
12111
12112 for attempt in 1..=2 {
12113 assert!(matches!(
12114 on_child_exit(
12115 &spec,
12116 runtime.restart_policy,
12117 &supervisor.registry,
12118 &snapshot,
12119 &runtime.terminal_ring,
12120 &runtime.spawn_events,
12121 &runtime.child_roster,
12122 crash_exit_report(attempt),
12123 )
12124 .await,
12125 NextAction::Restart { schedule: _ }
12126 ));
12127 }
12128
12129 reset_restart_count(&snapshot, &spec.module_id).unwrap();
12130 {
12131 let state = lock_snapshot(&snapshot).unwrap();
12132 assert!(
12133 state.crash_restarts.is_empty(),
12134 "an operator restart returns the full budget"
12135 );
12136 assert_eq!(
12137 state.lifetime_restarts, 2,
12138 "clearing the budget must not unmake the crashes"
12139 );
12140 }
12141
12142 assert!(
12143 matches!(
12144 on_child_exit(
12145 &spec,
12146 runtime.restart_policy,
12147 &supervisor.registry,
12148 &snapshot,
12149 &runtime.terminal_ring,
12150 &runtime.spawn_events,
12151 &runtime.child_roster,
12152 crash_exit_report(3),
12153 )
12154 .await,
12155 NextAction::Restart { schedule: _ }
12156 ),
12157 "the cleared budget must be spendable again"
12158 );
12159 let state = lock_snapshot(&snapshot).unwrap();
12160 assert_eq!(state.crash_restarts.len(), 1);
12161 assert_eq!(state.lifetime_restarts, 3);
12162 }
12163
12164 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
12165 async fn severance_marker_for_a_dead_child_does_not_label_its_successor() {
12166 let severed = ProcessIdentity {
12167 pid: 41,
12168 start_time: 101,
12169 };
12170 let successor = ProcessIdentity {
12171 pid: 41,
12172 start_time: 202,
12173 };
12174 let module = module_with_recovery_snapshot(ModuleState::Running, true, 0);
12175 update_snapshot(&module.inner.snapshot, Some("recovery-snapshot"), |state| {
12176 state.pid = Some(successor.pid);
12177 state.process_start_time = Some(successor.start_time);
12178 })
12179 .unwrap();
12180 assert!(!module.record_deliberate_severance(severed).unwrap());
12181
12182 let exit_report = apply_deliberate_severance_marker(
12183 &module.inner.snapshot,
12184 Some(successor),
12185 ExitReport {
12186 kind: ExitKind::Crash,
12187 code: Some(1),
12188 signal: None,
12189 at_ms: 1,
12190 },
12191 );
12192
12193 assert_eq!(exit_report.kind, ExitKind::Crash);
12194 }
12195
12196 #[tokio::test]
12197 async fn drain_reap_marks_deliberate_severance_and_records_lifetime_without_budget() {
12198 let registry = Registry::default();
12199 let supervisor = Supervisor::new_for_test(
12200 Arc::new(Registry::default()),
12201 RestartPolicy::new(3, Duration::ZERO),
12202 );
12203 let runtime = supervisor.runtime_config();
12204 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
12205 let spec = ModuleSpec {
12206 module_id: "drain-deliberate-severance".to_string(),
12207 program: fake_aft_stub_path(),
12208 args: Vec::new(),
12209 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
12210 reserved: false,
12211 reserved_prefixes: Vec::new(),
12212 protocol: ModuleProtocol::Subc,
12213 overlap: Default::default(),
12214 };
12215 let mut child = spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap();
12216 let process = ProcessIdentity {
12217 pid: 41,
12218 start_time: 101,
12219 };
12220 child.process_identity = Some(process);
12221 update_snapshot(&snapshot, Some(&spec.module_id), |state| {
12222 state.pid = Some(process.pid);
12223 state.process_start_time = Some(process.start_time);
12224 })
12225 .unwrap();
12226 record_deliberate_severance(&snapshot, process).unwrap();
12227
12228 drain_child_to_state(
12229 &spec.module_id,
12230 spec.protocol,
12231 StopNotice::SentOverConnection,
12234 ®istry,
12235 None,
12236 &snapshot,
12237 &runtime.terminal_ring,
12238 &runtime.spawn_events,
12239 child,
12240 Duration::from_secs(1),
12241 ModuleState::Stopped,
12242 Some(false),
12243 )
12244 .await
12245 .unwrap();
12246
12247 let state = lock_snapshot(&snapshot).unwrap();
12248 assert_eq!(
12249 state.last_exit.as_ref().map(|exit| exit.kind),
12250 Some(ExitKind::DeliberateSeverance)
12251 );
12252 assert_eq!(state.lifetime_restarts, 1);
12253 assert_eq!(state.crash_restarts.len(), 0);
12254 drop(state);
12255 let history = runtime.terminal_ring.lock().unwrap().snapshot();
12256 assert_eq!(
12257 history.entries[0].exit_kind,
12258 subc_control::TerminalExitKind::DeliberateSeverance
12259 );
12260 }
12261
12262 #[tokio::test]
12263 async fn ordinary_drain_reap_does_not_record_a_lifetime_restart() {
12264 let registry = Registry::default();
12265 let supervisor = Supervisor::new_for_test(
12266 Arc::new(Registry::default()),
12267 RestartPolicy::new(3, Duration::ZERO),
12268 );
12269 let runtime = supervisor.runtime_config();
12270 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
12271 let spec = ModuleSpec {
12272 module_id: "ordinary-drain".to_string(),
12273 program: fake_aft_stub_path(),
12274 args: Vec::new(),
12275 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
12276 reserved: false,
12277 reserved_prefixes: Vec::new(),
12278 protocol: ModuleProtocol::Subc,
12279 overlap: Default::default(),
12280 };
12281 let child = spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap();
12282
12283 drain_child_to_state(
12284 &spec.module_id,
12285 spec.protocol,
12286 StopNotice::SentOverConnection,
12289 ®istry,
12290 None,
12291 &snapshot,
12292 &runtime.terminal_ring,
12293 &runtime.spawn_events,
12294 child,
12295 Duration::from_secs(1),
12296 ModuleState::Stopped,
12297 Some(false),
12298 )
12299 .await
12300 .unwrap();
12301
12302 let state = lock_snapshot(&snapshot).unwrap();
12303 assert_eq!(
12304 state.last_exit.as_ref().map(|exit| exit.kind),
12305 Some(ExitKind::Crash)
12306 );
12307 assert_eq!(state.lifetime_restarts, 0);
12308 assert_eq!(state.crash_restarts.len(), 0);
12309 }
12310
12311 #[test]
12312 fn fatal_connection_teardown_cannot_arm_a_marker_for_a_surviving_process() {
12313 assert!(!include_str!("server.rs")
12319 .contains("router.record_deliberate_connection_severance(ctx.connection_id)"));
12320 }
12321
12322 #[test]
12329 fn drained_after_quiescence_wait_passes_ok_through_and_forces_false_on_err() {
12330 assert!(drained_after_quiescence_wait(&Ok(true)));
12331 assert!(!drained_after_quiescence_wait(&Ok(false)));
12332 assert!(!drained_after_quiescence_wait(&Err(
12333 SuperviseError::StatePoisoned { module_id: None }
12334 )));
12335 }
12336
12337 #[test]
12346 fn wait_error_exit_report_records_a_failed_terminal_with_no_code_or_signal() {
12347 let ring = Arc::new(Mutex::new(TerminalRing::new(
12348 TerminalRingConfig::default(),
12349 0,
12350 )));
12351 record_wait_error_terminal("wait-error", &ring, &super::SpawnEventFeed::default());
12352
12353 let snapshot = ring.lock().unwrap().snapshot();
12354 assert_eq!(snapshot.entries.len(), 1);
12355 let entry = &snapshot.entries[0];
12356 assert_eq!(entry.exit_code, None);
12357 assert_eq!(entry.exit_signal, None);
12358 assert_eq!(entry.disposition, TerminalDisposition::Failed);
12359 }
12360
12361 #[test]
12362 fn wait_error_exit_path_preserves_spawn_event_density() {
12363 let feed = super::SpawnEventFeed::default();
12364 feed.configure_incarnation("wait-error-density".to_string());
12365 feed.emit_spawned("wait-error", 41, 1);
12366 let ring = Arc::new(Mutex::new(TerminalRing::new(
12367 TerminalRingConfig::default(),
12368 0,
12369 )));
12370
12371 record_wait_error_terminal("wait-error", &ring, &feed);
12372 feed.emit_spawned("after-wait-error", 42, 2);
12373
12374 let state = feed.0.lock().unwrap();
12375 let sequences = state
12376 .events
12377 .iter()
12378 .map(|event| event.cursor.seq)
12379 .collect::<Vec<_>>();
12380 assert_eq!(sequences, vec![1, 2, 3]);
12381 assert_eq!(state.events[1].kind, SpawnEventKind::Exited);
12382 assert_eq!(state.events[1].exit_code, None);
12383 assert_eq!(state.events[1].exit_signal, None);
12384 }
12385
12386 #[test]
12390 fn wait_error_exit_report_is_classified_as_a_crash() {
12391 assert_eq!(wait_error_exit_report().kind, ExitKind::Crash);
12392 }
12393}
12394
12395#[cfg(all(test, unix))]
12396mod health_event_tests {
12397 use super::*;
12398 use cortexkit_test_support::ScratchDir;
12399 use subc_protocol::{manifest::Concurrency, session::ModuleControlResponse};
12400
12401 struct Actor {
12402 module: SupervisedModule,
12403 registry: Arc<Registry>,
12404 forwarding: Arc<ForwardingTable>,
12405 spec: ModuleSpec,
12406 health: HealthConfig,
12407 rx: mpsc::Receiver<crate::router::OutboundFrame>,
12408 _home: ScratchDir,
12409 }
12410
12411 async fn wait_for<T>(reason: &str, mut observe: impl FnMut() -> Option<T>) -> T {
12412 let deadline = std::time::Instant::now() + Duration::from_secs(10);
12416 let tick = Instant::now();
12417 loop {
12418 if let Some(value) = observe() {
12419 assert_eq!(Instant::now(), tick, "{reason} must not wait for a timer");
12420 return value;
12421 }
12422 assert!(
12423 std::time::Instant::now() < deadline,
12424 "timed out waiting for {reason}"
12425 );
12426 tokio::task::yield_now().await;
12427 }
12428 }
12429
12430 impl Actor {
12431 async fn start(protocol: ModuleProtocol, cadence: Duration) -> Self {
12432 let home = ScratchDir::new("health-events");
12433 let registry = Arc::new(Registry::default());
12434 let forwarding = Arc::new(ForwardingTable::default());
12435 let health = HealthConfig {
12436 cadence,
12437 deadline: Duration::from_secs(3600),
12438 ..HealthConfig::default()
12439 };
12440 let supervisor =
12441 Supervisor::new_for_test(registry.clone(), RestartPolicy::new(3, Duration::ZERO))
12442 .with_forwarding(forwarding.clone())
12443 .with_health_config(health.clone());
12444 let spec = ModuleSpec {
12445 module_id: "event-health-child".into(),
12446 program: PathBuf::from("/bin/sh"),
12447 args: vec!["-c".into(), "exec sleep 600".into()],
12450 env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
12451 .into_iter()
12452 .map(|key| (key.into(), home.path().display().to_string()))
12453 .collect(),
12454 reserved: false,
12455 reserved_prefixes: vec![],
12456 protocol,
12457 overlap: Default::default(),
12458 };
12459 let module = supervisor.spawn(spec.clone()).unwrap();
12460 let (_, rx) = mpsc::channel(8);
12461 let actor = Self {
12462 module,
12463 registry,
12464 forwarding,
12465 spec,
12466 health,
12467 rx,
12468 _home: home,
12469 };
12470 actor
12471 .wait_for_select(|checkpoint| checkpoint.generation > 0)
12472 .await;
12473 actor
12474 }
12475
12476 fn turns(&self) -> u64 {
12477 lock_snapshot(&self.module.inner.snapshot)
12478 .unwrap()
12479 .actor_turns
12480 }
12481
12482 async fn wait_for_select(
12483 &self,
12484 expected: impl Fn(&ActorSelectCheckpoint) -> bool,
12485 ) -> ActorSelectCheckpoint {
12486 wait_for("actor select checkpoint", || {
12487 let state = lock_snapshot(&self.module.inner.snapshot).unwrap();
12488 state.actor_select.filter(|checkpoint| {
12489 checkpoint.generation == state.spawn_generation
12490 && state.state == ModuleState::Running
12491 && state.process_alive
12492 && state.reported_pid().is_some()
12493 && expected(checkpoint)
12494 })
12495 })
12496 .await
12497 }
12498
12499 fn hello(&mut self, connection: u64, health: bool) {
12500 let manifest =
12501 subc_protocol::manifest::ModuleManifest::builder(&self.spec.module_id, "1.0.0")
12502 .protocol_ver(subc_protocol::PROTOCOL_VERSION)
12503 .build();
12504 let connection = ConnectionId::new(connection);
12505 let (tx, rx) = mpsc::channel(8);
12506 self.rx = rx;
12507 self.forwarding
12508 .register_module_connection(
12509 connection,
12510 self.spec.module_id.clone(),
12511 subc_protocol::PROTOCOL_VERSION,
12512 Concurrency::ModuleManaged,
12513 FrameSink::new(tx),
12514 )
12515 .unwrap();
12516 self.registry
12517 .register_with_control_ops(
12518 manifest,
12519 subc_protocol::PROTOCOL_VERSION,
12520 connection,
12521 if health {
12522 vec![MODULE_CONTROL_OP_HEALTH_CHECK.into()]
12523 } else {
12524 vec![]
12525 },
12526 )
12527 .unwrap();
12528 }
12529
12530 async fn probe(&mut self) -> crate::router::OutboundFrame {
12531 let frame = wait_for("outbound health probe", || match self.rx.try_recv() {
12532 Ok(frame) => Some(frame),
12533 Err(mpsc::error::TryRecvError::Empty) => None,
12534 Err(mpsc::error::TryRecvError::Disconnected) => panic!("health peer disconnected"),
12535 })
12536 .await;
12537 let body: Value = serde_json::from_slice(&frame.body).unwrap();
12538 assert_eq!(body["op"], MODULE_CONTROL_OP_HEALTH_CHECK);
12539 frame
12540 }
12541
12542 fn no_probe(&mut self) {
12543 assert!(matches!(
12544 self.rx.try_recv(),
12545 Err(mpsc::error::TryRecvError::Empty)
12546 ));
12547 }
12548
12549 fn answer(&self, connection: u64, frame: crate::router::OutboundFrame) {
12550 self.forwarding
12551 .complete_module_control_rpc(
12552 ConnectionId::new(connection),
12553 frame.header.corr,
12554 Some(MODULE_CONTROL_OP_HEALTH_CHECK),
12555 ModuleControlRpcOutcome::Response(ModuleControlResponse::HealthCheck {
12556 status: HealthStatus::Ok,
12557 detail: None,
12558 metrics: None,
12559 }),
12560 )
12561 .unwrap();
12562 }
12563 }
12564
12565 #[tokio::test(start_paused = true)]
12566 async fn no_health_children_park_for_sixty_seconds() {
12567 let none = Actor::start(ModuleProtocol::None, Duration::from_secs(30)).await;
12568 let unregistered = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12569 let mut unadvertised = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12570 unadvertised.hello(1, false);
12571 for _ in 0..6000 {
12576 tokio::time::advance(Duration::from_millis(10)).await;
12577 tokio::task::yield_now().await;
12578 }
12579 for actor in [&none, &unregistered, &unadvertised] {
12580 assert!(
12581 actor.turns() <= 5,
12582 "no-health actor woke {} times",
12583 actor.turns()
12584 );
12585 assert_eq!(actor.module.state().unwrap(), ModuleState::Running);
12586 }
12587 }
12588
12589 #[tokio::test(start_paused = true)]
12590 async fn late_hello_starts_a_due_probe_in_the_notification_tick() {
12591 let mut actor = Actor::start(ModuleProtocol::Subc, Duration::ZERO).await;
12595 let tick = Instant::now();
12596 actor.hello(1, true);
12597 actor.probe().await;
12598 assert_eq!(Instant::now(), tick, "HELLO must not wait for a poll");
12599 }
12600
12601 #[tokio::test(start_paused = true)]
12602 async fn hello_keeps_the_jittered_cadence_and_catalog_updates_do_not_reset_it() {
12603 let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12604 let hello_tick = Instant::now();
12605 actor.hello(1, true);
12606 let armed = actor
12607 .wait_for_select(|checkpoint| {
12608 checkpoint.registered_connection == Some(ConnectionId::new(1))
12609 && checkpoint.next_probe_at.is_some()
12610 })
12611 .await;
12612 let delay = jittered_health_delay(&actor.spec.module_id, 0, actor.health.cadence);
12613 assert!((Duration::from_secs(30)..Duration::from_secs(33)).contains(&delay));
12614 let first_deadline = hello_tick + delay;
12615 assert_eq!(armed.next_probe_at, Some(first_deadline));
12616 tokio::time::advance(Duration::from_secs(29)).await;
12617 let turns = actor.turns();
12618 actor
12619 .registry
12620 .replace_catalog_for_connection(ConnectionId::new(1), vec![], None, Some(true))
12621 .unwrap();
12622 let updated = actor
12623 .wait_for_select(|checkpoint| checkpoint.turn > turns)
12624 .await;
12625 assert_eq!(
12626 updated.next_probe_at,
12627 Some(first_deadline),
12628 "catalog update must not reset cadence"
12629 );
12630 actor.no_probe();
12631 tokio::time::advance(first_deadline - Instant::now()).await;
12632 let frame = actor.probe().await;
12633 actor.answer(1, frame);
12634 let next = jittered_health_delay(&actor.spec.module_id, 1, actor.health.cadence);
12635 let next_deadline = Instant::now() + next;
12636 let rearmed = actor
12637 .wait_for_select(|checkpoint| {
12638 checkpoint
12639 .next_probe_at
12640 .is_some_and(|deadline| deadline > first_deadline)
12641 })
12642 .await;
12643 assert_eq!(rearmed.next_probe_at, Some(next_deadline));
12644 tokio::time::advance(next - Duration::from_nanos(1)).await;
12645 actor.no_probe();
12646 tokio::time::advance(Duration::from_nanos(1)).await;
12647 actor.probe().await;
12648 }
12649
12650 #[tokio::test(start_paused = true)]
12651 async fn disconnect_disarms_and_reconnect_rearms_health() {
12652 let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12653 actor.hello(1, true);
12654 actor
12655 .wait_for_select(|checkpoint| {
12656 checkpoint.registered_connection == Some(ConnectionId::new(1))
12657 && checkpoint.next_probe_at.is_some()
12658 })
12659 .await;
12660 let turns = actor.turns();
12661 let tick = Instant::now();
12662 actor
12663 .registry
12664 .deregister_connection(ConnectionId::new(1))
12665 .unwrap();
12666 let disarmed = actor
12667 .wait_for_select(|checkpoint| {
12668 checkpoint.turn > turns && checkpoint.registered_connection.is_none()
12669 })
12670 .await;
12671 assert_eq!(disarmed.next_probe_at, None);
12672 assert_eq!(disarmed.wake_after, None);
12673 assert_eq!(
12674 Instant::now(),
12675 tick,
12676 "disconnect must disarm in the notification tick"
12677 );
12678 tokio::time::advance(Duration::from_secs(60)).await;
12679 actor.no_probe();
12680 let reconnect_tick = Instant::now();
12681 actor.hello(2, true);
12682 let rearmed = actor
12683 .wait_for_select(|checkpoint| {
12684 checkpoint.registered_connection == Some(ConnectionId::new(2))
12685 && checkpoint.next_probe_at.is_some()
12686 })
12687 .await;
12688 let delay = jittered_health_delay(&actor.spec.module_id, 0, actor.health.cadence);
12689 assert_eq!(rearmed.next_probe_at, Some(reconnect_tick + delay));
12690 tokio::time::advance(delay).await;
12691 actor.probe().await;
12692 }
12693
12694 #[tokio::test(start_paused = true)]
12695 async fn reregistration_without_health_disarms_the_old_schedule() {
12696 let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12697 actor.hello(1, true);
12698 actor
12699 .wait_for_select(|checkpoint| {
12700 checkpoint.registered_connection == Some(ConnectionId::new(1))
12701 && checkpoint.next_probe_at.is_some()
12702 })
12703 .await;
12704 actor
12705 .registry
12706 .deregister_connection(ConnectionId::new(1))
12707 .unwrap();
12708 actor.hello(2, false);
12709 let disarmed = actor
12710 .wait_for_select(|checkpoint| {
12711 checkpoint.registered_connection == Some(ConnectionId::new(2))
12712 })
12713 .await;
12714 assert_eq!(disarmed.next_probe_at, None);
12715 assert_eq!(disarmed.wake_after, None);
12716 tokio::time::advance(Duration::from_secs(60)).await;
12717 actor.no_probe();
12718 }
12719
12720 #[tokio::test(start_paused = true)]
12721 async fn restart_waits_for_its_own_hello_before_rearming_health() {
12722 let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12723 actor.hello(1, true);
12724 let armed = actor
12725 .wait_for_select(|checkpoint| {
12726 checkpoint.registered_connection == Some(ConnectionId::new(1))
12727 && checkpoint.next_probe_at.is_some()
12728 })
12729 .await;
12730 actor
12734 .registry
12735 .deregister_connection(ConnectionId::new(1))
12736 .unwrap();
12737 actor.module.restart(Some(0)).await.unwrap();
12738 let parked = actor
12739 .wait_for_select(|checkpoint| {
12740 checkpoint.generation > armed.generation
12741 && checkpoint.registered_connection.is_none()
12742 && checkpoint.next_probe_at.is_none()
12743 })
12744 .await;
12745 assert_eq!(parked.wake_after, None);
12746 actor.rx.close();
12747 while actor.rx.try_recv().is_ok() {}
12750 let turns = actor.turns();
12751 tokio::time::advance(Duration::from_secs(60)).await;
12752 let after = actor.turns();
12753 eprintln!(
12754 "restart park turns: before={turns} after={after} delta={}",
12755 after - turns
12756 );
12757 assert_eq!(after, turns, "replacement must park before HELLO");
12758 let hello_tick = Instant::now();
12759 actor.hello(2, true);
12760 let rearmed = actor
12761 .wait_for_select(|checkpoint| {
12762 checkpoint.registered_connection == Some(ConnectionId::new(2))
12763 && checkpoint.next_probe_at.is_some()
12764 })
12765 .await;
12766 let delay = jittered_health_delay(&actor.spec.module_id, 0, actor.health.cadence);
12767 assert_eq!(rearmed.next_probe_at, Some(hello_tick + delay));
12768 tokio::time::advance(delay).await;
12769 actor.probe().await;
12770 }
12771
12772 #[tokio::test(start_paused = true)]
12773 async fn rescan_adds_and_removes_http_health_without_polling() {
12774 use tokio::{
12775 io::{AsyncReadExt, AsyncWriteExt},
12776 net::TcpListener,
12777 };
12778 let mut actor = Actor::start(ModuleProtocol::None, Duration::from_secs(30)).await;
12779 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
12780 actor.health.http = Some(format!("http://{}/health", listener.local_addr().unwrap()));
12781 actor.health.cadence = Duration::from_millis(1);
12783 let (started_tx, mut started_rx) = mpsc::channel(8);
12784 let peer = tokio::spawn(async move {
12785 loop {
12786 let (mut socket, _) = listener.accept().await.unwrap();
12787 let mut request = [0; 1024];
12788 assert!(socket.read(&mut request).await.unwrap() > 0);
12789 started_tx.send(()).await.unwrap();
12790 socket
12791 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")
12792 .await
12793 .unwrap();
12794 }
12795 });
12796 let tick = Instant::now();
12797 let turns = actor.turns();
12798 actor
12799 .module
12800 .update_configuration(actor.spec.clone(), actor.health.clone(), None)
12801 .await
12802 .unwrap();
12803 let armed = actor
12804 .wait_for_select(|checkpoint| {
12805 checkpoint.turn > turns && checkpoint.next_probe_at.is_some()
12806 })
12807 .await;
12808 assert_eq!(armed.next_probe_at, Some(tick + Duration::from_millis(1)));
12809 assert_eq!(Instant::now(), tick);
12810 tokio::time::advance(Duration::from_millis(1)).await;
12811 let mut started = false;
12812 wait_for("successful HTTP probe", || {
12813 started |= started_rx.try_recv().is_ok();
12814 (started && actor.module.status().unwrap().health.status == SupervisorHealthStatus::Ok)
12815 .then_some(())
12816 })
12817 .await;
12818 assert!(started, "added HTTP check must start at its first deadline");
12819 assert_eq!(
12820 actor.module.status().unwrap().health.status,
12821 SupervisorHealthStatus::Ok
12822 );
12823 assert_eq!(Instant::now(), tick + Duration::from_millis(1));
12824 actor.health.http = None;
12825 let turns = actor.turns();
12826 actor
12827 .module
12828 .update_configuration(actor.spec.clone(), actor.health.clone(), None)
12829 .await
12830 .unwrap();
12831 let parked = actor
12832 .wait_for_select(|checkpoint| {
12833 checkpoint.turn > turns && checkpoint.next_probe_at.is_none()
12834 })
12835 .await;
12836 assert_eq!(parked.wake_after, None);
12837 let turns = actor.turns();
12838 tokio::time::advance(Duration::from_secs(60)).await;
12839 assert_eq!(
12840 actor.turns(),
12841 turns,
12842 "removed HTTP check must park the actor"
12843 );
12844 assert!(
12845 started_rx.try_recv().is_err(),
12846 "removed HTTP check must stay disarmed"
12847 );
12848 assert_eq!(
12849 actor.module.status().unwrap().health,
12850 ModuleHealthStatus::default()
12851 );
12852 peer.abort();
12853 }
12854}
12855
12856#[cfg(test)]
12857mod health_evidence_tests {
12858 use super::{HealthProbeError, HealthProbeEvidence};
12859 use std::collections::HashSet;
12860
12861 #[test]
12869 fn only_a_dead_lane_is_proof_of_death() {
12870 assert!(HealthProbeError::lane_dead("gone").is_proof_of_death());
12871 assert!(!HealthProbeError::no_answer("timed out").is_proof_of_death());
12875 assert!(!HealthProbeError::bad_answer("garbage").is_proof_of_death());
12876 assert!(!HealthProbeError::misconfigured("no table").is_proof_of_death());
12877 }
12878
12879 #[test]
12885 fn every_evidence_class_has_a_distinct_label() {
12886 let labels = [
12887 HealthProbeError::lane_dead("").label(),
12888 HealthProbeError::no_answer("").label(),
12889 HealthProbeError::bad_answer("").label(),
12890 HealthProbeError::misconfigured("").label(),
12891 ];
12892 let unique: HashSet<_> = labels.iter().collect();
12893 assert_eq!(unique.len(), labels.len(), "labels collided: {labels:?}");
12894 }
12895
12896 #[test]
12902 fn classification_preserves_the_original_message() {
12903 let err = HealthProbeError::no_answer("module did not answer within 5s");
12904 assert_eq!(err.to_string(), "module did not answer within 5s");
12905 assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
12906 }
12907}
12908
12909#[cfg(test)]
12910mod health_tombstone_tests {
12911 use std::{path::PathBuf, sync::Arc, time::Duration};
12912
12913 use subc_protocol::{
12914 manifest::Concurrency,
12915 session::{HealthStatus, ModuleControlResponse},
12916 };
12917 use tokio::sync::mpsc;
12918
12919 use super::{
12920 probe_module_health, HealthAction, HealthConfig, HealthProbeEvidence, ModuleProtocol,
12921 ModuleSpec, RestartPolicy, Supervisor, SupervisorRuntimeConfig,
12922 };
12923 use crate::{
12924 control::ControlHandler,
12925 forwarding::{ForwardingTable, ModuleControlRpcCompletion, ModuleControlRpcOutcome},
12926 registry::{ConnectionId, Registry},
12927 router::FrameSink,
12928 };
12929
12930 struct ProbeHarness {
12931 spec: ModuleSpec,
12932 runtime: SupervisorRuntimeConfig,
12933 forwarding: Arc<ForwardingTable>,
12934 module_connection: ConnectionId,
12935 module_rx: mpsc::Receiver<crate::router::OutboundFrame>,
12936 handler: ControlHandler,
12937 module: super::SupervisedModule,
12938 }
12939
12940 fn probe_harness() -> ProbeHarness {
12941 let registry = Arc::new(Registry::default());
12942 let forwarding = Arc::new(ForwardingTable::default());
12943 let supervisor_handle = super::SupervisorHandle::new();
12944 let health = HealthConfig {
12945 http: None,
12946 cadence: Duration::from_secs(30),
12947 deadline: Duration::from_secs(5),
12948 failure_threshold: 3,
12949 on_degraded: HealthAction::Report,
12950 on_failing: HealthAction::Report,
12951 critical: false,
12952 };
12953 let supervisor = Supervisor::new_for_test(Arc::clone(®istry), RestartPolicy::default())
12954 .with_forwarding(Arc::clone(&forwarding))
12955 .with_handle(supervisor_handle.clone())
12956 .with_health_config(health);
12957 let spec = ModuleSpec {
12958 module_id: "late-health-module".to_string(),
12959 program: PathBuf::from("disabled-module"),
12960 args: Vec::new(),
12961 env: Vec::new(),
12962 reserved: false,
12963 reserved_prefixes: Vec::new(),
12964 protocol: ModuleProtocol::Subc,
12965 overlap: Default::default(),
12966 };
12967 let module = supervisor
12968 .supervise_configured(spec.clone(), false)
12969 .unwrap();
12970 let runtime = supervisor.runtime_config();
12971 let handler = ControlHandler::with_forwarding(registry, Arc::clone(&forwarding))
12972 .with_supervisor(supervisor_handle);
12973 let module_connection = ConnectionId::new(700);
12974 let (module_tx, module_rx) = mpsc::channel(8);
12975 forwarding
12976 .register_module_connection(
12977 module_connection,
12978 spec.module_id.clone(),
12979 subc_protocol::PROTOCOL_VERSION,
12980 Concurrency::ModuleManaged,
12981 FrameSink::new(module_tx),
12982 )
12983 .unwrap();
12984
12985 ProbeHarness {
12986 spec,
12987 runtime,
12988 forwarding,
12989 module_connection,
12990 module_rx,
12991 handler,
12992 module,
12993 }
12994 }
12995
12996 async fn finish_after(
12997 harness: &mut ProbeHarness,
12998 stall: Duration,
12999 ) -> ModuleControlRpcCompletion {
13000 assert!(stall > harness.runtime.health.deadline);
13001 let deadline = harness.runtime.health.deadline;
13002 let probe = probe_module_health(&harness.spec.module_id, &harness.runtime, None);
13003 let answer = async {
13004 let frame = harness.module_rx.recv().await.expect("health.check frame");
13005 tokio::time::advance(deadline).await;
13006 tokio::task::yield_now().await;
13007 tokio::time::advance(stall - deadline).await;
13008 harness
13009 .forwarding
13010 .complete_module_control_rpc(
13011 harness.module_connection,
13012 frame.header.corr,
13013 Some("health.check"),
13014 ModuleControlRpcOutcome::Response(ModuleControlResponse::HealthCheck {
13015 status: HealthStatus::Ok,
13016 detail: None,
13017 metrics: None,
13018 }),
13019 )
13020 .unwrap()
13021 };
13022 let (probe_result, completion) = tokio::join!(probe, answer);
13023 let err = probe_result.expect_err("probe must miss its deadline");
13024 assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
13025 completion
13026 }
13027
13028 async fn time_out_without_answer(harness: &mut ProbeHarness) {
13029 let deadline = harness.runtime.health.deadline;
13030 let probe = probe_module_health(&harness.spec.module_id, &harness.runtime, None);
13031 let exhaust_deadline = async {
13032 let _frame = harness.module_rx.recv().await.expect("health.check frame");
13033 tokio::time::advance(deadline).await;
13034 tokio::task::yield_now().await;
13035 };
13036 let (probe_result, ()) = tokio::join!(probe, exhaust_deadline);
13037 let err = probe_result.expect_err("probe must miss its deadline");
13038 assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
13039 }
13040
13041 async fn run_probe_cycle(harness: &mut ProbeHarness, answer: bool) {
13042 let registry = Arc::clone(&harness.module.inner.registry);
13043 let snapshot = Arc::clone(&harness.module.inner.snapshot);
13044 let process_liveness = super::SupervisorProcessLiveness::default();
13045 let mut child = None;
13046 let cycle = super::run_health_probe_cycle(
13047 &harness.spec,
13048 &harness.runtime,
13049 ®istry,
13050 &process_liveness,
13051 &snapshot,
13052 &mut child,
13053 );
13054 let peer = async {
13055 let frame = harness.module_rx.recv().await.expect("health.check frame");
13056 if answer {
13057 harness
13058 .forwarding
13059 .complete_module_control_rpc(
13060 harness.module_connection,
13061 frame.header.corr,
13062 Some("health.check"),
13063 ModuleControlRpcOutcome::Response(ModuleControlResponse::HealthCheck {
13064 status: HealthStatus::Ok,
13065 detail: None,
13066 metrics: Some(serde_json::json!({"ready": true})),
13067 }),
13068 )
13069 .unwrap();
13070 } else {
13071 tokio::time::advance(harness.runtime.health.deadline).await;
13072 tokio::task::yield_now().await;
13073 }
13074 };
13075 tokio::join!(cycle, peer);
13076 }
13077
13078 #[tokio::test(start_paused = true)]
13079 async fn unanswered_probe_is_unknown_until_threshold_and_ok_report_recovers() {
13080 let mut harness = probe_harness();
13081 let monitor = harness.module.inner.monitor.lock().unwrap().take().unwrap();
13085 monitor.abort();
13086 let _ = monitor.await;
13087 super::update_snapshot(&harness.module.inner.snapshot, None, |state| {
13088 state.enabled = true;
13089 state.state = super::ModuleState::Running;
13090 state.process_alive = true;
13091 })
13092 .unwrap();
13093
13094 run_probe_cycle(&mut harness, true).await;
13095 assert_eq!(
13096 harness.module.status().unwrap().health.status,
13097 super::SupervisorHealthStatus::Ok
13098 );
13099
13100 for failures in 1..harness.runtime.health.failure_threshold {
13101 run_probe_cycle(&mut harness, false).await;
13102 let status = harness.module.status().unwrap();
13103 assert_eq!(status.health.status, super::SupervisorHealthStatus::Unknown);
13104 assert_eq!(status.health.consecutive_failures, failures);
13105 assert!(status.health.last_probe_ms.is_some());
13106 assert!(status.health.detail.unwrap().starts_with("[no-answer]"));
13107 assert!(status.health.metrics.is_none());
13108 assert_eq!(status.state, super::ModuleState::Running);
13109 assert!(status.process_alive);
13110 assert_eq!(status.restart_count, 0);
13111 assert_eq!(status.lifetime_restarts, 0);
13112 assert!(status.health.last_action.is_none());
13113 assert!(harness.runtime.scheduled_respawn.lock().unwrap().is_none());
13114 }
13115
13116 run_probe_cycle(&mut harness, true).await;
13117 let recovered = harness.module.status().unwrap();
13118 assert_eq!(recovered.health.status, super::SupervisorHealthStatus::Ok);
13119 assert_eq!(recovered.health.consecutive_failures, 0);
13120 assert!(recovered.health.detail.is_none());
13121 assert_eq!(
13122 recovered.health.metrics,
13123 Some(serde_json::json!({"ready": true}))
13124 );
13125 assert_eq!(recovered.lifetime_restarts, 0);
13126
13127 for failures in 1..=harness.runtime.health.failure_threshold {
13128 run_probe_cycle(&mut harness, false).await;
13129 let status = harness.module.status().unwrap();
13130 assert_eq!(status.health.consecutive_failures, failures);
13131 if failures < harness.runtime.health.failure_threshold {
13132 assert_eq!(status.health.status, super::SupervisorHealthStatus::Unknown);
13133 assert_eq!(status.state, super::ModuleState::Running);
13134 assert_eq!(status.lifetime_restarts, 0);
13135 assert!(harness.runtime.scheduled_respawn.lock().unwrap().is_none());
13136 } else {
13137 assert_eq!(
13138 status.health.status,
13139 super::SupervisorHealthStatus::Unresponsive
13140 );
13141 assert_eq!(status.state, super::ModuleState::Restarting);
13142 assert_eq!(status.restart_count, 1);
13143 assert_eq!(status.lifetime_restarts, 1);
13144 assert_eq!(status.health.last_action.as_deref(), Some("restart"));
13145 assert!(harness.runtime.scheduled_respawn.lock().unwrap().is_some());
13146 }
13147 }
13148 }
13149
13150 #[tokio::test(start_paused = true)]
13151 async fn late_health_answers_record_start_anchored_latency_for_two_stalls() {
13152 let mut harness = probe_harness();
13153
13154 let first = finish_after(&mut harness, Duration::from_secs(8)).await;
13155 let first_latency = match &first {
13156 ModuleControlRpcCompletion::LateHealthAnswer { latency, .. } => *latency,
13157 other => panic!("late answer was not retained: {other:?}"),
13158 };
13159 assert!(harness.handler.observe_module_control_completion(first));
13160
13161 let second = finish_after(&mut harness, Duration::from_secs(11)).await;
13162 let second_latency = match &second {
13163 ModuleControlRpcCompletion::LateHealthAnswer { latency, .. } => *latency,
13164 other => panic!("late answer was not retained: {other:?}"),
13165 };
13166 assert!(harness.handler.observe_module_control_completion(second));
13167
13168 assert_eq!(first_latency, Duration::from_secs(8));
13169 assert_eq!(
13170 second_latency - first_latency,
13171 Duration::from_secs(3),
13172 "latency must grow linearly with the additional stall"
13173 );
13174 let health = harness.module.status().unwrap().health;
13175 assert_eq!(health.late_answer_count, 2);
13176 assert_eq!(health.last_late_answer_latency_ms, Some(11_000));
13177 }
13178
13179 #[tokio::test(start_paused = true)]
13187 async fn late_answer_clears_the_consecutive_failure_streak() {
13188 let mut harness = probe_harness();
13189
13190 time_out_without_answer(&mut harness).await;
13192 harness
13193 .module
13194 .record_health_probe_failure_for_test("[no-answer] test miss")
13195 .unwrap();
13196 assert_eq!(
13197 harness.module.status().unwrap().health.consecutive_failures,
13198 1,
13199 "precondition: the miss must be on the streak before the late answer"
13200 );
13201
13202 let late = finish_after(&mut harness, Duration::from_secs(9)).await;
13204 assert!(matches!(
13205 late,
13206 ModuleControlRpcCompletion::LateHealthAnswer { .. }
13207 ));
13208 assert!(harness.handler.observe_module_control_completion(late));
13209
13210 let health = harness.module.status().unwrap().health;
13211 assert_eq!(
13212 health.consecutive_failures, 0,
13213 "a late answer is an answer: the streak must reset"
13214 );
13215 assert_eq!(health.late_answer_count, 1);
13216 }
13217
13218 #[tokio::test(start_paused = true)]
13219 async fn repeated_serial_probe_cycles_keep_one_tombstone_per_endpoint() {
13220 let mut harness = probe_harness();
13221
13222 for _ in 0..20 {
13223 time_out_without_answer(&mut harness).await;
13224 assert_eq!(
13225 harness.forwarding.health_probe_tombstone_count().unwrap(),
13226 1
13227 );
13228 }
13229 }
13230}
13231
13232#[cfg(test)]
13233mod child_env_tests {
13234 #[cfg(windows)]
13235 use super::{
13236 clear_current_process_facts, observed_launch_nonce_source, ModuleState, SupervisorSnapshot,
13237 };
13238 #[cfg(windows)]
13239 use std::sync::{Arc, OnceLock};
13240 #[cfg(windows)]
13241 #[test]
13242 fn windows_wire_spawn_prepares_pipe_and_keeps_phase_one_env() {
13243 let spec = spec(Vec::new());
13244 let mut command = Command::new("unused");
13245 let handoff = apply_wire_spawn_args(&mut command, &spec, None, None)
13246 .unwrap()
13247 .unwrap();
13248 let name = command
13249 .as_std()
13250 .get_envs()
13251 .find(|(key, _)| *key == OsStr::new(subc_os::launch_nonce::LAUNCH_NONCE_PIPE_ENV))
13252 .and_then(|(_, value)| value)
13253 .unwrap();
13254 assert_eq!(name, OsStr::new(handoff.name()));
13255 assert!(handoff.name().starts_with(r"\\.\pipe\subc-launch-"));
13256 assert!(command.as_std().get_envs().any(|(key, value)| key
13257 == OsStr::new(subc_os::LAUNCH_NONCE_PIPE_FALLBACK_ENV)
13258 && value == Some(OsStr::new("env"))));
13259 assert!(command
13260 .as_std()
13261 .get_envs()
13262 .any(|(key, value)| key == OsStr::new(SUBC_LAUNCH_NONCE_ENV) && value.is_some()));
13263 }
13264
13265 #[cfg(windows)]
13266 #[test]
13267 fn windows_source_reporting_tracks_consumption_and_resets_per_spawn() {
13268 let mut snapshot = SupervisorSnapshot::new(ModuleState::Running, true);
13269 snapshot.process_alive = true;
13270 snapshot.spawned_protocol = Some(ModuleProtocol::Subc);
13271 assert_eq!(
13272 observed_launch_nonce_source(&snapshot).as_deref(),
13273 Some("env")
13274 );
13275 let consumed = Arc::new(OnceLock::new());
13276 snapshot.pipe_consumed = Some(consumed.clone());
13277 assert_eq!(
13278 observed_launch_nonce_source(&snapshot).as_deref(),
13279 Some("env")
13280 );
13281 consumed.set(()).unwrap();
13282 assert_eq!(
13283 observed_launch_nonce_source(&snapshot).as_deref(),
13284 Some("pipe")
13285 );
13286 snapshot.pipe_consumed = Some(Arc::new(OnceLock::new()));
13287 assert_eq!(
13288 observed_launch_nonce_source(&snapshot).as_deref(),
13289 Some("env")
13290 );
13291 clear_current_process_facts(&mut snapshot);
13292 assert_eq!(observed_launch_nonce_source(&snapshot), None);
13293 }
13294 use super::{
13295 apply_child_env, apply_spawn_role, apply_wire_spawn_args, ModuleProtocol, ModuleSpec,
13296 SpawnRole, SupervisorHandle, SPAWN_ROLE_SWAP_CANDIDATE, SUBC_ARG, SUBC_LAUNCH_NONCE_ENV,
13297 SUBC_MODULE_ID_ENV, SUBC_SPAWN_ROLE_ENV,
13298 };
13299 use std::{ffi::OsStr, path::PathBuf};
13300 use tokio::process::Command;
13301
13302 fn spec(env: Vec<(String, String)>) -> ModuleSpec {
13303 ModuleSpec {
13304 module_id: "env-plan".to_string(),
13305 program: PathBuf::from("/nonexistent"),
13306 args: Vec::new(),
13307 env,
13308 reserved: false,
13309 reserved_prefixes: Vec::new(),
13310 protocol: ModuleProtocol::Subc,
13311 overlap: Default::default(),
13312 }
13313 }
13314
13315 #[test]
13329 fn ambient_ck_log_is_removed_and_a_configured_one_survives() {
13330 let mut command = Command::new("/nonexistent");
13331 apply_child_env(&mut command, &spec(Vec::new()));
13332 let removed = command
13333 .as_std()
13334 .get_envs()
13335 .any(|(key, value)| key == OsStr::new("CK_LOG") && value.is_none());
13336 assert!(
13337 removed,
13338 "ambient CK_LOG must be explicitly removed for an unconfigured module"
13339 );
13340
13341 let mut configured = Command::new("/nonexistent");
13342 apply_child_env(
13343 &mut configured,
13344 &spec(vec![("CK_LOG".to_string(), "debug".to_string())]),
13345 );
13346 let effective = configured
13347 .as_std()
13348 .get_envs()
13349 .filter(|(key, _)| *key == OsStr::new("CK_LOG"))
13350 .last()
13351 .map(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()));
13352 assert_eq!(
13353 effective,
13354 Some(Some("debug".to_string())),
13355 "a module's configured CK_LOG must survive the ambient removal"
13356 );
13357 }
13358
13359 #[test]
13368 fn protocol_none_spawn_carries_no_subc_argument_and_no_nonce() {
13369 let connection_file = std::path::Path::new("/run/subc-connection.json");
13370 let handle = SupervisorHandle::new();
13371
13372 let mut none_spec = spec(Vec::new());
13373 none_spec.protocol = ModuleProtocol::None;
13374 let mut none = Command::new("/nonexistent");
13375 let none_handoff =
13376 apply_wire_spawn_args(&mut none, &none_spec, Some(connection_file), Some(&handle))
13377 .expect("protocol-none spawn args apply");
13378 assert!(
13379 none_handoff.is_none(),
13380 "protocol:none spawn must not receive a nonce descriptor"
13381 );
13382 #[cfg(windows)]
13383 assert!(!none.as_std().get_envs().any(|(key, value)| key
13384 == OsStr::new(subc_os::LAUNCH_NONCE_PIPE_FALLBACK_ENV)
13385 && value.is_some()));
13386 assert!(
13387 !none.as_std().get_envs().any(|(key, value)| key
13388 == OsStr::new(subc_os::LAUNCH_NONCE_FD_ENV)
13389 && value.is_some()),
13390 "protocol:none spawn must not name a nonce descriptor"
13391 );
13392 let none_args: Vec<String> = none
13393 .as_std()
13394 .get_args()
13395 .map(|a| a.to_string_lossy().into_owned())
13396 .collect();
13397 assert!(
13398 !none_args.iter().any(|a| a == SUBC_ARG),
13399 "protocol:none argv must not carry --subc; got {none_args:?}"
13400 );
13401 let none_has_nonce = none
13402 .as_std()
13403 .get_envs()
13404 .any(|(key, value)| key == OsStr::new(SUBC_LAUNCH_NONCE_ENV) && value.is_some());
13405 assert!(
13406 !none_has_nonce,
13407 "protocol:none spawn must not receive a launch nonce"
13408 );
13409 let none_has_module_id = none
13410 .as_std()
13411 .get_envs()
13412 .any(|(key, value)| key == OsStr::new(SUBC_MODULE_ID_ENV) && value.is_some());
13413 assert!(
13414 none_has_module_id,
13415 "SUBC_MODULE_ID is inert and stays on every path"
13416 );
13417 assert!(
13418 handle.spawn_nonce(&none_spec.module_id).is_none(),
13419 "no nonce record for a process that will never present one"
13420 );
13421
13422 let wire_spec = spec(Vec::new());
13424 let mut wire = Command::new("/nonexistent");
13425 let wire_handoff =
13426 apply_wire_spawn_args(&mut wire, &wire_spec, Some(connection_file), Some(&handle))
13427 .expect("subc-wire spawn args apply");
13428 let wire_fd_env = wire
13429 .as_std()
13430 .get_envs()
13431 .find(|(key, _)| *key == OsStr::new(subc_os::LAUNCH_NONCE_FD_ENV))
13432 .and_then(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()));
13433 #[cfg(unix)]
13434 assert_eq!(
13435 wire_fd_env,
13436 Some(wire_handoff.expect("a descriptor handoff").fd_env_value()),
13437 "a subc-wire spawn names the pipe it will receive at descriptor 3"
13438 );
13439 #[cfg(windows)]
13440 assert!(wire_handoff.is_some() && wire_fd_env.is_none());
13441 #[cfg(not(any(unix, windows)))]
13442 assert!(wire_handoff.is_none() && wire_fd_env.is_none());
13443 let wire_args: Vec<String> = wire
13444 .as_std()
13445 .get_args()
13446 .map(|a| a.to_string_lossy().into_owned())
13447 .collect();
13448 assert_eq!(
13449 wire_args,
13450 vec![
13451 SUBC_ARG.to_string(),
13452 connection_file.to_string_lossy().into_owned()
13453 ],
13454 "a subc-wire spawn still carries --subc <path>"
13455 );
13456 assert_eq!(
13457 wire.as_std()
13458 .get_envs()
13459 .any(|(key, value)| key == OsStr::new(SUBC_LAUNCH_NONCE_ENV) && value.is_some()),
13460 !cfg!(unix),
13461 "only Windows supplies the environment nonce"
13462 );
13463 assert!(handle.spawn_nonce(&wire_spec.module_id).is_some());
13464 }
13465
13466 #[test]
13476 fn plain_spawn_removes_the_spawn_role_even_when_the_spec_sets_it() {
13477 let role = |command: &Command| {
13478 command
13479 .as_std()
13480 .get_envs()
13481 .filter(|(key, _)| *key == OsStr::new(SUBC_SPAWN_ROLE_ENV))
13482 .last()
13483 .map(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()))
13484 };
13485 let forged = spec(vec![(
13486 SUBC_SPAWN_ROLE_ENV.to_string(),
13487 SPAWN_ROLE_SWAP_CANDIDATE.to_string(),
13488 )]);
13489
13490 let mut plain = Command::new("/nonexistent");
13491 apply_child_env(&mut plain, &forged);
13492 apply_spawn_role(&mut plain, SpawnRole::Plain);
13493 assert_eq!(
13494 role(&plain),
13495 Some(None),
13496 "a plain spawn must remove SUBC_SPAWN_ROLE, whatever the spec says"
13497 );
13498
13499 let mut candidate = Command::new("/nonexistent");
13500 apply_child_env(&mut candidate, &spec(Vec::new()));
13501 apply_spawn_role(&mut candidate, SpawnRole::SwapCandidate);
13502 assert_eq!(
13503 role(&candidate),
13504 Some(Some(SPAWN_ROLE_SWAP_CANDIDATE.to_string()))
13505 );
13506 }
13507
13508 #[test]
13514 fn daemon_private_capture_keys_are_not_passed_to_the_child() {
13515 let mut command = Command::new("/nonexistent");
13516 apply_child_env(
13517 &mut command,
13518 &spec(vec![
13519 (super::CAPTURE_KEEP_ENV.to_string(), "5".to_string()),
13520 ("KEPT".to_string(), "yes".to_string()),
13521 ]),
13522 );
13523 let keys: Vec<String> = command
13524 .as_std()
13525 .get_envs()
13526 .filter(|(_, value)| value.is_some())
13527 .map(|(key, _)| key.to_string_lossy().into_owned())
13528 .collect();
13529 assert!(keys.contains(&"KEPT".to_string()), "got {keys:?}");
13530 assert!(
13531 !keys.contains(&super::CAPTURE_KEEP_ENV.to_string()),
13532 "daemon-private capture key leaked to the child: {keys:?}"
13533 );
13534 }
13535}
13536
13537#[cfg(test)]
13538mod jitter_tests {
13539 use super::jittered_health_delay;
13540 use std::{collections::HashSet, time::Duration};
13541
13542 const FLEET: [&str; 14] = [
13551 "aft",
13552 "alfonso-core",
13553 "magic-context",
13554 "broca",
13555 "thalamus",
13556 "quota",
13557 "engram",
13558 "plexus",
13559 "cerebellum",
13560 "astrocyte",
13561 "synapse",
13562 "subc-mcp",
13563 "cortexkit-credentials",
13564 "subc-federation",
13565 ];
13566
13567 #[test]
13575 fn probe_delays_disperse_across_the_fleet() {
13576 let cadence = Duration::from_secs(30);
13577 let delays: HashSet<Duration> = FLEET
13578 .iter()
13579 .map(|id| jittered_health_delay(id, 0, cadence))
13580 .collect();
13581 assert_eq!(
13582 delays.len(),
13583 FLEET.len(),
13584 "every supervised module must land on its own probe offset"
13585 );
13586 }
13587
13588 #[test]
13594 fn jitter_only_delays_and_stays_within_one_tenth_of_cadence() {
13595 let cadence = Duration::from_secs(30);
13596 let span = cadence / 10;
13597 for id in FLEET {
13598 for probe_index in 0..8 {
13599 let delay = jittered_health_delay(id, probe_index, cadence);
13600 assert!(
13601 delay >= cadence,
13602 "{id}#{probe_index}: jitter must not shorten the cadence"
13603 );
13604 assert!(
13605 delay < cadence + span,
13606 "{id}#{probe_index}: jitter must stay inside one tenth of the cadence"
13607 );
13608 }
13609 }
13610 }
13611
13612 #[test]
13618 fn a_module_offset_is_stable_across_restarts() {
13619 let cadence = Duration::from_secs(30);
13620 for id in FLEET {
13621 assert_eq!(
13622 jittered_health_delay(id, 0, cadence),
13623 jittered_health_delay(id, 0, cadence),
13624 "{id}: the same module and probe index must produce the same offset"
13625 );
13626 }
13627 }
13628
13629 #[test]
13631 fn zero_cadence_yields_zero_delay() {
13632 assert_eq!(
13633 jittered_health_delay("aft", 0, Duration::ZERO),
13634 Duration::ZERO
13635 );
13636 }
13637}
13638
13639#[cfg(all(test, target_os = "linux"))]
13640mod cgroup_placement_tests {
13641 use super::{
13642 apply_cgroup_placement, remove_module_cgroup, ModuleProtocol, ModuleSpec, SuperviseError,
13643 SupervisedChild,
13644 };
13645 use crate::stderr_tail::{StderrRing, StderrTailConfig};
13646 use cortexkit_test_support::ScratchDir;
13647 use std::{
13648 fs, io,
13649 path::{Path, PathBuf},
13650 sync::{Arc, Mutex},
13651 };
13652 use tokio::process::Command;
13653
13654 #[tokio::test]
13655 async fn unique_spawn_cgroups_clean_up_across_restarts_and_shutdown() {
13656 use super::*;
13657 let dir = ScratchDir::new("unique-spawn-cgroups");
13658 let root = PathBuf::from(format!(
13659 "/sys/fs/cgroup/subc-unique-test-{}-{}",
13660 std::process::id(),
13661 unix_ms_now()
13662 ));
13663 if let Err(error) = fs::create_dir(&root) {
13664 assert!(
13665 std::env::var_os("SUBC_REQUIRE_CGROUP_TEST").is_none(),
13666 "required cgroup test cannot execute: {error}"
13667 );
13668 eprintln!("SKIP unique_spawn_cgroups_clean_up_across_restarts_and_shutdown: {error}");
13669 return;
13670 }
13671 let placement = subc_cgroup::prepare_at(&root).unwrap().unwrap();
13672 let group_count = || {
13673 fs::read_dir(root.join("subc-modules"))
13674 .unwrap()
13675 .map(|entry| entry.unwrap().file_type().unwrap())
13676 .filter(|kind| kind.is_dir())
13677 .count()
13678 };
13679 let supervisor =
13680 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
13681 .with_cgroup_placement(Some(placement.clone()));
13682 let runtime = supervisor.runtime_config();
13683 let mut spec = ModuleSpec {
13684 module_id: "unique-spawn".into(),
13685 program: PathBuf::from("/bin/sleep"),
13686 args: vec!["60".into()],
13687 env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
13688 .into_iter()
13689 .map(|key| (key.into(), dir.to_string_lossy().into_owned()))
13690 .collect(),
13691 reserved: false,
13692 reserved_prefixes: vec![],
13693 protocol: ModuleProtocol::None,
13694 overlap: Default::default(),
13695 };
13696 let spawn = |spec: &ModuleSpec| {
13697 spawn_child(
13698 spec,
13699 None,
13700 None,
13701 &runtime.stderr_ring,
13702 None,
13703 &runtime.child_roster,
13704 Some(&placement),
13705 )
13706 .unwrap()
13707 };
13708 let mut live = spawn(&spec);
13709 for _ in 0..3 {
13710 let next = spawn(&spec);
13712 assert_ne!(live.module_id, next.module_id);
13713 live.start_kill().unwrap();
13714 live.wait().await.unwrap();
13715 live = next;
13716 assert!(
13717 live.child.try_wait().unwrap().is_none(),
13718 "retiring the old slot must not kill the replacement"
13719 );
13720 assert_eq!(
13721 group_count(),
13722 1,
13723 "only the live spawn's cgroup should remain"
13724 );
13725 }
13726 supervisor.begin_daemon_shutdown();
13727 let reap = tokio::spawn(async move {
13728 live.wait().await.unwrap();
13729 });
13730 supervisor
13731 .end_children_for_daemon_shutdown(false, std::future::pending())
13732 .await;
13733 reap.await.unwrap();
13734 assert_eq!(group_count(), 0);
13735 spec.program = PathBuf::from("/bin/true");
13737 spec.args.clear();
13738 let fresh_roster = ChildRoster::default();
13739 let mut short = spawn_child(
13740 &spec,
13741 None,
13742 None,
13743 &runtime.stderr_ring,
13744 None,
13745 &fresh_roster,
13746 Some(&placement),
13747 )
13748 .unwrap();
13749 short.wait().await.unwrap();
13750 assert_eq!(group_count(), 0);
13751 spec.module_id = "_".repeat(255);
13752 let mut long_id = spawn_child(
13753 &spec,
13754 None,
13755 None,
13756 &runtime.stderr_ring,
13757 None,
13758 &fresh_roster,
13759 Some(&placement),
13760 )
13761 .unwrap();
13762 long_id.wait().await.unwrap();
13763 assert_eq!(
13764 group_count(),
13765 0,
13766 "valid long module IDs must not exceed cgroup NAME_MAX"
13767 );
13768 fs::remove_dir(root.join("subc-modules")).unwrap();
13769 fs::remove_dir(root).unwrap();
13770 eprintln!("EXECUTED unique_spawn_cgroups_clean_up_across_restarts_and_shutdown");
13771 }
13772
13773 #[test]
13774 fn failed_parent_cgroup_open_is_a_cgroup_supervision_error() {
13775 let path = Path::new("/definitely-missing-subc-cgroup");
13776 let mut command = Command::new("true");
13777 let error = apply_cgroup_placement(
13778 &mut command,
13779 &ModuleSpec {
13780 module_id: "broken-cgroup".to_string(),
13781 program: PathBuf::from("true"),
13782 args: Vec::new(),
13783 env: Vec::new(),
13784 reserved: false,
13785 reserved_prefixes: Vec::new(),
13786 protocol: ModuleProtocol::Subc,
13787 overlap: Default::default(),
13788 },
13789 path,
13790 )
13791 .expect_err("a parent cgroup open failure must reject the supervised spawn");
13792 let reason = error.to_string();
13793
13794 assert!(
13795 matches!(error, SuperviseError::Cgroup { .. }),
13796 "parent cgroup open must be reported as a cgroup supervision error: {reason}"
13797 );
13798 assert!(
13799 reason.contains("/definitely-missing-subc-cgroup/cgroup.procs"),
13800 "parent cgroup open failure must name cgroup.procs: {reason}"
13801 );
13802 }
13803
13804 #[tokio::test]
13805 async fn reaping_a_child_removes_its_empty_module_cgroup() {
13806 let root = ScratchDir::new("supervisor-reap-cgroup");
13807 fs::write(root.join("cgroup.procs"), b"").expect("write scratch cgroup marker");
13808 let placement = subc_cgroup::prepare_at(&root)
13809 .expect("prepare scratch cgroup root")
13810 .expect("scratch root has a cgroup.procs marker");
13811 let module_id = "reaped-module";
13812 let module = placement
13813 .module_path(module_id)
13814 .expect("create scratch module cgroup");
13815 let child = Command::new("true")
13816 .env("XDG_DATA_HOME", root.path())
13817 .env("XDG_RUNTIME_DIR", root.path())
13818 .env("XDG_CONFIG_HOME", root.path())
13819 .spawn()
13820 .expect("spawn short-lived child");
13821 let pid = child.id().expect("spawned child has pid");
13822 let mut child = SupervisedChild {
13823 child,
13824 protocol: ModuleProtocol::Subc,
13825 module_id: module_id.to_string(),
13826 cgroup_placement: Some(placement),
13827 stdout_pump: None,
13828 stderr_pump: None,
13829 stderr_ring: Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default()))),
13830 spawned_at_ms: 0,
13831 spawned_from: PathBuf::from("true"),
13832 spawned_file_identity: None,
13833 process_start_time: None,
13834 process_identity: None,
13835 pid,
13836 roster_guard: None,
13837 #[cfg(target_os = "macos")]
13838 privacy_exec: None,
13839 spawn_failure: None,
13840 };
13841
13842 child.wait().await.expect("reap short-lived child");
13843
13844 assert!(
13845 !module.exists(),
13846 "reaping the supervised child must remove its empty cgroup"
13847 );
13848 }
13849
13850 #[test]
13851 fn non_empty_cgroup_removal_is_reported_without_blocking_teardown() {
13852 let root = ScratchDir::new("supervisor-non-empty-cgroup");
13853 fs::write(root.join("cgroup.procs"), b"").expect("write scratch cgroup marker");
13854 let placement = subc_cgroup::prepare_at(&root)
13855 .expect("prepare scratch cgroup root")
13856 .expect("scratch root has a cgroup.procs marker");
13857 let module = placement
13858 .module_path("surviving-module")
13859 .expect("create scratch module cgroup");
13860 fs::write(module.join("surviving-process"), b"still present")
13861 .expect("make scratch cgroup non-empty");
13862 let (logs, _guard) = crate::router::test_log::log_capture(tracing::Level::WARN);
13863
13864 remove_module_cgroup(&placement, "surviving-module");
13865
13866 let logs = crate::router::test_log::captured_logs(&logs);
13867 assert!(
13868 module.exists(),
13869 "failed removal must leave the cgroup intact"
13870 );
13871 assert!(
13872 logs.contains("could not remove module cgroup after process exit; continuing teardown")
13873 && logs.contains("surviving-module"),
13874 "best-effort removal must report the failure without returning it: {logs}"
13875 );
13876 }
13877
13878 #[test]
13879 fn cgroup_pre_exec_spawn_failure_names_the_cgroup_path() {
13880 let cgroup_path = PathBuf::from("/sys/fs/cgroup/subc-modules/broken-module");
13881 let reason = SuperviseError::Spawn {
13882 program: PathBuf::from("/bin/true"),
13883 source: io::Error::from_raw_os_error(13),
13884 cgroup_path: Some(cgroup_path.clone()),
13885 }
13886 .to_string();
13887
13888 assert!(
13889 reason.contains(&cgroup_path.display().to_string()),
13890 "a pre_exec spawn failure must name the cgroup path: {reason}"
13891 );
13892 }
13893}
13894
13895#[cfg(test)]
13896mod spawn_subscriber_lag_tests {
13897 use super::*;
13898
13899 #[tokio::test]
13904 async fn lagged_spawn_subscriber_receives_a_terminal_lagged_error_after_its_queued_frames() {
13905 let feed = SpawnEventFeed::default();
13906 feed.configure_incarnation("lag-incarnation".to_string());
13907 let (tx, mut rx) = mpsc::channel(1);
13910 feed.subscribe(ConnectionId::new(1), 7, 1, None, FrameSink::new(tx))
13911 .expect("subscribe");
13912 let emitted = SPAWN_SUBSCRIBER_BUFFER + 16;
13913 for index in 0..emitted {
13914 feed.emit_spawned(&format!("lag-module-{index}"), 1000, 0);
13915 tokio::task::yield_now().await;
13918 }
13919 assert_eq!(
13920 feed.subscriber_count(),
13921 0,
13922 "the lagged subscriber must be removed"
13923 );
13924
13925 let mut data = Vec::new();
13926 let mut last = None;
13927 loop {
13928 let next = tokio::time::timeout(Duration::from_secs(5), rx.recv())
13929 .await
13930 .expect("the forwarder must finish once the subscriber is dropped");
13931 let Some(outbound) = next else { break };
13932 let frame = outbound.frame;
13933 if frame.header.ty == FrameType::StreamData {
13934 assert!(last.is_none(), "no data may follow the terminal frame");
13935 let event: SpawnEvent = serde_json::from_slice(&frame.body).unwrap();
13936 data.push(event.cursor.seq);
13937 } else {
13938 assert!(last.is_none(), "exactly one terminal frame");
13939 last = Some(frame);
13940 }
13941 }
13942 assert!(!data.is_empty(), "queued frames drain before the terminal");
13943 for pair in data.windows(2) {
13944 assert_eq!(
13945 pair[1],
13946 pair[0] + 1,
13947 "queued frames arrive dense and in order"
13948 );
13949 }
13950 let terminal = last.expect("a lagged subscriber must receive a terminal frame");
13951 assert_eq!(terminal.header.ty, FrameType::Error);
13952 assert_eq!(terminal.header.corr, 7);
13953 let body: subc_protocol::ErrorBody = serde_json::from_slice(&terminal.body).unwrap();
13954 assert_eq!(body.code, SPAWN_SUBSCRIBER_LAGGED_CODE);
13955 let detail = body.detail.expect("lagged error carries detail");
13956 assert_eq!(
13957 detail["first_undelivered_cursor"]["seq"],
13958 data.last().unwrap() + 1,
13959 "the named cursor is the first event the subscriber did not receive"
13960 );
13961 assert_eq!(
13962 detail["first_undelivered_cursor"]["daemon_incarnation"],
13963 "lag-incarnation"
13964 );
13965 }
13966}
13967
13968#[cfg(test)]
13969mod terminal_history_read_concurrency_tests {
13970 use super::*;
13971 use crate::terminal_journal::read_pause;
13972 use cortexkit_test_support::ScratchDir;
13973 use std::sync::mpsc as std_mpsc;
13974
13975 fn journaled_ring(
13976 journal: &Arc<crate::terminal_journal::TerminalJournal>,
13977 ) -> Arc<Mutex<TerminalRing>> {
13978 Arc::new(Mutex::new(
13979 TerminalRing::new(TerminalRingConfig::default(), 1)
13980 .with_journal(Some(Arc::clone(journal))),
13981 ))
13982 }
13983
13984 fn crash(at_ms: u64) -> ExitReport {
13985 ExitReport {
13986 kind: ExitKind::Crash,
13987 code: Some(1),
13988 signal: None,
13989 at_ms,
13990 }
13991 }
13992
13993 fn record_within(
13996 module_id: &'static str,
13997 ring: &Arc<Mutex<TerminalRing>>,
13998 at_ms: u64,
13999 bound: Duration,
14000 ) -> bool {
14001 let ring = Arc::clone(ring);
14002 let (done, done_rx) = std_mpsc::channel();
14003 std::thread::spawn(move || {
14004 record_terminal(
14005 module_id,
14006 &ring,
14007 &SpawnEventFeed::default(),
14008 &crash(at_ms),
14009 TerminalDisposition::Restarting,
14010 );
14011 let _ = done.send(());
14012 });
14013 done_rx.recv_timeout(bound).is_ok()
14014 }
14015
14016 #[test]
14021 fn exits_recorded_during_a_paused_history_read_are_not_blocked_or_half_merged() {
14022 let dir = ScratchDir::new("terminal-history-concurrent-read");
14023 let path = dir.join("terminals.jsonl");
14024 let journal = Arc::new(crate::terminal_journal::TerminalJournal::open(
14025 path.clone(),
14026 "daemon".into(),
14027 ));
14028 let reader_ring = journaled_ring(&journal);
14029 let other_ring = journaled_ring(&journal);
14030 assert!(record_within(
14031 "reader-module",
14032 &reader_ring,
14033 10,
14034 Duration::from_secs(5)
14035 ));
14036
14037 let (started, release) = read_pause::install(&path);
14038 let reading = {
14039 let ring = Arc::clone(&reader_ring);
14040 std::thread::spawn(move || durable_terminal_history_of(&ring, "reader-module"))
14041 };
14042 started
14043 .recv_timeout(Duration::from_secs(5))
14044 .expect("the history read reached its pause");
14045
14046 let bound = Duration::from_secs(1);
14047 assert!(
14048 record_within("other-module", &other_ring, 20, bound),
14049 "another module's exit waited on a history read (journal writer held)"
14050 );
14051 assert!(
14052 record_within("reader-module", &reader_ring, 30, bound),
14053 "the read module's own exit waited on its history read (ring held)"
14054 );
14055
14056 drop(release);
14057 let paused = reading.join().unwrap();
14058 assert_eq!(
14059 paused.entries.iter().map(|e| e.at_ms).collect::<Vec<_>>(),
14060 vec![10],
14061 "an exit recorded after the read began lands in neither half of it"
14062 );
14063 assert_eq!(paused.journal_skipped_lines, 0);
14064 assert_eq!(paused.journal_read_errors, 0);
14065
14066 let after = durable_terminal_history_of(&reader_ring, "reader-module");
14067 assert_eq!(
14068 after.entries.iter().map(|e| e.at_ms).collect::<Vec<_>>(),
14069 vec![10, 30],
14070 "the next read merges ring and journal with no duplicate"
14071 );
14072 assert_eq!(after.journal_skipped_lines, 0);
14073 }
14074}
14075
14076#[cfg(test)]
14081mod stderr_settle_tests {
14082 use std::{
14083 future::Future,
14084 io,
14085 pin::Pin,
14086 sync::{Arc, Mutex},
14087 task::{Context, Poll},
14088 time::Duration,
14089 };
14090
14091 use tokio::{
14092 io::{AsyncRead, ReadBuf},
14093 sync::oneshot,
14094 time::Instant,
14095 };
14096
14097 use super::{settle_stderr_pump, settle_stdout_pump, StderrPump};
14098 use crate::stderr_tail::{
14099 pump_stderr_to, pump_stdout_to, split_capture_stamp, untimed, CaptureState,
14100 ChildOutputSink, OutputSink, StderrRing, StderrTailConfig, TailEntry,
14101 };
14102
14103 const BOUND: Duration = Duration::from_millis(250);
14104
14105 struct HeldReader {
14109 before: Option<Vec<u8>>,
14110 gate: Option<oneshot::Receiver<()>>,
14111 after: io::Cursor<Vec<u8>>,
14112 }
14113
14114 impl AsyncRead for HeldReader {
14115 fn poll_read(
14116 mut self: Pin<&mut Self>,
14117 cx: &mut Context<'_>,
14118 buf: &mut ReadBuf<'_>,
14119 ) -> Poll<io::Result<()>> {
14120 if let Some(bytes) = self.before.take() {
14121 buf.put_slice(&bytes);
14122 return Poll::Ready(Ok(()));
14123 }
14124 if let Some(gate) = self.gate.as_mut() {
14125 match Pin::new(gate).poll(cx) {
14126 Poll::Pending => return Poll::Pending,
14127 Poll::Ready(_) => self.gate = None,
14128 }
14129 }
14130 Pin::new(&mut self.after).poll_read(cx, buf)
14131 }
14132 }
14133
14134 struct DiscardSink;
14135
14136 impl OutputSink for DiscardSink {
14137 fn write_line(&mut self, _line: &[u8]) {}
14138 }
14139
14140 fn line(text: &str) -> TailEntry {
14141 TailEntry::Line {
14142 text: text.to_string(),
14143 truncated: false,
14144 at_ms: None,
14145 }
14146 }
14147
14148 fn lock(ring: &Arc<Mutex<StderrRing>>) -> std::sync::MutexGuard<'_, StderrRing> {
14149 ring.lock().unwrap()
14150 }
14151
14152 fn held_pump(
14156 ring: &Arc<Mutex<StderrRing>>,
14157 before: &str,
14158 after: &str,
14159 ) -> (StderrPump, oneshot::Sender<()>) {
14160 let generation = lock(ring).begin_process();
14161 let (release, gate) = oneshot::channel();
14162 let reader = HeldReader {
14163 before: Some(before.as_bytes().to_vec()),
14164 gate: Some(gate),
14165 after: io::Cursor::new(after.as_bytes().to_vec()),
14166 };
14167 let task = tokio::spawn(pump_stderr_to(
14168 reader,
14169 Arc::clone(ring),
14170 generation,
14171 DiscardSink,
14172 ));
14173 (StderrPump { task, generation }, release)
14174 }
14175
14176 async fn wait_until(ring: &Arc<Mutex<StderrRing>>, done: impl Fn(&StderrRing) -> bool) {
14177 for _ in 0..1000 {
14178 if done(&lock(ring)) {
14179 return;
14180 }
14181 tokio::time::sleep(Duration::from_millis(1)).await;
14182 }
14183 panic!(
14184 "ring never reached the expected state: {:?}",
14185 lock(ring).snapshot(None, None)
14186 );
14187 }
14188
14189 #[tokio::test(start_paused = true)]
14190 async fn a_crash_line_the_reader_had_not_reached_by_the_bound_is_kept_before_the_restart() {
14191 let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
14192 let (pump, release) = held_pump(&ring, "booting\n", "config error: missing storage\n");
14193
14194 settle_stderr_pump("crasher", &ring, pump, BOUND).await;
14195 let before_release = lock(&ring).snapshot(None, None);
14196 assert!(
14197 matches!(before_release.capture, CaptureState::Incomplete { .. }),
14198 "a reader that has not reached EOF cannot claim a whole tail: {before_release:?}"
14199 );
14200
14201 let next = lock(&ring).begin_process();
14204 lock(&ring).push_line_from(next, "next process booting");
14205 release.send(()).unwrap();
14206 wait_until(&ring, |ring| {
14207 ring.snapshot(None, None).capture == CaptureState::Captured
14208 })
14209 .await;
14210
14211 assert_eq!(
14212 untimed(lock(&ring).snapshot(None, None).entries),
14213 vec![
14214 line("booting"),
14215 line("config error: missing storage"),
14216 TailEntry::ProcessStart,
14217 line("next process booting"),
14218 ],
14219 "the crash's last line must survive a slow reader and stay in the crashed process's section"
14220 );
14221 }
14222
14223 #[tokio::test(start_paused = true)]
14224 async fn a_pipe_held_open_by_a_descendant_reads_incomplete_without_delaying_the_restart_past_the_bound(
14225 ) {
14226 let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
14227 let (pump, _held) = held_pump(&ring, "parent exiting\n", "");
14230
14231 let started = Instant::now();
14232 settle_stderr_pump("orphaning", &ring, pump, BOUND).await;
14233 assert_eq!(
14234 started.elapsed(),
14235 BOUND,
14236 "the restart must wait exactly the bound for a pipe that stays open, no longer"
14237 );
14238
14239 let next = lock(&ring).begin_process();
14240 lock(&ring).push_line_from(next, "next process booting");
14241 tokio::time::sleep(Duration::from_secs(60)).await;
14242
14243 let snapshot = lock(&ring).snapshot(None, None);
14244 match &snapshot.capture {
14245 CaptureState::Incomplete { reason } => assert!(
14246 reason.contains("had not reached EOF") && reason.contains("250ms"),
14247 "the reason must say what is missing and after how long: {reason}"
14248 ),
14249 other => panic!("expected Incomplete while the pipe is held open, got {other:?}"),
14250 }
14251 assert_eq!(
14252 untimed(snapshot.entries),
14253 vec![
14254 line("parent exiting"),
14255 TailEntry::ProcessStart,
14256 line("next process booting"),
14257 ]
14258 );
14259 }
14260
14261 #[tokio::test(start_paused = true)]
14262 async fn a_reader_that_reaches_eof_within_the_bound_leaves_the_tail_captured() {
14263 let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
14264 let (pump, release) = held_pump(&ring, "one\n", "two\n");
14265 release.send(()).unwrap();
14266
14267 settle_stderr_pump("clean", &ring, pump, BOUND).await;
14268
14269 let snapshot = lock(&ring).snapshot(None, None);
14270 assert_eq!(snapshot.capture, CaptureState::Captured);
14271 assert_eq!(untimed(snapshot.entries), vec![line("one"), line("two")]);
14272 }
14273
14274 #[tokio::test(start_paused = true)]
14275 async fn stdout_buffered_past_the_exit_drain_bound_reaches_the_capture_file() {
14276 const BURST: usize = 150;
14277 let capture = cortexkit_test_support::ScratchDir::new("stdout-exit-drain");
14278 let path = capture.join("two-pipe.stderr.log");
14279 let sink = ChildOutputSink::open(&path, cortexkit_log::Retention::default()).unwrap();
14280 let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
14281 let generation = lock(&ring).begin_process();
14282 let stdout = (0..BURST)
14283 .map(|i| format!("out-{i:04}-{}\n", "o".repeat(64)))
14284 .collect::<String>();
14285 let stderr = (0..BURST)
14286 .map(|i| format!("err-{i:04}-{}\n", "e".repeat(64)))
14287 .collect::<String>();
14288 let first_chunk = stdout.find('\n').unwrap() + 1 + "out-0001-".len();
14293 let (release, gate) = oneshot::channel();
14294 let reader = HeldReader {
14295 before: Some(stdout.as_bytes()[..first_chunk].to_vec()),
14296 gate: Some(gate),
14297 after: io::Cursor::new(stdout.as_bytes()[first_chunk..stdout.len() - 1].to_vec()),
14298 };
14299 let (finished, completion) = oneshot::channel();
14300 let stdout_sink = sink.clone();
14301 let pump = tokio::spawn(async move {
14302 pump_stdout_to(reader, stdout_sink).await;
14303 finished.send(()).unwrap();
14304 });
14305 pump_stderr_to(io::Cursor::new(stderr.into_bytes()), ring, generation, sink).await;
14306
14307 let started = Instant::now();
14308 settle_stdout_pump("two-pipe", pump, BOUND).await;
14309 assert_eq!(
14310 started.elapsed(),
14311 BOUND,
14312 "a late reader must not hold up restart"
14313 );
14314 assert_eq!(
14315 std::fs::read_to_string(&path).unwrap().lines().count(),
14316 BURST + 1
14317 );
14318
14319 release
14320 .send(())
14321 .expect("stdout reader must survive the exit drain bound");
14322 tokio::time::timeout(Duration::from_secs(1), completion)
14323 .await
14324 .expect("released stdout reader must reach EOF")
14325 .expect("stdout capture must finish at EOF, not be cancelled");
14326 let contents = std::fs::read_to_string(&path).unwrap();
14327 let mut lines = contents
14328 .lines()
14329 .map(|line| split_capture_stamp(line).expect("capture stamp").1)
14330 .collect::<Vec<_>>();
14331 let mut expected = (0..BURST)
14332 .flat_map(|i| {
14333 [
14334 format!("out-{i:04}-{}", "o".repeat(64)),
14335 format!("err-{i:04}-{}", "e".repeat(64)),
14336 ]
14337 })
14338 .collect::<Vec<_>>();
14339 lines.sort_unstable();
14340 expected.sort_unstable();
14341 assert_eq!(
14342 lines, expected,
14343 "both pipes must retain every complete line exactly once"
14344 );
14345 }
14346}
14347
14348#[cfg(all(test, windows))]
14362mod job_containment_tests {
14363 use super::*;
14364 use cortexkit_test_support::ScratchDir;
14365 use std::{
14366 path::{Path, PathBuf},
14367 sync::{Arc, Mutex},
14368 time::{Duration, Instant},
14369 };
14370
14371 fn stub_path() -> PathBuf {
14377 let mut path = std::env::current_exe().expect("current_exe available in tests");
14378 path.pop();
14379 path.pop();
14380 path.push("fake-aft-stub.exe");
14381 assert!(
14382 path.exists(),
14383 "fake-aft-stub not built at {}: run `cargo test -p subc-core` (which builds \
14384 [[bin]] targets) rather than `cargo test -p subc-core --lib` (which does not)",
14385 path.display()
14386 );
14387 path
14388 }
14389
14390 fn read_grandchild_pid(path: &Path) -> u32 {
14392 let deadline = Instant::now() + Duration::from_secs(10);
14393 loop {
14394 if let Ok(contents) = std::fs::read_to_string(path) {
14395 if let Ok(pid) = contents.trim().parse() {
14396 return pid;
14397 }
14398 }
14399 assert!(
14400 Instant::now() < deadline,
14401 "the stub never recorded a grandchild pid at {}",
14402 path.display()
14403 );
14404 std::thread::sleep(Duration::from_millis(10));
14405 }
14406 }
14407
14408 struct Fixture {
14411 _dir: ScratchDir,
14412 module_id: String,
14413 grandchild: u32,
14414 child: Option<SupervisedChild>,
14415 registry: Arc<Registry>,
14416 snapshot: Arc<Mutex<SupervisorSnapshot>>,
14417 terminal_ring: Arc<Mutex<TerminalRing>>,
14418 spawn_events: SpawnEventFeed,
14419 }
14420
14421 fn fixture(label: &str, module_id: &str) -> Fixture {
14422 let dir = ScratchDir::new(label);
14423 let pid_file = dir.join("grandchild.pid");
14424 let supervisor = Supervisor::new_for_test(
14425 Arc::new(Registry::default()),
14426 RestartPolicy::new(3, Duration::ZERO),
14427 );
14428 let runtime = supervisor.runtime_config();
14429 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
14430 let spec = ModuleSpec {
14431 module_id: module_id.to_string(),
14432 program: stub_path(),
14433 args: Vec::new(),
14437 env: vec![
14438 ("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string()),
14439 (
14440 "FAKE_AFT_GRANDCHILD_PID_FILE".to_string(),
14441 pid_file.display().to_string(),
14442 ),
14443 ],
14444 reserved: false,
14445 reserved_prefixes: Vec::new(),
14446 protocol: ModuleProtocol::Subc,
14447 overlap: Default::default(),
14448 };
14449 let child = spawn_and_mark_running(&spec, &runtime, &snapshot)
14450 .expect("spawn the supervised fixture");
14451 let grandchild = read_grandchild_pid(&pid_file);
14452 Fixture {
14453 _dir: dir,
14454 module_id: module_id.to_string(),
14455 grandchild,
14456 child: Some(child),
14457 registry: Arc::new(Registry::default()),
14458 snapshot,
14459 terminal_ring: Arc::clone(&runtime.terminal_ring),
14460 spawn_events: SpawnEventFeed::default(),
14461 }
14462 }
14463
14464 impl Fixture {
14465 async fn drain(&mut self) {
14467 let child = self
14468 .child
14469 .take()
14470 .expect("the fixture child is still present");
14471 drain_child_to_state(
14472 &self.module_id,
14473 ModuleProtocol::Subc,
14474 StopNotice::NotSent,
14477 &self.registry,
14478 None,
14479 &self.snapshot,
14480 &self.terminal_ring,
14481 &self.spawn_events,
14482 child,
14483 Duration::from_millis(500),
14484 ModuleState::Stopped,
14485 Some(false),
14486 )
14487 .await
14488 .expect("drain the supervised fixture");
14489 }
14490 }
14491
14492 #[tokio::test]
14498 async fn teardown_reaps_the_grandchild() {
14499 let mut fixture = fixture("teardown-grandchild", "tree-teardown");
14500 let grandchild = fixture.grandchild;
14501
14502 assert!(
14503 subc_jobobject::process_exists(grandchild),
14504 "grandchild {grandchild} must be alive before teardown, or this proves nothing"
14505 );
14506
14507 fixture.drain().await;
14508
14509 assert!(
14510 subc_jobobject::wait_for_process_exit(grandchild, Duration::from_secs(10)),
14511 "grandchild {grandchild} outlived module teardown: the tree was not contained"
14512 );
14513 }
14514
14515 #[test]
14528 fn an_uncontained_grandchild_survives_a_direct_child_kill() {
14529 let dir = ScratchDir::new("teardown-uncontained");
14530 let pid_file = dir.join("grandchild.pid");
14531 let mut child = std::process::Command::new(stub_path())
14532 .env("FAKE_AFT_NEVER_CONNECT", "1")
14533 .env(
14534 "FAKE_AFT_GRANDCHILD_PID_FILE",
14535 pid_file.display().to_string(),
14536 )
14537 .stdin(std::process::Stdio::null())
14538 .stdout(std::process::Stdio::null())
14539 .stderr(std::process::Stdio::null())
14540 .spawn()
14541 .expect("spawn the uncontained fixture");
14542 let grandchild = read_grandchild_pid(&pid_file);
14543
14544 child.kill().expect("kill the direct child");
14546 let _ = child.wait();
14547
14548 assert!(
14549 subc_jobobject::process_exists(grandchild),
14550 "grandchild {grandchild} died with the direct child, so this control no longer \
14551 distinguishes contained from uncontained teardown and the regression test is \
14552 passing vacuously"
14553 );
14554
14555 kill_tree(grandchild);
14558 }
14559
14560 #[tokio::test]
14569 async fn dropping_containment_reaps_the_grandchild() {
14570 let mut fixture = fixture("drop-containment", "tree-drop");
14571 let grandchild = fixture.grandchild;
14572
14573 assert!(subc_jobobject::process_exists(grandchild));
14574
14575 fixture.child.as_mut().expect("child present").job = None;
14577
14578 assert!(
14579 subc_jobobject::wait_for_process_exit(grandchild, Duration::from_secs(10)),
14580 "grandchild {grandchild} survived the containment handle closing, so a daemon \
14581 crash would leave the tree behind"
14582 );
14583 }
14584
14585 fn kill_tree(pid: u32) {
14587 let _ = std::process::Command::new("taskkill.exe")
14588 .args(["/PID", &pid.to_string(), "/T", "/F"])
14589 .stdin(std::process::Stdio::null())
14590 .stdout(std::process::Stdio::null())
14591 .stderr(std::process::Stdio::null())
14592 .status();
14593 assert!(
14594 subc_jobobject::wait_for_process_exit(pid, Duration::from_secs(10)),
14595 "could not clean up grandchild {pid}"
14596 );
14597 }
14598}
14599
14600#[cfg(test)]
14601mod privacy_trampoline_configuration_tests {
14602 #[tokio::test]
14603 #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
14604 async fn macos_spawn_without_a_configured_trampoline_refuses_by_name() {
14605 #[cfg(target_os = "macos")]
14606 {
14607 let supervisor = super::Supervisor::new(
14608 std::sync::Arc::new(crate::Registry::default()),
14609 super::RestartPolicy::default(),
14610 );
14611 let error = supervisor.spawn(spec()).unwrap_err();
14612 assert!(
14613 error
14614 .to_string()
14615 .contains("no privacy trampoline configured"),
14616 "{error}"
14617 );
14618 }
14619 }
14620
14621 #[tokio::test]
14622 #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
14623 async fn macos_wrong_trampoline_is_rejected_by_the_startup_probe() {
14624 #[cfg(target_os = "macos")]
14625 {
14626 let supervisor = super::Supervisor::new(
14627 std::sync::Arc::new(crate::Registry::default()),
14628 super::RestartPolicy::default(),
14629 )
14630 .with_privacy_trampoline(std::env::current_exe().unwrap());
14631 let error = supervisor.spawn(spec()).unwrap_err();
14632 assert!(
14633 error
14634 .to_string()
14635 .contains("binary does not implement the privacy trampoline protocol"),
14636 "{error}"
14637 );
14638 }
14639 }
14640
14641 #[cfg(target_os = "macos")]
14642 fn spec() -> super::ModuleSpec {
14643 super::ModuleSpec {
14644 module_id: "privacy-configuration".into(),
14645 program: "/bin/sleep".into(),
14646 args: vec!["30".into()],
14647 env: vec![],
14648 reserved: false,
14649 reserved_prefixes: vec![],
14650 protocol: subc_control::ModuleProtocol::None,
14651 overlap: super::ModuleOverlap::Exclusive,
14652 }
14653 }
14654}
14655
14656#[cfg(test)]
14657mod privacy_exec_boundary_tests {
14658 #[cfg(target_os = "macos")]
14659 use super::*;
14660 #[cfg(target_os = "macos")]
14661 use std::{
14662 io::{Read, Write},
14663 net::{TcpListener, TcpStream},
14664 };
14665
14666 #[cfg(target_os = "macos")]
14669 pub(super) fn before_image_sample(spec: &ModuleSpec, pid: u32) {
14670 if let Some((_, path)) = spec
14671 .env
14672 .iter()
14673 .find(|(key, _)| key == "SUBC_TEST_PRIVACY_SAMPLE_BARRIER")
14674 {
14675 let mut barrier = TcpStream::connect(path).unwrap();
14676 barrier
14677 .set_read_timeout(Some(Duration::from_secs(30)))
14678 .unwrap();
14679 barrier.write_all(&pid.to_ne_bytes()).unwrap();
14680 let mut release = [0];
14681 barrier.read_exact(&mut release).unwrap();
14682 assert_eq!(&release, b"X");
14683 }
14684 }
14685
14686 #[cfg(target_os = "macos")]
14687 fn spec(program: &str, args: &[&str]) -> ModuleSpec {
14688 ModuleSpec {
14689 module_id: "privacy-boundary".into(),
14690 program: program.into(),
14691 args: args.iter().map(|arg| (*arg).into()).collect(),
14692 env: vec![],
14693 reserved: false,
14694 reserved_prefixes: vec![],
14695 protocol: ModuleProtocol::None,
14696 overlap: ModuleOverlap::Exclusive,
14697 }
14698 }
14699
14700 #[cfg(target_os = "macos")]
14701 async fn accept(listener: TcpListener) -> TcpStream {
14702 let listener = tokio::net::TcpListener::from_std({
14704 listener.set_nonblocking(true).unwrap();
14705 listener
14706 })
14707 .unwrap();
14708 let (stream, _) = tokio::time::timeout(Duration::from_secs(30), listener.accept())
14709 .await
14710 .unwrap()
14711 .unwrap();
14712 let stream = stream.into_std().unwrap();
14713 stream.set_nonblocking(false).unwrap();
14714 stream
14715 .set_read_timeout(Some(Duration::from_secs(30)))
14716 .unwrap();
14717 stream
14718 }
14719
14720 #[tokio::test]
14721 #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
14722 async fn macos_roster_withholds_a_nonnull_trampoline_image_until_exec_confirmation() {
14723 #[cfg(target_os = "macos")]
14724 {
14725 let root = cortexkit_test_support::ScratchDir::new("privacy-roster-barrier");
14726 let exec_listener = TcpListener::bind("127.0.0.1:0").unwrap();
14729 let sample_listener = TcpListener::bind("127.0.0.1:0").unwrap();
14730 let record = root.join("live-children.json");
14731 let supervisor =
14732 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
14733 .with_live_children_record(&record);
14734 let runtime = supervisor.runtime_config();
14735 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
14736 let mut spec = spec("/bin/sleep", &["30"]);
14737 spec.env = vec![
14738 (
14739 "SUBC_TEST_PRIVACY_EXEC_BARRIER".into(),
14740 exec_listener.local_addr().unwrap().to_string(),
14741 ),
14742 (
14743 "SUBC_TEST_PRIVACY_SAMPLE_BARRIER".into(),
14744 sample_listener.local_addr().unwrap().to_string(),
14745 ),
14746 ];
14747 let spawn = tokio::task::spawn_blocking(move || {
14748 spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap()
14749 });
14750 let mut sample = accept(sample_listener).await;
14751 let mut pid = [0; 4];
14752 sample.read_exact(&mut pid).unwrap();
14753 let pid = u32::from_ne_bytes(pid);
14754 let mut exec = accept(exec_listener).await;
14755 let mut ready = [0];
14756 exec.read_exact(&mut ready).unwrap();
14757 assert_eq!(&ready, b"R");
14758 let trampoline = subc_os::file_identity(&test_privacy_trampoline()).unwrap();
14761 assert_eq!(
14762 observe_spawned_image(pid).unwrap().executable,
14763 Some(trampoline)
14764 );
14765 sample.write_all(b"X").unwrap();
14766 let mut child = spawn.await.unwrap();
14767 let early = crate::live_children::read_record(&record).unwrap();
14768 assert_eq!(early.len(), 1);
14769 assert_eq!(early[0].pid, pid);
14770 assert_eq!(
14771 early[0].executable, None,
14772 "unconfirmed trampoline image entered the roster"
14773 );
14774 assert!(child.report_ready.get().is_none());
14775 child.privacy_exec.as_mut().unwrap().deadline =
14778 tokio::time::Instant::now() + Duration::from_secs(30);
14779 exec.write_all(b"X").unwrap();
14780 child.confirm_privacy_exec().await;
14781 assert_eq!(child.spawn_failure, None);
14782 assert!(child.report_ready.get().is_some());
14783 let confirmed = crate::live_children::read_record(&record).unwrap();
14784 let module = subc_os::file_identity(std::path::Path::new("/bin/sleep")).unwrap();
14785 assert_ne!(module, trampoline);
14786 assert_eq!(confirmed[0].executable, Some(module.into()));
14787 child.start_kill().unwrap();
14788 child.wait().await.unwrap();
14789 child.release_roster();
14790 }
14791 }
14792
14793 #[tokio::test]
14794 #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
14795 async fn macos_already_exited_121_in_try_wait_is_a_module_exit_not_a_trampoline_refusal() {
14796 #[cfg(target_os = "macos")]
14797 {
14798 let registry = Arc::new(Registry::default());
14799 let policy = RestartPolicy::new(0, Duration::ZERO);
14800 let supervisor = Supervisor::new_for_test(Arc::clone(®istry), policy);
14801 let runtime = supervisor.runtime_config();
14802 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
14803 let spec = spec("/bin/sh", &["-c", "exit 121"]);
14804 let mut child = spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap();
14808 let pid = child.pid;
14809 tokio::task::spawn_blocking(move || {
14810 subc_os::fork_exec_test::wait_for_child_exit_without_reaping(pid)
14811 })
14812 .await
14813 .unwrap()
14814 .unwrap();
14815 child.privacy_exec.as_mut().unwrap().deadline =
14816 tokio::time::Instant::now() + Duration::from_secs(30);
14817 let status = child.wait().await.unwrap();
14818 assert_eq!(status.code(), Some(121));
14819 assert!(child.privacy_exec.is_none());
14820 assert!(
14821 child.report_ready.get().is_none(),
14822 "an exited module must not publish a live pid"
14823 );
14824 let report = classify_reaped_child_exit(&snapshot, &child, &status);
14825 on_child_exit(
14826 &spec,
14827 policy,
14828 ®istry,
14829 &snapshot,
14830 &runtime.terminal_ring,
14831 &runtime.spawn_events,
14832 &runtime.child_roster,
14833 report,
14834 )
14835 .await;
14836 let state = lock_snapshot(&snapshot).unwrap();
14837 assert_eq!(state.state, ModuleState::Failed);
14838 assert_eq!(state.last_exit.as_ref().unwrap().code, Some(121));
14839 assert_eq!(state.reported_pid(), None);
14840 drop(state);
14841 let history = runtime.terminal_ring.lock().unwrap().snapshot();
14842 assert_eq!(history.entries.len(), 1);
14843 let terminal = &history.entries[0];
14844 assert_eq!(terminal.exit_code, Some(121));
14845 assert_eq!(terminal.exit_kind, subc_control::TerminalExitKind::Crash);
14846 assert_eq!(terminal.disposition, TerminalDisposition::Failed);
14847 assert_eq!(
14848 terminal.disposition_detail.as_deref(),
14849 Some(policy.budget_exhausted_detail().as_str()),
14850 "module exit 121 was classified as a trampoline refusal: {terminal:?}"
14851 );
14852 assert_eq!(child.spawn_failure, None);
14853 child.release_roster();
14854 }
14855 }
14856}
14857
14858#[cfg(all(test, unix))]
14862mod launch_nonce_descriptor_tests {
14863 use super::{ChildRoster, ModuleProtocol, ModuleSpec, SupervisorHandle};
14864 use crate::stderr_tail::{StderrRing, StderrTailConfig};
14865 use cortexkit_test_support::ScratchDir;
14866 use std::{
14867 path::PathBuf,
14868 sync::{Arc, Mutex},
14869 time::{Duration, Instant},
14870 };
14871
14872 async fn probe(role: super::SpawnRole) {
14873 let scratch = ScratchDir::new("launch-nonce-descriptor");
14874 let fd_copy = scratch.join("from-descriptor");
14875 let env_copy = scratch.join("environment");
14876 let script = format!(
14877 "env > '{env}.tmp' && mv '{env}.tmp' '{env}'; cat <&3 > '{fd}.tmp' && mv '{fd}.tmp' '{fd}'; sleep 30",
14878 fd = fd_copy.display(), env = env_copy.display(),
14879 );
14880 let xdg = |name: &str| (name.to_string(), scratch.join(name).display().to_string());
14881 let spec = ModuleSpec {
14882 module_id: "nonce-descriptor-probe".to_string(),
14883 program: PathBuf::from("/bin/sh"),
14884 args: vec!["-c".to_string(), script],
14885 env: vec![
14886 xdg("XDG_DATA_HOME"),
14887 xdg("XDG_RUNTIME_DIR"),
14888 xdg("XDG_CONFIG_HOME"),
14889 ("SUBC_LAUNCH_NONCE".to_string(), "must-not-leak".to_string()),
14890 ],
14891 reserved: true,
14892 reserved_prefixes: Vec::new(),
14893 protocol: ModuleProtocol::Subc,
14894 overlap: Default::default(),
14895 };
14896 let handle = SupervisorHandle::new();
14897 let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
14898 let roster = ChildRoster::default();
14899 #[cfg(target_os = "macos")]
14900 {
14901 let path = super::test_privacy_trampoline();
14902 roster.set_privacy_trampoline(super::probe_privacy_trampoline(&path).map(|()| path));
14903 }
14904 let child = super::spawn_child_in_slot(
14905 &spec,
14906 None,
14907 Some(&handle),
14908 &ring,
14909 None,
14910 &roster,
14911 #[cfg(target_os = "linux")]
14912 None,
14913 role,
14914 matches!(role, super::SpawnRole::SwapCandidate),
14915 )
14916 .expect("spawn probe");
14917 let deadline = Instant::now() + Duration::from_secs(10);
14918 while !(fd_copy.exists() && env_copy.exists()) {
14919 assert!(Instant::now() < deadline, "probe never wrote its copies");
14920 tokio::time::sleep(Duration::from_millis(20)).await;
14921 }
14922 let nonce = std::fs::read_to_string(fd_copy).unwrap();
14923 assert!(!nonce.is_empty());
14924 let environment = std::fs::read_to_string(env_copy).unwrap();
14925 assert!(environment
14926 .lines()
14927 .any(|line| line.starts_with("SUBC_LAUNCH_NONCE_FD=3:")));
14928 let copy = environment
14929 .lines()
14930 .find_map(|line| line.strip_prefix("SUBC_LAUNCH_NONCE="));
14931 assert_eq!(
14932 copy, None,
14933 "Unix children must never receive the environment nonce"
14934 );
14935 if matches!(role, super::SpawnRole::Plain) {
14936 assert_eq!(
14937 handle.spawn_nonce(&spec.module_id).as_deref(),
14938 Some(nonce.as_str())
14939 );
14940 }
14941 drop(child);
14942 }
14943
14944 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
14945 async fn a_spawned_module_receives_its_nonce_only_on_descriptor_3() {
14946 probe(super::SpawnRole::Plain).await;
14947 }
14948
14949 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
14950 async fn swap_candidate_receives_its_nonce_only_on_descriptor_3() {
14951 probe(super::SpawnRole::SwapCandidate).await;
14952 }
14953}
14954
14955#[cfg(all(test, target_os = "linux"))]
14956mod cgroup_containment_tests {
14957 use super::*;
14958 use cortexkit_test_support::ScratchDir;
14959
14960 fn running(pid: u32) -> bool {
14961 std::fs::read_to_string(format!("/proc/{pid}/stat"))
14963 .ok()
14964 .and_then(|stat| {
14965 stat.rsplit_once(") ")
14966 .map(|(_, rest)| rest.starts_with('Z'))
14967 })
14968 .is_some_and(|zombie| !zombie)
14969 }
14970
14971 #[tokio::test]
14972 async fn linux_teardown_reaps_the_grandchild() {
14973 teardown_tree("linux_teardown_reaps_the_grandchild", false).await;
14974 }
14975
14976 #[tokio::test]
14977 async fn linux_shutdown_straggler_reaps_the_grandchild() {
14978 teardown_tree("linux_shutdown_straggler_reaps_the_grandchild", true).await;
14979 }
14980
14981 async fn teardown_tree(test_name: &str, shutdown: bool) {
14982 let dir = ScratchDir::new(test_name);
14983 let root = PathBuf::from(format!(
14984 "/sys/fs/cgroup/subc-tree-test-{test_name}-{}-{}",
14985 std::process::id(),
14986 unix_ms_now()
14987 ));
14988 if let Err(error) = std::fs::create_dir(&root) {
14989 assert!(
14990 std::env::var_os("SUBC_REQUIRE_CGROUP_TEST").is_none(),
14991 "required cgroup test cannot execute: {error}"
14992 );
14993 eprintln!(
14994 "SKIP {test_name}: no writable delegated cgroup at {}: {error}",
14995 root.display()
14996 );
14997 return;
14998 }
14999 let placement = subc_cgroup::prepare_at(&root)
15000 .expect("prepare isolated kernel cgroup")
15001 .expect("isolated cgroup is delegated");
15002 let module_id = "tree-teardown";
15003 let module = placement
15004 .module_path(module_id)
15005 .expect("create isolated module cgroup");
15006 if !module.join("cgroup.kill").exists() {
15007 std::fs::remove_dir(&module).unwrap();
15008 std::fs::remove_dir(root.join("subc-modules")).unwrap();
15009 std::fs::remove_dir(&root).unwrap();
15010 assert!(
15011 std::env::var_os("SUBC_REQUIRE_CGROUP_TEST").is_none(),
15012 "required cgroup.kill interface unavailable"
15013 );
15014 eprintln!("SKIP {test_name}: cgroup.kill unavailable (kernel < 5.14)");
15015 return;
15016 }
15017 let supervisor = Supervisor::new_for_test(
15018 Arc::new(Registry::default()),
15019 RestartPolicy::new(3, Duration::ZERO),
15020 )
15021 .with_cgroup_placement(Some(placement));
15022 let mut runtime = supervisor.runtime_config();
15023 runtime.child_roster = runtime
15024 .child_roster
15025 .for_module(Arc::new(Mutex::new(Duration::from_millis(100))));
15026 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
15027 let pid_file = dir.join("grandchild.pid");
15028 let spec = ModuleSpec {
15029 module_id: module_id.to_string(),
15030 program: PathBuf::from("/bin/sh"),
15031 args: vec![
15032 "-c".into(),
15033 "trap '' TERM; sleep 600 & echo $! > \"$1\"; wait".into(),
15034 "fixture".into(),
15035 pid_file.display().to_string(),
15036 ],
15037 env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
15038 .into_iter()
15039 .map(|key| (key.to_string(), dir.display().to_string()))
15040 .collect(),
15041 reserved: false,
15042 reserved_prefixes: Vec::new(),
15043 protocol: ModuleProtocol::None,
15044 overlap: Default::default(),
15045 };
15046 let child =
15047 spawn_and_mark_running(&spec, &runtime, &snapshot).expect("spawn supervised tree");
15048 let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
15049 let grandchild: u32 = loop {
15050 if let Ok(contents) = std::fs::read_to_string(&pid_file) {
15051 if let Ok(pid) = contents.trim().parse() {
15052 break pid;
15053 }
15054 }
15055 assert!(
15056 tokio::time::Instant::now() < deadline,
15057 "grandchild pid was not recorded"
15058 );
15059 tokio::time::sleep(Duration::from_millis(10)).await;
15060 };
15061 assert!(
15062 running(grandchild),
15063 "grandchild must be alive before teardown"
15064 );
15065 if shutdown {
15066 let mut child = child;
15067 crate::child_roster::end_children_for_daemon_shutdown(
15068 &runtime.child_roster,
15069 false,
15070 std::future::pending(),
15071 )
15072 .await;
15073 child.wait().await.expect("reap shutdown straggler");
15074 } else {
15075 drain_child_to_state(
15076 module_id,
15077 ModuleProtocol::None,
15078 StopNotice::NotSent,
15079 &Registry::default(),
15080 None,
15081 &snapshot,
15082 &runtime.terminal_ring,
15083 &SpawnEventFeed::default(),
15084 child,
15085 Duration::from_millis(100),
15086 ModuleState::Stopped,
15087 Some(false),
15088 )
15089 .await
15090 .expect("real supervisor teardown");
15091 }
15092 let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
15093 while running(grandchild) && tokio::time::Instant::now() < deadline {
15094 tokio::time::sleep(Duration::from_millis(10)).await;
15095 }
15096 let survived = running(grandchild);
15097 if survived {
15099 let pid = rustix::process::Pid::from_raw(grandchild as i32).unwrap();
15100 let _ = rustix::process::kill_process(pid, rustix::process::Signal::KILL);
15101 tokio::time::sleep(Duration::from_millis(100)).await;
15102 }
15103 if module.exists() {
15104 std::fs::remove_dir(&module).expect("remove empty module cgroup");
15105 }
15106 std::fs::remove_dir(root.join("subc-modules")).unwrap();
15107 std::fs::remove_dir(&root).unwrap();
15108 assert!(
15109 !survived,
15110 "grandchild {grandchild} outlived module teardown"
15111 );
15112 eprintln!("EXECUTED {test_name}: grandchild {grandchild} killed in isolated cgroup");
15113 }
15114}