1use std::{
2 collections::{HashMap, HashSet, VecDeque},
3 error::Error,
4 fmt, io,
5 path::PathBuf,
6 process::{ExitStatus, Stdio},
7 sync::{Arc, Mutex, OnceLock},
8 time::{Duration, SystemTime, UNIX_EPOCH},
9};
10
11use cortexkit_log::Retention;
12use serde_json::Value;
13use subc_control::{
14 ClientControlPush, LiveSpawn, ModuleProtocol, RouteCloseReason, SpawnCursor, SpawnEvent,
15 SpawnEventKind, SpawnSnapshot, SupervisorHealthStatus, TerminalDisposition, TerminalExitKind,
16};
17use subc_protocol::{
18 manifest::{SelfSignalKind, SignalAnchor},
19 session::{
20 HealthReport, HealthStatus, ModuleControlCommand, ModuleControlRequest,
21 MODULE_CONTROL_OP_HEALTH_CHECK,
22 },
23 Flags, FrameType, Priority, SUBC_LAUNCH_NONCE_ENV, SUBC_MODULE_ID_ENV,
24};
25use tokio::{
26 process::{Child, Command},
27 sync::{mpsc, oneshot, watch, Mutex as AsyncMutex},
28 task::JoinHandle,
29 time::{sleep, sleep_until, timeout, timeout_at, Instant},
30};
31use tracing::{debug, error, info, warn};
32
33use crate::{
34 child_roster::ChildRoster,
35 daemon_config::{
36 CAPTURE_KEEP_ENV, CAPTURE_MAX_AGE_DAYS_ENV, CAPTURE_MAX_FILE_MB_ENV, CK_LOG_ENV,
37 },
38 forwarding::{
39 CloseReason, ForwardingError, ForwardingTable, GoodbyeTarget, ModuleControlRpcOutcome,
40 ModuleDrainTarget, PendingModuleControlRpc,
41 },
42 provenance::{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
58pub const SUBC_ARG: &str = "--subc";
64
65const DEFAULT_MAX_RESTARTS: u32 = 3;
66const DEFAULT_BACKOFF: Duration = Duration::from_millis(100);
67const DEFAULT_MAX_BACKOFF: Duration = Duration::from_secs(30);
68const DEFAULT_RESTART_WINDOW: Duration = Duration::from_secs(600);
72pub const DEFAULT_DRAIN_TIMEOUT: Duration = Duration::from_secs(30);
83const REGISTRY_RELEASE_TIMEOUT: Duration = Duration::from_secs(1);
84const REGISTRY_RELEASE_POLL: Duration = Duration::from_millis(10);
85const STDERR_PUMP_DRAIN_TIMEOUT: Duration = Duration::from_millis(250);
108pub const SPAWN_EVENT_RING_CAPACITY: usize = 4096;
110const SPAWN_SUBSCRIBER_BUFFER: usize = SPAWN_EVENT_RING_CAPACITY + 1;
111pub(crate) const SPAWN_SUBSCRIBER_LAGGED_CODE: &str = "spawn_subscriber_lagged";
116
117#[cfg(target_os = "macos")]
118struct PrivacyExec {
119 reader: tokio::io::unix::AsyncFd<std::io::PipeReader>,
120 deadline: tokio::time::Instant,
121 expected: Option<subc_os::FileIdentity>,
122 trampoline: Option<subc_os::FileIdentity>,
123 script: bool,
124 module_id: String,
125}
126
127#[cfg(all(test, target_os = "macos"))]
128pub(crate) fn test_privacy_trampoline() -> PathBuf {
129 let path = std::env::current_exe()
132 .unwrap()
133 .parent()
134 .unwrap()
135 .parent()
136 .unwrap()
137 .join("privacy-trampoline-fixture");
138 assert!(
142 path.exists(),
143 "privacy-trampoline-fixture not built at {}: run `cargo build -p subc-daemon \
144 --bins --features test-support` or `cargo test -p subc-daemon` first",
145 path.display()
146 );
147 path
148}
149
150#[cfg(target_os = "macos")]
151fn probe_privacy_trampoline(path: &std::path::Path) -> Result<(), String> {
152 use std::io::Read;
153 let mut probe = std::process::Command::new(path)
154 .args(["__disclaim-exec", "--probe"])
155 .stdin(Stdio::null())
156 .stdout(Stdio::piped())
157 .stderr(Stdio::piped())
158 .spawn()
159 .map_err(|error| {
160 format!(
161 "privacy trampoline probe failed for {}: {error}",
162 path.display()
163 )
164 })?;
165 let deadline = std::time::Instant::now() + Duration::from_secs(5);
166 let status = loop {
167 match probe.try_wait() {
168 Ok(Some(status)) => break status,
169 Ok(None) if std::time::Instant::now() < deadline => {
170 std::thread::sleep(Duration::from_millis(5))
171 }
172 result => {
173 let _ = probe.kill();
174 let _ = probe.wait();
175 return Err(format!(
176 "privacy trampoline probe failed or timed out for {}: {result:?}",
177 path.display()
178 ));
179 }
180 }
181 };
182 let mut answer = String::new();
183 if let Some(stdout) = probe.stdout.take() {
184 let _ = stdout.take(256).read_to_string(&mut answer);
185 }
186 if status.success() && answer.trim() == subc_os::privacy_identity::TRAMPOLINE_PROBE {
187 return Ok(());
188 }
189 let mut diagnostic = String::new();
190 if let Some(stderr) = probe.stderr.take() {
191 let _ = stderr.take(1024).read_to_string(&mut diagnostic);
192 }
193 let cause = diagnostic
194 .trim()
195 .strip_prefix("ck-subc: own privacy identity refused: ")
196 .unwrap_or("binary does not implement the privacy trampoline protocol");
197 Err(format!(
198 "{cause}: probe of {} exited {status}",
199 path.display()
200 ))
201}
202
203#[cfg(target_os = "macos")]
204fn privacy_command(
205 spec: &ModuleSpec,
206 roster: &ChildRoster,
207) -> Result<
208 (
209 Command,
210 Option<PrivacyExec>,
211 subc_os::privacy_identity::ExecAcknowledgement,
212 ),
213 SuperviseError,
214> {
215 let failure = |cause: String| {
216 warn!(module_id = %spec.module_id, %cause, "privacy identity trampoline refused module spawn");
217 SuperviseError::Spawn {
218 program: spec.program.clone(),
219 source: io::Error::other(cause),
220 cgroup_path: None,
221 }
222 };
223 let trampoline = roster.privacy_trampoline().map_err(failure)?;
224 let program = if spec.program.components().count() == 1 && !spec.program.is_absolute() {
229 let path = spec
230 .env
231 .iter()
232 .find(|(key, _)| key == "PATH")
233 .map(|(_, value)| std::ffi::OsString::from(value))
234 .or_else(|| std::env::var_os("PATH"))
235 .unwrap_or_else(|| "/usr/bin:/bin".into());
236 std::env::split_paths(&path)
237 .map(|dir| dir.join(&spec.program))
238 .find(|path| path.is_file())
239 .unwrap_or_else(|| spec.program.clone())
240 } else {
241 spec.program.clone()
242 };
243 let expected = subc_os::file_identity(&program);
244 let trampoline_image = subc_os::file_identity(&trampoline);
245 let script = {
246 use std::io::Read;
247 let mut prefix = [0u8; 2];
248 std::fs::File::open(&program)
249 .is_ok_and(|mut file| file.read_exact(&mut prefix).is_ok() && &prefix == b"#!")
250 };
251 if expected.is_none() || expected == subc_os::file_identity(&trampoline) {
252 return Err(failure(
253 "privacy identity module executable is missing or is the trampoline itself".to_string(),
254 ));
255 }
256 let (reader, ack) = subc_os::privacy_identity::ExecAcknowledgement::pipe()
257 .map_err(|error| failure(error.to_string()))?;
258 let reader =
259 tokio::io::unix::AsyncFd::new(reader).map_err(|error| failure(error.to_string()))?;
260 let mut command = Command::new(&trampoline);
261 command
262 .arg("__disclaim-exec")
263 .arg(ack.fd().to_string())
264 .arg(&program);
265 ack.install(command.as_std_mut());
266 Ok((
267 command,
268 Some(PrivacyExec {
269 reader,
270 deadline: tokio::time::Instant::now() + Duration::from_secs(5),
271 expected,
272 trampoline: trampoline_image,
273 script,
274 module_id: spec.module_id.clone(),
275 }),
276 ack,
277 ))
278}
279
280struct SupervisedChild {
281 child: Child,
282 #[cfg(windows)]
283 nonce_delivery: Option<subc_os::LaunchNoncePipeDelivery>,
284 #[cfg(target_os = "macos")]
285 privacy_exec: Option<PrivacyExec>,
286 #[cfg(target_os = "macos")]
293 report_ready: Arc<OnceLock<()>>,
294 spawn_failure: Option<String>,
296 protocol: ModuleProtocol,
301 #[cfg(target_os = "linux")]
307 module_id: String,
308 #[cfg(target_os = "linux")]
309 cgroup_placement: Option<subc_cgroup::Placement>,
310 #[cfg(windows)]
332 job: Option<subc_jobobject::JobObject>,
333 stdout_pump: Option<JoinHandle<()>>,
334 stderr_pump: Option<StderrPump>,
335 stderr_ring: Arc<Mutex<StderrRing>>,
336 spawned_at_ms: u64,
337 spawned_from: PathBuf,
338 spawned_file_identity: Option<SpawnedFileIdentity>,
339 process_start_time: Option<u64>,
340 process_identity: Option<ProcessIdentity>,
341 pid: u32,
342 roster_guard: Option<crate::child_roster::RosterGuard>,
345}
346
347impl SupervisedChild {
348 fn id(&self) -> Option<u32> {
349 Some(self.pid)
350 }
351
352 fn process_identity(&self) -> Option<ProcessIdentity> {
353 self.process_identity
354 }
355
356 async fn wait(&mut self) -> io::Result<ExitStatus> {
357 #[cfg(target_os = "macos")]
358 self.confirm_privacy_exec().await;
359 let result = self.child.wait().await;
367 #[cfg(target_os = "linux")]
368 if result.is_ok() {
369 if let Some(placement) = self.cgroup_placement.as_ref() {
370 cleanup_reaped_cgroup(placement, &self.module_id).await;
371 self.cgroup_placement = None;
374 }
375 }
376 result
377 }
378
379 #[cfg(target_os = "macos")]
380 async fn confirm_privacy_exec(&mut self) {
381 let Some(pending) = &mut self.privacy_exec else {
382 return;
383 };
384 let result = tokio::time::timeout_at(pending.deadline, async {
385 let mut record = Vec::new();
386 loop {
387 let mut ready = pending.reader.readable().await?;
388 let read = ready.try_io(|reader| {
389 use std::io::Read;
390 let mut reader = reader.get_ref();
391 let mut buffer = [0u8; 256];
392 reader.read(&mut buffer).map(|count| (count, buffer))
393 });
394 match read {
395 Ok(Ok((0, _))) => return Ok::<_, io::Error>(record),
396 Ok(Ok((count, buffer))) => {
397 if record.len() + count > 1024 {
398 return Err(io::Error::other(
399 "privacy exec refusal record is too long",
400 ));
401 }
402 record.extend_from_slice(&buffer[..count]);
403 }
404 Ok(Err(error)) => return Err(error),
405 Err(_) => continue,
406 }
407 }
408 })
409 .await;
410 let pending = self.privacy_exec.as_ref().expect("pending exec");
413 let cause = match result {
414 Err(_) => Some("privacy identity trampoline did not exec within 5s".to_string()),
415 Ok(Err(error)) => Some(format!(
416 "privacy identity exec acknowledgement failed: {error}"
417 )),
418 Ok(Ok(record)) if !record.is_empty() => Some(
419 std::str::from_utf8(&record)
420 .ok()
421 .and_then(|record| {
422 record
423 .trim()
424 .strip_prefix(subc_os::privacy_identity::EXEC_REFUSAL_TAG)
425 })
426 .filter(|cause| !cause.is_empty())
427 .unwrap_or("invalid privacy exec refusal record")
428 .to_string(),
429 ),
430 Ok(Ok(_)) => match self.child.try_wait() {
431 Ok(Some(_status)) => None,
435 Err(error) => Some(format!("privacy identity trampoline wait failed: {error}")),
436 Ok(None) => {
437 let image = observe_spawned_image(self.pid);
438 if let Some(image) = image.filter(|image| {
439 image.executable.is_some()
440 && image.executable != pending.trampoline
441 && (image.executable == pending.expected || pending.script)
442 }) {
443 if let Some(guard) = &self.roster_guard {
444 guard.confirm_executable(image);
445 }
446 let _ = self.report_ready.set(());
447 info!(module_id = %pending.module_id, pid = self.pid,
448 "module spawned with own privacy identity (responsibility disclaimed)");
449 None
450 } else if image.is_none()
451 || image.is_some_and(|image| image.executable.is_none())
452 {
453 match tokio::time::timeout_at(pending.deadline, self.child.wait()).await {
460 Ok(Ok(_status)) => None,
461 Ok(Err(error)) => Some(format!("privacy identity trampoline wait failed: {error}")),
462 Err(_) => Some("privacy identity module executable remained unreadable until the 5s exec deadline".to_string()),
463 }
464 } else {
465 Some("privacy identity trampoline executable mismatch: expected the module program, not ck-subc".to_string())
466 }
467 }
468 },
469 };
470 let pending = self.privacy_exec.take().expect("pending exec");
471 if let Some(cause) = cause {
472 warn!(module_id = %pending.module_id, pid = self.pid, %cause, "privacy identity trampoline refused module spawn");
473 self.spawn_failure = Some(cause);
474 if let Some(pid) = rustix::process::Pid::from_raw(self.pid as i32) {
477 let _ = rustix::process::kill_process_group(pid, rustix::process::Signal::KILL);
478 }
479 let _ = self.child.start_kill();
480 }
481 }
482
483 fn release_roster(&mut self) {
487 self.roster_guard = None;
488 }
489
490 fn start_kill(&mut self) -> io::Result<()> {
503 #[cfg(windows)]
504 if let Some(job) = &self.job {
505 if let Err(error) = job.terminate() {
506 debug!(
507 error = %error,
508 "job termination failed; the direct-child kill still owns the outcome"
509 );
510 }
511 }
512 #[cfg(target_os = "linux")]
513 kill_module_cgroup(self.cgroup_placement.as_ref(), &self.module_id);
514 self.child.start_kill()
515 }
516
517 async fn drain_stderr(&mut self, module_id: &str) {
518 if let Some(pump) = self.stdout_pump.take() {
519 settle_stdout_pump(module_id, pump, STDERR_PUMP_DRAIN_TIMEOUT).await;
520 }
521
522 let Some(pump) = self.stderr_pump.take() else {
523 return;
524 };
525 settle_stderr_pump(
526 module_id,
527 &self.stderr_ring,
528 pump,
529 STDERR_PUMP_DRAIN_TIMEOUT,
530 )
531 .await;
532 }
533}
534
535async fn settle_stdout_pump(module_id: &str, mut pump: JoinHandle<()>, bound: Duration) {
539 match timeout(bound, &mut pump).await {
540 Ok(Ok(())) => {}
541 Ok(Err(error)) => {
542 warn!(module_id, error = %error, "stdout pump ended unexpectedly");
543 }
544 Err(_) => {
545 drop(pump);
546 warn!(
547 module_id,
548 waited = ?bound,
549 "stdout pipe of the exited process has not drained; its reader keeps running without delaying the restart"
550 );
551 }
552 }
553}
554
555struct StderrPump {
558 task: JoinHandle<()>,
559 generation: u64,
560}
561
562async fn settle_stderr_pump(
568 module_id: &str,
569 ring: &Arc<Mutex<StderrRing>>,
570 pump: StderrPump,
571 bound: Duration,
572) {
573 let lock = || ring.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
574 let StderrPump {
575 mut task,
576 generation,
577 } = pump;
578 lock().retire_pump(generation);
579 match timeout(bound, &mut task).await {
580 Ok(Ok(())) => {}
581 Ok(Err(err)) => {
582 let mut ring = lock();
583 ring.mark_incomplete_from(generation, format!("stderr pump ended unexpectedly: {err}"));
584 ring.finish_pump(generation);
585 warn!(module_id, error = %err, "stderr pump ended before clean EOF");
586 }
587 Err(_) => {
588 drop(task);
590 lock().mark_pump_late(
591 generation,
592 format!(
593 "stderr of the exited process had not reached EOF {bound:?} after it was \
594 retired (a descendant may still hold the pipe open); lines it still \
595 writes are kept in that process's section"
596 ),
597 );
598 warn!(
599 module_id,
600 waited = ?bound,
601 "stderr pipe of the exited process is still open; its reader keeps running without delaying the restart"
602 );
603 }
604 }
605}
606
607fn registration_release_events() -> &'static watch::Sender<u64> {
608 static EVENTS: OnceLock<watch::Sender<u64>> = OnceLock::new();
609 EVENTS.get_or_init(|| {
610 let (sender, _receiver) = watch::channel(0);
611 sender
612 })
613}
614
615pub(crate) fn notify_registration_release() {
616 let events = registration_release_events();
617 let next_generation = (*events.borrow()).wrapping_add(1);
618 events.send_replace(next_generation);
619}
620
621#[derive(Debug, Clone, PartialEq, Eq)]
623pub struct ModuleSpec {
624 pub module_id: String,
625 pub program: PathBuf,
626 pub args: Vec<String>,
627 pub env: Vec<(String, String)>,
628 pub reserved: bool,
633 pub reserved_prefixes: Vec<String>,
638 pub protocol: ModuleProtocol,
657 pub overlap: ModuleOverlap,
662}
663
664#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
671pub enum ModuleOverlap {
672 #[default]
674 Exclusive,
675 Safe,
687}
688
689impl ModuleOverlap {
690 pub fn as_str(self) -> &'static str {
691 match self {
692 Self::Exclusive => "exclusive",
693 Self::Safe => "safe",
694 }
695 }
696}
697
698pub const SUBC_SPAWN_ROLE_ENV: &str = "SUBC_SPAWN_ROLE";
708pub const SPAWN_ROLE_SWAP_CANDIDATE: &str = "swap_candidate";
710pub const DEFAULT_SWAP_READY_TIMEOUT: Duration = Duration::from_secs(100);
715
716#[derive(Debug, Clone, Copy, PartialEq, Eq)]
734pub struct RestartPolicy {
735 pub max_restarts: u32,
736 pub backoff: Duration,
739 pub max_backoff: Duration,
741 pub window: Duration,
745}
746
747impl RestartPolicy {
748 pub fn new(max_restarts: u32, backoff: Duration) -> Self {
752 Self {
753 max_restarts,
754 backoff,
755 max_backoff: DEFAULT_MAX_BACKOFF,
756 window: DEFAULT_RESTART_WINDOW,
757 }
758 }
759
760 pub fn with_max_backoff(mut self, max_backoff: Duration) -> Self {
761 self.max_backoff = max_backoff;
762 self
763 }
764
765 pub fn with_window(mut self, window: Duration) -> Self {
766 self.window = window;
767 self
768 }
769
770 fn delay_for_restart(&self, restart_in_window: u32) -> Duration {
775 if self.backoff.is_zero() || self.max_backoff.is_zero() {
776 return Duration::ZERO;
777 }
778
779 let mut delay = self.backoff;
780 for _ in 0..restart_in_window {
781 if delay >= self.max_backoff {
782 return self.max_backoff;
783 }
784 delay = delay
785 .checked_mul(10)
786 .unwrap_or(self.max_backoff)
787 .min(self.max_backoff);
788 }
789 delay.min(self.max_backoff)
790 }
791
792 fn budget_exhausted_detail(&self) -> String {
797 format!(
798 "crash budget exhausted: max_restarts={} within window_secs={}",
799 self.max_restarts,
800 self.window.as_secs()
801 )
802 }
803}
804
805impl Default for RestartPolicy {
806 fn default() -> Self {
807 Self {
808 max_restarts: DEFAULT_MAX_RESTARTS,
809 backoff: DEFAULT_BACKOFF,
810 max_backoff: DEFAULT_MAX_BACKOFF,
811 window: DEFAULT_RESTART_WINDOW,
812 }
813 }
814}
815
816#[derive(Debug, Clone, Copy, PartialEq, Eq)]
817struct CrashRestartSchedule {
818 restart_in_window: u32,
819 delay: Duration,
820}
821
822fn daemon_will_restart(
829 state: &mut SupervisorSnapshot,
830 policy: &RestartPolicy,
831 now: Instant,
832) -> bool {
833 state.enabled && state.crash_restarts_in_window(policy.window, now) < policy.max_restarts
834}
835
836const DEFAULT_HEALTH_CADENCE: Duration = Duration::from_secs(30);
837const DEFAULT_HEALTH_DEADLINE: Duration = Duration::from_secs(5);
838const DEFAULT_HEALTH_FAILURE_THRESHOLD: u32 = 3;
839const MAX_HEALTH_METRICS_BYTES: usize = 16 * 1024;
840
841#[derive(Debug, Clone, Copy, PartialEq, Eq)]
842pub enum HealthAction {
843 Report,
844 Restart,
845 Alert,
846}
847
848impl fmt::Display for HealthAction {
849 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
850 f.write_str(match self {
851 Self::Report => "report",
852 Self::Restart => "restart",
853 Self::Alert => "alert",
854 })
855 }
856}
857
858#[derive(Debug, Clone, PartialEq, Eq)]
859pub struct HealthConfig {
860 pub http: Option<String>,
864 pub cadence: Duration,
865 pub deadline: Duration,
866 pub failure_threshold: u32,
867 pub on_degraded: HealthAction,
868 pub on_failing: HealthAction,
869 pub critical: bool,
870}
871
872impl Default for HealthConfig {
873 fn default() -> Self {
874 Self {
875 http: None,
876 cadence: DEFAULT_HEALTH_CADENCE,
877 deadline: DEFAULT_HEALTH_DEADLINE,
878 failure_threshold: DEFAULT_HEALTH_FAILURE_THRESHOLD,
879 on_degraded: HealthAction::Report,
880 on_failing: HealthAction::Report,
881 critical: false,
882 }
883 }
884}
885
886#[derive(Debug, Clone, PartialEq)]
904pub struct ModuleHealthStatus {
905 pub status: SupervisorHealthStatus,
906 pub last_probe_ms: Option<u64>,
907 pub detail: Option<String>,
908 pub metrics: Option<Value>,
909 pub consecutive_failures: u32,
910 pub late_answer_count: u64,
913 pub last_late_answer_latency_ms: Option<u64>,
915 pub last_action: Option<String>,
916 pub last_action_ms: Option<u64>,
920}
921
922impl Default for ModuleHealthStatus {
923 fn default() -> Self {
924 Self {
925 status: SupervisorHealthStatus::Unknown,
926 last_probe_ms: None,
927 detail: None,
928 metrics: None,
929 consecutive_failures: 0,
930 late_answer_count: 0,
931 last_late_answer_latency_ms: None,
932 last_action: None,
933 last_action_ms: None,
934 }
935 }
936}
937
938#[derive(Debug, Clone, Copy, PartialEq, Eq)]
940pub enum ModuleState {
941 Starting,
942 Running,
943 Unresponsive,
944 Restarting,
945 Draining,
946 Stopped,
947 Failed,
948 Disabled,
949}
950
951impl fmt::Display for ModuleState {
952 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
953 f.write_str(match self {
954 Self::Starting => "starting",
955 Self::Running => "running",
956 Self::Unresponsive => "unresponsive",
957 Self::Restarting => "restarting",
958 Self::Draining => "draining",
959 Self::Stopped => "stopped",
960 Self::Failed => "failed",
961 Self::Disabled => "disabled",
962 })
963 }
964}
965
966#[derive(Debug, Clone, Copy, PartialEq, Eq)]
968pub enum ExitKind {
969 Clean,
970 Crash,
971 DeliberateSeverance,
972}
973
974impl From<ExitKind> for TerminalExitKind {
975 fn from(kind: ExitKind) -> Self {
976 match kind {
977 ExitKind::Clean => Self::Clean,
978 ExitKind::Crash => Self::Crash,
979 ExitKind::DeliberateSeverance => Self::DeliberateSeverance,
980 }
981 }
982}
983
984#[derive(Debug, Clone, Copy, PartialEq, Eq)]
987pub(crate) struct ProcessIdentity {
988 pub(crate) pid: u32,
989 pub(crate) start_time: u64,
990}
991
992#[derive(Debug, Clone, PartialEq, Eq)]
994pub struct ExitReport {
995 pub kind: ExitKind,
996 pub code: Option<i32>,
997 pub signal: Option<i32>,
998 pub at_ms: u64,
999}
1000
1001#[derive(Debug, Clone, PartialEq)]
1004pub struct ModuleStatus {
1005 pub module_id: String,
1006 pub waiting_for_previous_pid: Option<u32>,
1008 pub launch_nonce_source: Option<String>,
1013 pub state: ModuleState,
1014 pub enabled: bool,
1015 pub process_alive: bool,
1016 pub registration_active: bool,
1017 pub protocol: ModuleProtocol,
1022 pub live: bool,
1033 pub restart_count: u32,
1037 pub lifetime_restarts: u32,
1041 pub spawn_generation: u64,
1042 pub max_restarts: u32,
1047 pub restart_window: Duration,
1051 pub drain_timeout: Duration,
1055 pub restart_backoff: Duration,
1056 pub restart_max_backoff: Duration,
1057 pub pid: Option<u32>,
1062 pub spawned_at_ms: Option<u64>,
1063 pub spawned_from: Option<PathBuf>,
1064 pub process_start_time: Option<u64>,
1065 pub last_exit: Option<ExitReport>,
1066 pub health: ModuleHealthStatus,
1067}
1068
1069#[cfg(test)]
1070#[derive(Debug, Clone, Copy, PartialEq)]
1071struct ActorSelectCheckpoint {
1072 generation: u64,
1073 turn: u64,
1074 registered_connection: Option<ConnectionId>,
1075 next_probe_at: Option<Instant>,
1076 wake_after: Option<Duration>,
1077}
1078
1079#[derive(Debug, Clone, PartialEq)]
1080struct SupervisorSnapshot {
1081 #[cfg(windows)]
1082 pipe_consumed: Option<Arc<OnceLock<()>>>,
1083 #[cfg(test)]
1085 actor_turns: u64,
1086 #[cfg(test)]
1089 actor_select: Option<ActorSelectCheckpoint>,
1090 state: ModuleState,
1091 enabled: bool,
1092 process_alive: bool,
1093 spawned_protocol: Option<ModuleProtocol>,
1094 spawn_failure: Option<String>,
1095 waiting_for_previous_pid: Option<u32>,
1096 crash_restarts: VecDeque<Instant>,
1102 lifetime_restarts: u32,
1103 spawn_generation: u64,
1112 pid: Option<u32>,
1113 #[cfg(target_os = "macos")]
1114 report_ready: Option<Arc<OnceLock<()>>>,
1115 reaped_pid: Option<u32>,
1117 respawn_pending: bool,
1119 coalesced_restart_pending: bool,
1121 spawned_at_ms: Option<u64>,
1122 spawned_from: Option<PathBuf>,
1123 spawned_file_identity: Option<SpawnedFileIdentity>,
1124 process_start_time: Option<u64>,
1125 deliberate_severance: Option<ProcessIdentity>,
1126 last_exit: Option<ExitReport>,
1127 drain_disposition_detail: Option<String>,
1129 health: ModuleHealthStatus,
1130 in_alternate_slot: bool,
1135 draining_to_replace: bool,
1142 configuration_updated_since_spawn: bool,
1148}
1149
1150impl SupervisorSnapshot {
1151 fn reported_pid(&self) -> Option<u32> {
1156 #[cfg(target_os = "macos")]
1157 if self
1158 .report_ready
1159 .as_ref()
1160 .is_some_and(|ready| ready.get().is_none())
1161 {
1162 return None;
1163 }
1164 self.pid
1165 }
1166
1167 fn starting() -> Self {
1168 Self::new(ModuleState::Starting, true)
1169 }
1170
1171 fn disabled() -> Self {
1172 Self::new(ModuleState::Disabled, false)
1173 }
1174
1175 fn failed() -> Self {
1176 Self::new(ModuleState::Failed, true)
1177 }
1178
1179 fn crash_restarts_in_window(&mut self, window: Duration, now: Instant) -> u32 {
1183 while let Some(oldest) = self.crash_restarts.front() {
1184 if now.duration_since(*oldest) > window {
1185 self.crash_restarts.pop_front();
1186 } else {
1187 break;
1188 }
1189 }
1190 u32::try_from(self.crash_restarts.len()).unwrap_or(u32::MAX)
1191 }
1192
1193 fn record_crash_restart(&mut self, policy: &RestartPolicy, now: Instant) {
1199 self.crash_restarts.push_back(now);
1200 while self.crash_restarts.len() > policy.max_restarts as usize {
1201 self.crash_restarts.pop_front();
1202 }
1203 self.lifetime_restarts += 1;
1204 }
1205
1206 fn next_crash_restart(
1210 &mut self,
1211 policy: &RestartPolicy,
1212 now: Instant,
1213 ) -> Option<CrashRestartSchedule> {
1214 let restart_in_window = self.crash_restarts_in_window(policy.window, now);
1215 if restart_in_window >= policy.max_restarts {
1216 return None;
1217 }
1218 self.record_crash_restart(policy, now);
1219 Some(CrashRestartSchedule {
1220 restart_in_window,
1221 delay: policy.delay_for_restart(restart_in_window),
1222 })
1223 }
1224
1225 fn clear_crash_restarts(&mut self) {
1230 self.crash_restarts.clear();
1231 }
1232
1233 fn new(state: ModuleState, enabled: bool) -> Self {
1234 Self {
1235 #[cfg(test)]
1236 actor_turns: 0,
1237 #[cfg(test)]
1238 actor_select: None,
1239 state,
1240 enabled,
1241 process_alive: false,
1242 #[cfg(windows)]
1243 pipe_consumed: None,
1244 spawned_protocol: None,
1245 spawn_failure: None,
1246 waiting_for_previous_pid: None,
1247 crash_restarts: VecDeque::new(),
1248 lifetime_restarts: 0,
1249 spawn_generation: 0,
1250 pid: None,
1251 #[cfg(target_os = "macos")]
1252 report_ready: None,
1253 reaped_pid: None,
1254 respawn_pending: false,
1255 coalesced_restart_pending: false,
1256 spawned_at_ms: None,
1257 spawned_from: None,
1258 spawned_file_identity: None,
1259 process_start_time: None,
1260 deliberate_severance: None,
1261 last_exit: None,
1262 drain_disposition_detail: None,
1263 health: ModuleHealthStatus::default(),
1264 in_alternate_slot: false,
1265 draining_to_replace: false,
1266 configuration_updated_since_spawn: false,
1267 }
1268 }
1269}
1270
1271type SharedSnapshot = Arc<Mutex<SupervisorSnapshot>>;
1272
1273type SpawnSubscriberKey = (ConnectionId, u64);
1274
1275#[derive(Debug)]
1276struct SpawnSubscriber {
1277 version: u8,
1278 frames: mpsc::Sender<Frame>,
1279 lagged: Option<oneshot::Sender<SpawnCursor>>,
1283}
1284
1285#[derive(Debug)]
1286struct SpawnEventState {
1287 daemon_incarnation: String,
1288 seq: u64,
1289 capacity: usize,
1290 live: HashMap<String, LiveSpawn>,
1291 generations: HashMap<String, u64>,
1292 events: VecDeque<SpawnEvent>,
1293 subscribers: HashMap<SpawnSubscriberKey, SpawnSubscriber>,
1294}
1295
1296impl Default for SpawnEventState {
1297 fn default() -> Self {
1298 Self {
1299 daemon_incarnation: "unconfigured".to_string(),
1300 seq: 0,
1301 capacity: SPAWN_EVENT_RING_CAPACITY,
1302 live: HashMap::new(),
1303 generations: HashMap::new(),
1304 events: VecDeque::new(),
1305 subscribers: HashMap::new(),
1306 }
1307 }
1308}
1309
1310#[derive(Debug, Clone, Default)]
1311struct SpawnEventFeed(
1312 Arc<Mutex<SpawnEventState>>,
1313 #[cfg(test)] Arc<Mutex<Option<std::net::TcpStream>>>,
1314);
1315
1316#[derive(Debug, Clone, PartialEq, Eq)]
1317pub(crate) enum SpawnSubscribeRefusal {
1318 ForeignIncarnation { current: String },
1319 TooOld { oldest: SpawnCursor },
1320 Frame(String),
1321}
1322
1323impl SpawnEventFeed {
1324 fn configure_incarnation(&self, daemon_incarnation: String) {
1325 let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1326 state.daemon_incarnation = daemon_incarnation;
1327 state.seq = 0;
1328 state.live.clear();
1329 state.generations.clear();
1330 state.events.clear();
1331 state.subscribers.clear();
1332 }
1333
1334 fn cursor(state: &SpawnEventState) -> SpawnCursor {
1335 SpawnCursor {
1336 daemon_incarnation: state.daemon_incarnation.clone(),
1337 seq: state.seq,
1338 }
1339 }
1340
1341 fn snapshot(&self) -> SpawnSnapshot {
1342 let state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1343 let mut live = state.live.values().cloned().collect::<Vec<_>>();
1344 live.sort_by(|left, right| left.module_id.cmp(&right.module_id));
1345 SpawnSnapshot {
1346 cursor: Self::cursor(&state),
1347 ring_bound: state.capacity as u64,
1348 live,
1349 }
1350 }
1351
1352 fn emit_spawned(&self, module_id: &str, pid: u32, spawned_at_ms: u64) -> u64 {
1353 let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1354 let generation = state
1355 .generations
1356 .get(module_id)
1357 .copied()
1358 .unwrap_or(0)
1359 .checked_add(1)
1360 .expect("spawn generation exhausted");
1361 state.generations.insert(module_id.to_string(), generation);
1362 let live = LiveSpawn {
1363 module_id: module_id.to_string(),
1364 spawn_generation: generation,
1365 pid,
1366 spawned_at_ms,
1367 };
1368 state.live.insert(module_id.to_string(), live);
1369 Self::emit_locked(
1370 &mut state,
1371 SpawnEventKind::Spawned,
1372 module_id.to_string(),
1373 generation,
1374 pid,
1375 None,
1376 None,
1377 );
1378 generation
1379 }
1380
1381 fn emit_exited(&self, module_id: &str, exit_code: Option<i32>, exit_signal: Option<i32>) {
1382 let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1383 let Some(live) = state.live.remove(module_id) else {
1384 warn!(
1385 module_id,
1386 "terminal record had no live spawn event identity"
1387 );
1388 return;
1389 };
1390 Self::emit_locked(
1391 &mut state,
1392 SpawnEventKind::Exited,
1393 module_id.to_string(),
1394 live.spawn_generation,
1395 live.pid,
1396 exit_code,
1397 exit_signal,
1398 );
1399 }
1400
1401 fn emit_superseded_exited(
1408 &self,
1409 module_id: &str,
1410 spawn_generation: u64,
1411 pid: u32,
1412 exit_code: Option<i32>,
1413 exit_signal: Option<i32>,
1414 ) {
1415 let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1416 if state
1417 .live
1418 .get(module_id)
1419 .is_some_and(|live| live.spawn_generation == spawn_generation)
1420 {
1421 state.live.remove(module_id);
1422 }
1423 Self::emit_locked(
1424 &mut state,
1425 SpawnEventKind::Exited,
1426 module_id.to_string(),
1427 spawn_generation,
1428 pid,
1429 exit_code,
1430 exit_signal,
1431 );
1432 }
1433
1434 #[allow(clippy::too_many_arguments)]
1435 fn emit_locked(
1436 state: &mut SpawnEventState,
1437 kind: SpawnEventKind,
1438 module_id: String,
1439 spawn_generation: u64,
1440 pid: u32,
1441 exit_code: Option<i32>,
1442 exit_signal: Option<i32>,
1443 ) {
1444 state.seq = state
1445 .seq
1446 .checked_add(1)
1447 .expect("spawn event sequence exhausted");
1448 let event = SpawnEvent {
1449 cursor: Self::cursor(state),
1450 kind,
1451 module_id,
1452 spawn_generation,
1453 pid,
1454 exit_code,
1455 exit_signal,
1456 };
1457 state.events.push_back(event.clone());
1458 while state.events.len() > state.capacity {
1459 state.events.pop_front();
1460 }
1461 let body = match serde_json::to_vec(&event) {
1462 Ok(body) => body,
1463 Err(error) => {
1464 error!(%error, "failed to serialize supervisor spawn event");
1465 return;
1466 }
1467 };
1468 state.subscribers.retain(|(connection_id, corr), subscriber| {
1469 let frame = Frame::build_with_version(
1470 subscriber.version,
1471 FrameType::StreamData,
1472 control_flags(),
1473 0,
1474 0,
1475 *corr,
1476 body.clone(),
1477 );
1478 match frame {
1479 Ok(frame) => {
1480 if subscriber.frames.try_send(frame).is_ok() {
1481 true
1482 } else {
1483 warn!(connection_id = connection_id.get(), corr, "dropping lagged supervisor spawn subscriber");
1484 if let Some(lagged) = subscriber.lagged.take() {
1485 let _ = lagged.send(event.cursor.clone());
1486 }
1487 false
1488 }
1489 }
1490 Err(error) => {
1491 warn!(connection_id = connection_id.get(), corr, %error, "dropping supervisor spawn subscriber after frame build failure");
1492 false
1493 }
1494 }
1495 });
1496 }
1497
1498 fn subscribe(
1499 &self,
1500 connection_id: ConnectionId,
1501 corr: u64,
1502 version: u8,
1503 since: Option<SpawnCursor>,
1504 sink: FrameSink,
1505 ) -> Result<(), SpawnSubscribeRefusal> {
1506 let (frames, mut receiver) = mpsc::channel(SPAWN_SUBSCRIBER_BUFFER);
1507 let (lagged, mut lagged_rx) = oneshot::channel::<SpawnCursor>();
1508 {
1509 let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1510 let replay = if let Some(since) = since {
1511 if since.daemon_incarnation != state.daemon_incarnation {
1512 return Err(SpawnSubscribeRefusal::ForeignIncarnation {
1513 current: state.daemon_incarnation.clone(),
1514 });
1515 }
1516 if let Some(oldest) = state.events.front().map(|event| event.cursor.clone()) {
1517 if since.seq < oldest.seq.saturating_sub(1) {
1518 return Err(SpawnSubscribeRefusal::TooOld { oldest });
1519 }
1520 }
1521 state
1522 .events
1523 .iter()
1524 .filter(|event| event.cursor.seq > since.seq)
1525 .cloned()
1526 .collect::<Vec<_>>()
1527 } else {
1528 Vec::new()
1529 };
1530 for event in replay {
1531 let body = serde_json::to_vec(&event)
1532 .map_err(|error| SpawnSubscribeRefusal::Frame(error.to_string()))?;
1533 let frame = Frame::build_with_version(
1534 version,
1535 FrameType::StreamData,
1536 control_flags(),
1537 0,
1538 0,
1539 corr,
1540 body,
1541 )
1542 .map_err(|error| SpawnSubscribeRefusal::Frame(error.to_string()))?;
1543 frames
1544 .try_send(frame)
1545 .map_err(|error| SpawnSubscribeRefusal::Frame(error.to_string()))?;
1546 }
1547 state.subscribers.insert(
1548 (connection_id, corr),
1549 SpawnSubscriber {
1550 version,
1551 frames,
1552 lagged: Some(lagged),
1553 },
1554 );
1555 }
1556 tokio::spawn(async move {
1567 while let Some(frame) = receiver.recv().await {
1568 if sink.send(frame).await.is_err() {
1569 return;
1570 }
1571 }
1572 let Ok(first_undelivered) = lagged_rx.try_recv() else {
1573 return;
1574 };
1575 match spawn_subscriber_lagged_frame(version, corr, first_undelivered) {
1576 Ok(frame) => {
1577 let _ = sink.send(frame).await;
1578 }
1579 Err(error) => {
1580 error!(%error, corr, "failed to build lagged spawn subscriber terminal frame");
1581 }
1582 }
1583 });
1584 Ok(())
1585 }
1586
1587 fn cancel(&self, connection_id: ConnectionId, corr: u64) -> bool {
1588 let Some(subscriber) = self
1589 .0
1590 .lock()
1591 .unwrap_or_else(|p| p.into_inner())
1592 .subscribers
1593 .remove(&(connection_id, corr))
1594 else {
1595 return false;
1596 };
1597 if let Ok(frame) = Frame::build_with_version(
1598 subscriber.version,
1599 FrameType::StreamEnd,
1600 control_flags(),
1601 0,
1602 0,
1603 corr,
1604 Vec::new(),
1605 ) {
1606 tokio::spawn(async move {
1607 let _ = subscriber.frames.send(frame).await;
1608 });
1609 }
1610 true
1611 }
1612
1613 fn remove_connection(&self, connection_id: ConnectionId) {
1614 self.0
1615 .lock()
1616 .unwrap_or_else(|p| p.into_inner())
1617 .subscribers
1618 .retain(|(subscriber_connection, _), _| *subscriber_connection != connection_id);
1619 }
1620
1621 #[cfg(any(test, feature = "test-support"))]
1622 fn set_capacity(&self, capacity: usize) {
1623 self.0.lock().unwrap_or_else(|p| p.into_inner()).capacity = capacity;
1624 }
1625
1626 #[cfg(any(test, feature = "test-support"))]
1627 fn subscriber_count(&self) -> usize {
1628 self.0
1629 .lock()
1630 .unwrap_or_else(|p| p.into_inner())
1631 .subscribers
1632 .len()
1633 }
1634}
1635
1636fn spawn_subscriber_lagged_frame(
1639 version: u8,
1640 corr: u64,
1641 first_undelivered: SpawnCursor,
1642) -> Result<Frame, String> {
1643 let body = serde_json::to_vec(&subc_protocol::ErrorBody {
1644 code: SPAWN_SUBSCRIBER_LAGGED_CODE.to_string(),
1645 message: "spawn subscriber fell behind and was dropped; resubscribe from the last cursor received"
1646 .to_string(),
1647 detail: Some(serde_json::json!({
1648 "first_undelivered_cursor": first_undelivered
1649 })),
1650 })
1651 .map_err(|error| error.to_string())?;
1652 Frame::build_with_version(version, FrameType::Error, control_flags(), 0, 0, corr, body)
1653 .map_err(|error| error.to_string())
1654}
1655
1656pub trait ModuleProcessLiveness: Send + Sync {
1657 fn process_live(&self, module_id: &str) -> Option<bool>;
1658
1659 fn process_replacing(&self, _module_id: &str) -> bool {
1665 false
1666 }
1667}
1668
1669#[derive(Debug, Clone, Default)]
1671pub struct SupervisorProcessLiveness {
1672 snapshots: Arc<Mutex<HashMap<String, SharedSnapshot>>>,
1673}
1674
1675impl SupervisorProcessLiveness {
1676 pub fn new() -> Self {
1677 Self::default()
1678 }
1679
1680 fn track(&self, module_id: String, snapshot: SharedSnapshot) {
1681 let mut snapshots = self
1682 .snapshots
1683 .lock()
1684 .unwrap_or_else(|poisoned| poisoned.into_inner());
1685 snapshots.insert(module_id, snapshot);
1686 }
1687
1688 fn untrack_if_current(&self, module_id: &str, snapshot: &SharedSnapshot) {
1689 let mut snapshots = self
1690 .snapshots
1691 .lock()
1692 .unwrap_or_else(|poisoned| poisoned.into_inner());
1693 let is_current = snapshots
1694 .get(module_id)
1695 .map(|tracked| Arc::ptr_eq(tracked, snapshot))
1696 .unwrap_or(false);
1697 if is_current {
1698 snapshots.remove(module_id);
1699 }
1700 }
1701}
1702
1703impl ModuleProcessLiveness for SupervisorProcessLiveness {
1704 fn process_live(&self, module_id: &str) -> Option<bool> {
1705 let snapshot = {
1706 let snapshots = self
1707 .snapshots
1708 .lock()
1709 .unwrap_or_else(|poisoned| poisoned.into_inner());
1710 snapshots.get(module_id).cloned()
1711 }?;
1712 let snapshot = snapshot
1713 .lock()
1714 .unwrap_or_else(|poisoned| poisoned.into_inner());
1715 Some(snapshot.state == ModuleState::Running && snapshot.process_alive)
1716 }
1717
1718 fn process_replacing(&self, module_id: &str) -> bool {
1719 let Some(snapshot) = self
1720 .snapshots
1721 .lock()
1722 .unwrap_or_else(|poisoned| poisoned.into_inner())
1723 .get(module_id)
1724 .cloned()
1725 else {
1726 return false;
1727 };
1728 let snapshot = snapshot
1729 .lock()
1730 .unwrap_or_else(|poisoned| poisoned.into_inner());
1731 snapshot.enabled
1732 && match snapshot.state {
1733 ModuleState::Restarting => true,
1734 ModuleState::Draining => snapshot.draining_to_replace,
1735 ModuleState::Starting
1736 | ModuleState::Running
1737 | ModuleState::Unresponsive
1738 | ModuleState::Stopped
1739 | ModuleState::Failed
1740 | ModuleState::Disabled => false,
1741 }
1742 }
1743}
1744
1745#[cfg(test)]
1746#[derive(Debug, Default)]
1747struct ReloadExitRecordGate {
1748 reached: tokio::sync::Notify,
1749 resume: tokio::sync::Notify,
1750}
1751
1752#[derive(Debug, Clone, Copy)]
1753enum RespawnKind {
1754 Spawn,
1755 Reload,
1756}
1757
1758#[derive(Debug, Clone, Copy)]
1759struct PendingRespawn {
1760 deadline: Instant,
1761 kind: RespawnKind,
1762}
1763
1764type ReloadReply = oneshot::Sender<Result<(), SuperviseError>>;
1765
1766#[derive(Debug, Clone)]
1767struct SupervisorRuntimeConfig {
1768 scheduled_respawn: Arc<Mutex<Option<PendingRespawn>>>,
1770 deferred_reload_reply: Arc<Mutex<Option<ReloadReply>>>,
1772 restart_policy: RestartPolicy,
1773 drain_timeout: Duration,
1776 effective_drain_timeout: Arc<Mutex<Duration>>,
1779 default_drain_timeout: Duration,
1782 health: HealthConfig,
1783 connection_file_path: Option<PathBuf>,
1784 capture_logs_dir: Option<PathBuf>,
1785 forwarding: Option<Arc<ForwardingTable>>,
1786 supervisor_handle: Option<SupervisorHandle>,
1789 stderr_ring: Arc<Mutex<StderrRing>>,
1796 terminal_ring: Arc<Mutex<TerminalRing>>,
1797 spawn_events: SpawnEventFeed,
1798 child_roster: ChildRoster,
1799 #[cfg(target_os = "linux")]
1800 cgroup_placement: Option<subc_cgroup::Placement>,
1801 #[cfg(test)]
1802 test_seed_stale_facts_before_enable_spawn: bool,
1803 #[cfg(test)]
1804 test_reload_exit_record_gate: Option<Arc<ReloadExitRecordGate>>,
1805}
1806
1807#[derive(Debug, Clone, PartialEq, Eq)]
1808struct SupervisedConfiguration {
1809 spec: ModuleSpec,
1810 health: HealthConfig,
1811}
1812
1813#[derive(Debug, Clone, Default)]
1819pub struct SupervisorHandle {
1820 modules: Arc<Mutex<HashMap<String, SupervisedModule>>>,
1821 configured_ids: Arc<Mutex<HashSet<String>>>,
1833 spawn_events: SpawnEventFeed,
1834 reserved_nonces: Arc<Mutex<HashMap<String, Option<String>>>>,
1845 removal_tombstones: Arc<Mutex<HashMap<String, u64>>>,
1851 spawn_nonces: Arc<Mutex<HashMap<String, String>>>,
1855 reserved_prefix_owners: Arc<Mutex<HashMap<String, String>>>,
1863 swaps: Arc<Mutex<HashMap<String, OpenSwap>>>,
1869 promotion_observer: PromotionObserverSlot,
1871 operation_lock: Arc<AsyncMutex<()>>,
1875}
1876
1877pub(crate) trait SwapPromotionObserver: Send + Sync {
1886 fn swap_promoted(&self, registration: &crate::registry::ModuleRegistration);
1887}
1888
1889#[derive(Clone, Default)]
1893struct PromotionObserverSlot(Arc<Mutex<Option<std::sync::Weak<dyn SwapPromotionObserver>>>>);
1894
1895impl fmt::Debug for PromotionObserverSlot {
1896 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1897 f.write_str("PromotionObserverSlot")
1898 }
1899}
1900
1901#[derive(Debug, Clone)]
1903struct OpenSwap {
1904 candidate_nonce: String,
1907 incumbent_nonce: Option<String>,
1912 candidate_admitted: bool,
1916}
1917
1918#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1921pub(crate) enum SwapHelloAdmission {
1922 NotSwapping,
1925 Candidate,
1927 Refused,
1930}
1931
1932#[derive(Debug, Clone, PartialEq, Eq)]
1933pub(crate) enum ReservedHelloRejection {
1934 Exact {
1935 module_id: String,
1936 },
1937 Prefix {
1938 prefix: String,
1939 owner_module_id: String,
1940 },
1941}
1942
1943impl SupervisorHandle {
1944 pub fn new() -> Self {
1945 Self::default()
1946 }
1947
1948 pub(crate) fn spawn_snapshot(&self) -> SpawnSnapshot {
1949 self.spawn_events.snapshot()
1950 }
1951
1952 pub(crate) fn subscribe_spawns(
1953 &self,
1954 connection_id: ConnectionId,
1955 corr: u64,
1956 version: u8,
1957 since: Option<SpawnCursor>,
1958 sink: FrameSink,
1959 ) -> Result<(), SpawnSubscribeRefusal> {
1960 self.spawn_events
1961 .subscribe(connection_id, corr, version, since, sink)
1962 }
1963
1964 pub(crate) fn cancel_spawn_subscription(&self, connection_id: ConnectionId, corr: u64) -> bool {
1965 self.spawn_events.cancel(connection_id, corr)
1966 }
1967
1968 pub(crate) fn remove_spawn_subscribers(&self, connection_id: ConnectionId) {
1969 self.spawn_events.remove_connection(connection_id);
1970 }
1971
1972 #[cfg(any(test, feature = "test-support"))]
1973 pub fn set_spawn_event_capacity_for_test(&self, capacity: usize) {
1974 assert!(capacity > 0, "spawn event capacity must be non-zero");
1975 self.spawn_events.set_capacity(capacity);
1976 }
1977
1978 #[cfg(any(test, feature = "test-support"))]
1979 pub fn spawn_subscriber_count_for_test(&self) -> usize {
1980 self.spawn_events.subscriber_count()
1981 }
1982
1983 pub fn set_spawn_nonce(&self, module_id: &str, nonce: String) {
1986 self.spawn_nonces
1987 .lock()
1988 .unwrap_or_else(|poisoned| poisoned.into_inner())
1989 .insert(module_id.to_string(), nonce);
1990 }
1991
1992 pub fn set_reserved_nonce(&self, module_id: &str, nonce: String) {
1995 self.reserved_nonces
1996 .lock()
1997 .unwrap_or_else(|poisoned| poisoned.into_inner())
1998 .insert(module_id.to_string(), Some(nonce));
1999 }
2000
2001 pub fn set_reserved_prefixes(&self, owner_module_id: &str, prefixes: &[String]) {
2003 let mut owners = self
2004 .reserved_prefix_owners
2005 .lock()
2006 .unwrap_or_else(|poisoned| poisoned.into_inner());
2007 owners.retain(|_, owner| owner != owner_module_id);
2008 for prefix in prefixes {
2009 owners.insert(prefix.clone(), owner_module_id.to_string());
2010 }
2011 }
2012
2013 #[cfg(test)]
2015 pub(crate) fn spawn_nonce(&self, module_id: &str) -> Option<String> {
2016 self.spawn_nonces
2017 .lock()
2018 .unwrap_or_else(|poisoned| poisoned.into_inner())
2019 .get(module_id)
2020 .cloned()
2021 }
2022
2023 fn apply_identity_configuration(&self, spec: &ModuleSpec) {
2024 self.set_reserved_prefixes(&spec.module_id, &spec.reserved_prefixes);
2025 let spawn_nonce = self
2026 .spawn_nonces
2027 .lock()
2028 .unwrap_or_else(|poisoned| poisoned.into_inner())
2029 .get(&spec.module_id)
2030 .cloned();
2031 let mut reserved_nonces = self
2032 .reserved_nonces
2033 .lock()
2034 .unwrap_or_else(|poisoned| poisoned.into_inner());
2035 if spec.reserved {
2036 reserved_nonces.insert(spec.module_id.clone(), spawn_nonce);
2041 }
2042 drop(reserved_nonces);
2043 self.removal_tombstones
2047 .lock()
2048 .unwrap_or_else(|poisoned| poisoned.into_inner())
2049 .remove(&spec.module_id);
2050 }
2051
2052 pub fn reserved_hello_authorized(&self, module_id: &str, presented: Option<&str>) -> bool {
2057 self.reserved_hello_rejection(module_id, presented)
2058 .is_none()
2059 }
2060
2061 pub(crate) fn reserved_hello_rejection(
2062 &self,
2063 module_id: &str,
2064 presented: Option<&str>,
2065 ) -> Option<ReservedHelloRejection> {
2066 let nonces = self
2067 .reserved_nonces
2068 .lock()
2069 .unwrap_or_else(|poisoned| poisoned.into_inner());
2070 if let Some(expected) = nonces.get(module_id) {
2071 let authorized = match expected {
2075 Some(expected) => {
2076 presented.is_some_and(|p| constant_time_eq(expected.as_bytes(), p.as_bytes()))
2077 }
2078 None => false,
2079 };
2080 if authorized {
2081 return None;
2082 }
2083 return Some(ReservedHelloRejection::Exact {
2084 module_id: module_id.to_string(),
2085 });
2086 }
2087 drop(nonces);
2088
2089 let matched_prefix = self
2090 .reserved_prefix_owners
2091 .lock()
2092 .unwrap_or_else(|poisoned| poisoned.into_inner())
2093 .iter()
2094 .filter(|(prefix, _)| module_id.starts_with(prefix.as_str()))
2095 .max_by_key(|(prefix, _)| prefix.len())
2096 .map(|(prefix, owner)| (prefix.clone(), owner.clone()));
2097 let (prefix, owner_module_id) = matched_prefix?;
2098
2099 let authorized = presented.is_some_and(|presented| {
2100 self.spawn_nonces
2101 .lock()
2102 .unwrap_or_else(|poisoned| poisoned.into_inner())
2103 .get(&owner_module_id)
2104 .is_some_and(|expected| constant_time_eq(expected.as_bytes(), presented.as_bytes()))
2105 || self.swap_nonce_matches(&owner_module_id, presented)
2108 });
2109 if authorized {
2110 None
2111 } else {
2112 Some(ReservedHelloRejection::Prefix {
2113 prefix,
2114 owner_module_id,
2115 })
2116 }
2117 }
2118
2119 pub fn spawned_consumer_authorized(&self, module_id: &str, presented: &str) -> bool {
2124 if presented.is_empty() {
2125 return false;
2126 }
2127 let nonces = self
2128 .spawn_nonces
2129 .lock()
2130 .unwrap_or_else(|poisoned| poisoned.into_inner());
2131 let current = nonces
2132 .get(module_id)
2133 .is_some_and(|expected| constant_time_eq(expected.as_bytes(), presented.as_bytes()));
2134 drop(nonces);
2135 current || self.swap_nonce_matches(module_id, presented)
2140 }
2141
2142 fn swap_nonce_matches(&self, module_id: &str, presented: &str) -> bool {
2144 let swaps = self
2145 .swaps
2146 .lock()
2147 .unwrap_or_else(|poisoned| poisoned.into_inner());
2148 swaps.get(module_id).is_some_and(|swap| {
2149 constant_time_eq(swap.candidate_nonce.as_bytes(), presented.as_bytes())
2150 || swap.incumbent_nonce.as_deref().is_some_and(|incumbent| {
2151 constant_time_eq(incumbent.as_bytes(), presented.as_bytes())
2152 })
2153 })
2154 }
2155
2156 pub(crate) fn open_swap(&self, module_id: &str, candidate_nonce: String) {
2159 let incumbent_nonce = self
2160 .spawn_nonces
2161 .lock()
2162 .unwrap_or_else(|poisoned| poisoned.into_inner())
2163 .get(module_id)
2164 .cloned();
2165 self.swaps
2166 .lock()
2167 .unwrap_or_else(|poisoned| poisoned.into_inner())
2168 .insert(
2169 module_id.to_string(),
2170 OpenSwap {
2171 candidate_nonce,
2172 incumbent_nonce,
2173 candidate_admitted: false,
2174 },
2175 );
2176 }
2177
2178 pub(crate) fn close_swap(&self, module_id: &str) {
2181 self.swaps
2182 .lock()
2183 .unwrap_or_else(|poisoned| poisoned.into_inner())
2184 .remove(module_id);
2185 }
2186
2187 pub(crate) fn set_swap_promotion_observer(
2190 &self,
2191 observer: std::sync::Weak<dyn SwapPromotionObserver>,
2192 ) {
2193 *self
2194 .promotion_observer
2195 .0
2196 .lock()
2197 .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(observer);
2198 }
2199
2200 fn notify_swap_promoted(&self, registration: &crate::registry::ModuleRegistration) {
2203 let observer = self
2204 .promotion_observer
2205 .0
2206 .lock()
2207 .unwrap_or_else(|poisoned| poisoned.into_inner())
2208 .as_ref()
2209 .and_then(std::sync::Weak::upgrade);
2210 if let Some(observer) = observer {
2211 observer.swap_promoted(registration);
2212 }
2213 }
2214
2215 pub(crate) fn swap_open(&self, module_id: &str) -> bool {
2217 self.swaps
2218 .lock()
2219 .unwrap_or_else(|poisoned| poisoned.into_inner())
2220 .contains_key(module_id)
2221 }
2222
2223 fn promote_swap_nonce(&self, module_id: &str, reserved: bool) {
2228 let candidate_nonce = self
2229 .swaps
2230 .lock()
2231 .unwrap_or_else(|poisoned| poisoned.into_inner())
2232 .get(module_id)
2233 .map(|swap| swap.candidate_nonce.clone());
2234 let Some(nonce) = candidate_nonce else {
2235 return;
2236 };
2237 self.set_spawn_nonce(module_id, nonce.clone());
2238 if reserved {
2239 self.set_reserved_nonce(module_id, nonce);
2240 }
2241 }
2242
2243 pub(crate) fn swap_hello_admission(
2258 &self,
2259 module_id: &str,
2260 presented: Option<&str>,
2261 ) -> SwapHelloAdmission {
2262 let swaps = self
2263 .swaps
2264 .lock()
2265 .unwrap_or_else(|poisoned| poisoned.into_inner());
2266 let Some(swap) = swaps.get(module_id) else {
2267 return SwapHelloAdmission::NotSwapping;
2268 };
2269 let Some(presented) = presented else {
2270 return SwapHelloAdmission::Refused;
2271 };
2272 if constant_time_eq(swap.candidate_nonce.as_bytes(), presented.as_bytes()) {
2273 return if swap.candidate_admitted {
2274 SwapHelloAdmission::Refused
2275 } else {
2276 SwapHelloAdmission::Candidate
2277 };
2278 }
2279 if swap
2280 .incumbent_nonce
2281 .as_deref()
2282 .is_some_and(|incumbent| constant_time_eq(incumbent.as_bytes(), presented.as_bytes()))
2283 {
2284 return SwapHelloAdmission::NotSwapping;
2285 }
2286 SwapHelloAdmission::Refused
2287 }
2288
2289 pub(crate) fn mark_swap_candidate_admitted(&self, module_id: &str) {
2292 if let Some(swap) = self
2293 .swaps
2294 .lock()
2295 .unwrap_or_else(|poisoned| poisoned.into_inner())
2296 .get_mut(module_id)
2297 {
2298 swap.candidate_admitted = true;
2299 }
2300 }
2301
2302 pub fn spawn_launch_nonce_for(&self, module_id: &str) -> Option<String> {
2304 self.spawn_nonces
2305 .lock()
2306 .unwrap_or_else(|poisoned| poisoned.into_inner())
2307 .get(module_id)
2308 .cloned()
2309 }
2310
2311 pub fn reserved_launch_nonce_for(&self, module_id: &str) -> Option<String> {
2313 self.reserved_nonces
2314 .lock()
2315 .unwrap_or_else(|poisoned| poisoned.into_inner())
2316 .get(module_id)
2317 .cloned()
2318 .flatten()
2319 }
2320
2321 pub fn insert(&self, module: SupervisedModule) -> Option<SupervisedModule> {
2322 self.mark_configured(module.module_id());
2326 let mut modules = self
2327 .modules
2328 .lock()
2329 .unwrap_or_else(|poisoned| poisoned.into_inner());
2330 modules.insert(module.module_id().to_string(), module)
2331 }
2332
2333 fn mark_configured(&self, module_id: &str) {
2337 self.configured_ids
2338 .lock()
2339 .unwrap_or_else(|poisoned| poisoned.into_inner())
2340 .insert(module_id.to_string());
2341 }
2342
2343 fn unmark_configured_unless_rostered(&self, module_id: &str) {
2347 let modules = self
2348 .modules
2349 .lock()
2350 .unwrap_or_else(|poisoned| poisoned.into_inner());
2351 if !modules.contains_key(module_id) {
2352 self.configured_ids
2353 .lock()
2354 .unwrap_or_else(|poisoned| poisoned.into_inner())
2355 .remove(module_id);
2356 }
2357 }
2358
2359 pub(crate) fn is_configured(&self, module_id: &str) -> bool {
2367 self.configured_ids
2368 .lock()
2369 .unwrap_or_else(|poisoned| poisoned.into_inner())
2370 .contains(module_id)
2371 }
2372
2373 pub fn get(&self, module_id: &str) -> Option<SupervisedModule> {
2374 let modules = self
2375 .modules
2376 .lock()
2377 .unwrap_or_else(|poisoned| poisoned.into_inner());
2378 modules.get(module_id).cloned()
2379 }
2380
2381 pub(crate) fn record_late_health_answer(
2382 &self,
2383 module_id: &str,
2384 latency_ms: u64,
2385 ) -> Result<bool, SuperviseError> {
2386 let Some(module) = self.get(module_id) else {
2387 return Ok(false);
2388 };
2389 update_snapshot(&module.inner.snapshot, Some(module_id), |state| {
2390 state.health.late_answer_count = state.health.late_answer_count.saturating_add(1);
2391 state.health.last_late_answer_latency_ms = Some(latency_ms);
2392 state.health.consecutive_failures = 0;
2400 })?;
2401 Ok(true)
2402 }
2403
2404 pub fn record_deliberate_severance(&self, module_id: &str) -> Result<bool, SuperviseError> {
2410 let Some(module) = self.get(module_id) else {
2411 return Ok(false);
2412 };
2413 let snapshot = lock_snapshot(&module.inner.snapshot)?;
2414 let Some((pid, start_time)) = snapshot.pid.zip(snapshot.process_start_time) else {
2415 return Ok(false);
2416 };
2417 drop(snapshot);
2418 module.record_deliberate_severance(ProcessIdentity { pid, start_time })
2419 }
2420
2421 pub fn list(&self) -> Vec<SupervisedModule> {
2422 let modules = self
2423 .modules
2424 .lock()
2425 .unwrap_or_else(|poisoned| poisoned.into_inner());
2426 let mut modules = modules.values().cloned().collect::<Vec<_>>();
2427 modules.sort_by(|left, right| left.module_id().cmp(right.module_id()));
2428 modules
2429 }
2430
2431 pub(crate) fn retire(&self, module_id: &str) -> Option<SupervisedModule> {
2432 self.spawn_nonces
2433 .lock()
2434 .unwrap_or_else(|poisoned| poisoned.into_inner())
2435 .remove(module_id);
2436 self.close_swap(module_id);
2437 let mut reserved_nonces = self
2438 .reserved_nonces
2439 .lock()
2440 .unwrap_or_else(|poisoned| poisoned.into_inner());
2441 if reserved_nonces.contains_key(module_id) {
2442 reserved_nonces.insert(module_id.to_string(), None);
2445 }
2446 drop(reserved_nonces);
2447 self.reserved_prefix_owners
2448 .lock()
2449 .unwrap_or_else(|poisoned| poisoned.into_inner())
2450 .retain(|_, owner| owner != module_id);
2451 let removed = self
2452 .modules
2453 .lock()
2454 .unwrap_or_else(|poisoned| poisoned.into_inner())
2455 .remove(module_id);
2456 self.configured_ids
2457 .lock()
2458 .unwrap_or_else(|poisoned| poisoned.into_inner())
2459 .remove(module_id);
2460 removed
2461 }
2462
2463 pub(crate) fn record_rescan_removal(&self, module_id: &str) {
2466 self.removal_tombstones
2467 .lock()
2468 .unwrap_or_else(|poisoned| poisoned.into_inner())
2469 .insert(module_id.to_string(), unix_ms_now());
2470 }
2471
2472 pub(crate) fn removal_tombstone_age_ms(&self, module_id: &str) -> Option<u64> {
2474 self.removal_tombstones
2475 .lock()
2476 .unwrap_or_else(|poisoned| poisoned.into_inner())
2477 .get(module_id)
2478 .copied()
2479 .map(|removed_at_ms| unix_ms_now().saturating_sub(removed_at_ms))
2480 }
2481
2482 pub(crate) fn release_retained_reserved_gate(&self, module_id: &str) -> bool {
2487 if self.get(module_id).is_some() {
2488 return false;
2489 }
2490 let mut reserved_nonces = self
2491 .reserved_nonces
2492 .lock()
2493 .unwrap_or_else(|poisoned| poisoned.into_inner());
2494 if !matches!(reserved_nonces.get(module_id), Some(None)) {
2495 return false;
2496 }
2497 reserved_nonces.remove(module_id);
2498 true
2499 }
2500
2501 pub(crate) fn operation_lock(&self) -> Arc<AsyncMutex<()>> {
2502 Arc::clone(&self.operation_lock)
2503 }
2504}
2505
2506#[derive(Debug, Clone)]
2508pub struct Supervisor {
2509 registry: Arc<Registry>,
2510 restart_policy: RestartPolicy,
2511 drain_timeout: Duration,
2512 connection_file_path: Option<PathBuf>,
2513 capture_logs_dir: Option<PathBuf>,
2514 forwarding: Option<Arc<ForwardingTable>>,
2515 process_liveness: Arc<SupervisorProcessLiveness>,
2516 supervisor_handle: Option<SupervisorHandle>,
2517 health: HealthConfig,
2518 daemon_start_clock: crate::clock::StartClock,
2519 terminal_journal: Option<Arc<crate::terminal_journal::TerminalJournal>>,
2520 spawn_events: SpawnEventFeed,
2521 provenance_probe: ExecutableIdentityProbe,
2522 child_roster: ChildRoster,
2525 #[cfg(target_os = "linux")]
2526 cgroup_placement: Option<subc_cgroup::Placement>,
2527 #[cfg(test)]
2528 test_after_first_spawn: AfterFirstSpawnHook,
2529}
2530
2531#[cfg(test)]
2537#[derive(Clone, Default)]
2538struct AfterFirstSpawnHook(Option<AfterFirstSpawnFn>);
2539
2540#[cfg(test)]
2541type AfterFirstSpawnFn = Arc<dyn Fn(&str) + Send + Sync>;
2542
2543#[cfg(test)]
2544impl fmt::Debug for AfterFirstSpawnHook {
2545 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2546 f.write_str("AfterFirstSpawnHook")
2547 }
2548}
2549
2550#[cfg(test)]
2551impl AfterFirstSpawnHook {
2552 fn run(&self, module_id: &str) {
2553 if let Some(hook) = &self.0 {
2554 hook(module_id);
2555 }
2556 }
2557}
2558
2559impl Supervisor {
2560 #[cfg(test)]
2561 pub(crate) fn new_for_test(registry: Arc<Registry>, policy: RestartPolicy) -> Self {
2562 let supervisor = Self::new(registry, policy);
2563 #[cfg(target_os = "macos")]
2564 let supervisor = supervisor.with_privacy_trampoline(test_privacy_trampoline());
2565 supervisor
2566 }
2567 pub fn with_privacy_trampoline(self, path: impl Into<PathBuf>) -> Self {
2572 let path = path.into();
2573 #[cfg(target_os = "macos")]
2574 {
2575 let result = probe_privacy_trampoline(&path).map(|()| path);
2576 if let Err(cause) = &result {
2577 error!(%cause, "macOS privacy identity unavailable; supervised launches will refuse");
2578 }
2579 self.child_roster.set_privacy_trampoline(result);
2580 }
2581 #[cfg(not(target_os = "macos"))]
2582 let _ = path;
2583 self
2584 }
2585 #[cfg(unix)]
2596 pub(crate) fn begin_daemon_shutdown(&self) {
2597 self.child_roster.close();
2598 if let Some(journal) = &self.terminal_journal {
2599 journal.stamp_shutdown();
2600 }
2601 }
2602
2603 #[cfg(unix)]
2607 pub(crate) async fn drain_for_daemon_shutdown(&self) -> Result<(), SuperviseError> {
2608 const NOTICE_BUDGET: Duration = Duration::from_millis(500);
2609 const DRAIN_BUDGET: Duration = Duration::from_secs(2);
2610 let Some(forwarding) = &self.forwarding else {
2611 return Ok(());
2612 };
2613 let module_ids = forwarding
2614 .begin_daemon_drain()
2615 .map_err(SuperviseError::Forwarding)?;
2616 let deadline_ms =
2617 unix_ms_now().saturating_add((NOTICE_BUDGET + DRAIN_BUDGET).as_millis() as u64);
2618 let mut notices = tokio::task::JoinSet::new();
2619 let mut drains = Vec::new();
2620 for module_id in module_ids {
2621 let Some(target) = forwarding
2622 .begin_module_drain(&module_id, RouteCloseReason::Restart)
2623 .map_err(SuperviseError::Forwarding)?
2624 else {
2625 continue;
2626 };
2627 let routes = forwarding
2628 .endpoint_routes(target.endpoint)
2629 .map_err(SuperviseError::Forwarding)?;
2630 let command = serde_json::to_vec(&ModuleControlCommand::Draining {
2636 reason: RouteCloseReason::Restart,
2637 deadline_ms,
2638 })
2639 .expect("module draining serializes");
2640 let mut recipients = vec![(target.sink.clone(), target.negotiated_ver, command)];
2641 let mut clients: Vec<(crate::forwarding::GoodbyeTarget, Vec<u16>)> = Vec::new();
2642 for route in routes {
2643 let client = route.goodbye_target;
2644 if let Some((_, channels)) = clients
2645 .iter_mut()
2646 .find(|(existing, _)| existing.connection_id == client.connection_id)
2647 {
2648 channels.push(client.channel);
2649 } else {
2650 let channel = client.channel;
2651 clients.push((client, vec![channel]));
2652 }
2653 }
2654 for (client, mut channels) in clients {
2655 channels.sort_unstable();
2656 channels.dedup();
2657 let closing = serde_json::to_vec(&ClientControlPush::RouteClosing {
2658 module_id: module_id.clone(),
2659 channels,
2660 reason: RouteCloseReason::Restart,
2661 })
2662 .expect("route closing serializes");
2663 recipients.push((client.sink, client.negotiated_ver, closing));
2664 }
2665 for (sink, version, body) in recipients {
2666 notices.spawn(async move {
2667 let frame = Frame::build_with_version(
2668 version,
2669 FrameType::Push,
2670 control_flags(),
2671 0,
2672 0,
2673 0,
2674 body,
2675 )
2676 .expect("bounded lifecycle notice frame builds");
2677 sink.send_flushed(frame).await
2678 });
2679 }
2680 let gauges = declared_busy_gauges(&self.registry, &module_id)?;
2681 drains.push((module_id, target.endpoint, gauges));
2682 }
2683 let notice_deadline = Instant::now() + NOTICE_BUDGET;
2686 while let Ok(Some(result)) = timeout_at(notice_deadline, notices.join_next()).await {
2687 if !matches!(result, Ok(Ok(()))) {
2688 warn!(?result, "daemon shutdown notice delivery failed");
2689 }
2690 }
2691 notices.abort_all();
2692 let deadline = Instant::now() + DRAIN_BUDGET;
2693 let mut waits = tokio::task::JoinSet::new();
2694 for (module_id, endpoint, gauges) in drains {
2695 let forwarding = Arc::clone(forwarding);
2696 let mut runtime = self.runtime_config();
2697 runtime.health.cadence = Duration::from_millis(100);
2698 waits.spawn(async move {
2699 wait_for_forwarding_quiescence(
2700 &forwarding,
2701 &module_id,
2702 &runtime,
2703 endpoint,
2704 deadline,
2705 &gauges,
2706 DrainScope::Active,
2707 )
2708 .await
2709 });
2710 }
2711 while let Ok(Some(result)) = timeout_at(deadline, waits.join_next()).await {
2712 if !matches!(result, Ok(Ok(true))) {
2713 warn!(?result, "daemon shutdown drain did not reach quiescence");
2714 }
2715 }
2716 Ok(())
2717 }
2718
2719 #[cfg(unix)]
2731 pub(crate) async fn end_children_for_daemon_shutdown(
2732 &self,
2733 already_escalated: bool,
2734 escalate: impl std::future::Future<Output = ()>,
2735 ) {
2736 tokio::pin!(escalate);
2737 let mut escalated = already_escalated;
2738 if let Some(forwarding) = &self.forwarding {
2739 let reason = CloseReason::new(
2740 "daemon_shutdown",
2741 "the daemon is exiting after its shutdown notice and drain",
2742 );
2743 if escalated {
2744 send_module_goodbyes_for_daemon_shutdown(forwarding, &reason, false).await;
2747 } else {
2748 tokio::select! {
2749 biased;
2750 _ = escalate.as_mut() => {
2751 info!("second SIGTERM: abandoning module GOODBYE delivery");
2752 escalated = true;
2753 }
2754 _ = send_module_goodbyes_for_daemon_shutdown(forwarding, &reason, true) => {}
2755 }
2756 }
2757 let closed = forwarding.close_all_connections(&reason);
2758 debug!(closed, "closed established connections for daemon shutdown");
2759 }
2760 let escalated_here = escalated && !already_escalated;
2764 let remaining_escalate = async move {
2765 if escalated_here {
2766 std::future::pending::<()>().await;
2767 } else {
2768 escalate.await;
2769 }
2770 };
2771 crate::child_roster::end_children_for_daemon_shutdown(
2772 &self.child_roster,
2773 escalated,
2774 remaining_escalate,
2775 )
2776 .await;
2777 }
2778
2779 pub fn new(registry: Arc<Registry>, restart_policy: RestartPolicy) -> Self {
2780 Self {
2781 registry,
2782 restart_policy,
2783 drain_timeout: DEFAULT_DRAIN_TIMEOUT,
2784 connection_file_path: None,
2785 capture_logs_dir: None,
2786 forwarding: None,
2787 process_liveness: Arc::new(SupervisorProcessLiveness::default()),
2788 supervisor_handle: None,
2789 health: HealthConfig::default(),
2790 daemon_start_clock: crate::clock::StartClock::capture(),
2791 terminal_journal: None,
2792 spawn_events: SpawnEventFeed::default(),
2793 provenance_probe: ExecutableIdentityProbe::default(),
2794 child_roster: ChildRoster::default(),
2795 #[cfg(target_os = "linux")]
2796 cgroup_placement: None,
2797 #[cfg(test)]
2798 test_after_first_spawn: AfterFirstSpawnHook::default(),
2799 }
2800 }
2801
2802 pub fn with_drain_timeout(mut self, drain_timeout: Duration) -> Self {
2803 self.drain_timeout = drain_timeout;
2804 self
2805 }
2806
2807 pub fn with_process_liveness(
2808 mut self,
2809 process_liveness: Arc<SupervisorProcessLiveness>,
2810 ) -> Self {
2811 self.process_liveness = process_liveness;
2812 self
2813 }
2814
2815 pub fn with_connection_file_path(mut self, connection_file_path: impl Into<PathBuf>) -> Self {
2816 self.connection_file_path = Some(connection_file_path.into());
2817 self
2818 }
2819
2820 pub fn with_capture_logs_dir(mut self, logs_dir: impl Into<PathBuf>) -> Self {
2822 self.capture_logs_dir = Some(logs_dir.into());
2823 self
2824 }
2825
2826 pub fn with_daemon_incarnation(self, daemon_incarnation: String) -> Self {
2829 self.spawn_events.configure_incarnation(daemon_incarnation);
2833 self
2834 }
2835
2836 pub fn with_terminal_journal(self, path: PathBuf, daemon_incarnation: String) -> Self {
2839 let mut this = self.with_daemon_incarnation(daemon_incarnation.clone());
2840 this.terminal_journal = Some(Arc::new(crate::terminal_journal::TerminalJournal::open(
2841 path,
2842 daemon_incarnation,
2843 )));
2844 this
2845 }
2846
2847 pub fn with_forwarding(mut self, forwarding: Arc<ForwardingTable>) -> Self {
2848 self.forwarding = Some(forwarding);
2849 self
2850 }
2851
2852 pub fn with_handle(mut self, supervisor_handle: SupervisorHandle) -> Self {
2853 self.spawn_events = supervisor_handle.spawn_events.clone();
2854 self.supervisor_handle = Some(supervisor_handle);
2855 self
2856 }
2857
2858 pub fn with_health_config(mut self, health: HealthConfig) -> Self {
2859 self.health = health;
2860 self
2861 }
2862
2863 pub fn with_live_children_record(self, path: impl Into<PathBuf>) -> Self {
2867 self.child_roster.record_to(path.into());
2868 self
2869 }
2870
2871 #[cfg(target_os = "linux")]
2872 pub fn with_cgroup_placement(
2873 mut self,
2874 cgroup_placement: Option<subc_cgroup::Placement>,
2875 ) -> Self {
2876 self.cgroup_placement = cgroup_placement;
2877 self
2878 }
2879
2880 pub fn spawn(&self, spec: ModuleSpec) -> Result<SupervisedModule, SuperviseError> {
2886 validate_spec(&spec)?;
2887 self.establish_identity(&spec);
2888
2889 let runtime = self.runtime_config();
2890 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
2891 let spawned = spawn_child(
2892 &spec,
2893 runtime.connection_file_path.as_deref(),
2894 self.supervisor_handle.as_ref(),
2895 &runtime.stderr_ring,
2896 runtime.capture_logs_dir.as_deref(),
2897 &runtime.child_roster,
2898 #[cfg(target_os = "linux")]
2899 runtime.cgroup_placement.as_ref(),
2900 );
2901 #[cfg(test)]
2902 self.test_after_first_spawn.run(&spec.module_id);
2903 let child = match spawned {
2904 Ok(child) => child,
2905 Err(err) => {
2906 self.abandon_unrostered(&spec.module_id);
2909 return Err(err);
2910 }
2911 };
2912 self.mark_first_process_running(&spec, &runtime, &snapshot, &child)?;
2913
2914 Ok(self.supervised_module(spec, runtime, snapshot, Some(child)))
2915 }
2916
2917 fn establish_identity(&self, spec: &ModuleSpec) {
2930 if let Some(supervisor_handle) = &self.supervisor_handle {
2931 supervisor_handle.apply_identity_configuration(spec);
2932 supervisor_handle.mark_configured(&spec.module_id);
2933 }
2934 }
2935
2936 fn abandon_unrostered(&self, module_id: &str) {
2939 if let Some(supervisor_handle) = &self.supervisor_handle {
2940 supervisor_handle.unmark_configured_unless_rostered(module_id);
2941 }
2942 }
2943
2944 fn mark_first_process_running(
2947 &self,
2948 spec: &ModuleSpec,
2949 runtime: &SupervisorRuntimeConfig,
2950 snapshot: &SharedSnapshot,
2951 child: &SupervisedChild,
2952 ) -> Result<(), SuperviseError> {
2953 if let Err(err) = set_running(snapshot, child, &spec.module_id, &runtime.spawn_events) {
2954 self.abandon_unrostered(&spec.module_id);
2955 return Err(err);
2956 }
2957 self.process_liveness
2958 .track(spec.module_id.clone(), Arc::clone(snapshot));
2959 Ok(())
2960 }
2961
2962 pub fn supervise_configured(
2968 &self,
2969 spec: ModuleSpec,
2970 enabled: bool,
2971 ) -> Result<SupervisedModule, SuperviseError> {
2972 validate_spec(&spec)?;
2973 self.establish_identity(&spec);
2974
2975 let runtime = self.runtime_config();
2976 if !enabled {
2977 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::disabled()));
2978 return Ok(self.supervised_module(spec, runtime, snapshot, None));
2979 }
2980
2981 #[cfg(target_os = "linux")]
2982 if let Some(pid) = runtime.child_roster.previous_instance_pid(&spec.module_id) {
2983 let mut snapshot = SupervisorSnapshot::starting();
2984 snapshot.waiting_for_previous_pid = Some(pid);
2985 return Ok(self.supervised_module(spec, runtime, Arc::new(Mutex::new(snapshot)), None));
2986 }
2987
2988 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
2989 let spawned = spawn_child(
2990 &spec,
2991 runtime.connection_file_path.as_deref(),
2992 self.supervisor_handle.as_ref(),
2993 &runtime.stderr_ring,
2994 runtime.capture_logs_dir.as_deref(),
2995 &runtime.child_roster,
2996 #[cfg(target_os = "linux")]
2997 runtime.cgroup_placement.as_ref(),
2998 );
2999 #[cfg(test)]
3000 self.test_after_first_spawn.run(&spec.module_id);
3001 match spawned {
3002 Ok(child) => {
3003 self.mark_first_process_running(&spec, &runtime, &snapshot, &child)?;
3004 Ok(self.supervised_module(spec, runtime, snapshot, Some(child)))
3005 }
3006 Err(err) => {
3007 error!(
3008 module_id = %spec.module_id,
3009 program = %spec.program.display(),
3010 error = %err,
3011 "configured module failed to spawn; marking failed and continuing"
3012 );
3013 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::failed()));
3014 Ok(self.supervised_module(spec, runtime, snapshot, None))
3015 }
3016 }
3017 }
3018
3019 pub fn supervise_configured_with_health(
3025 &self,
3026 spec: ModuleSpec,
3027 enabled: bool,
3028 health: HealthConfig,
3029 drain_timeout_ms: Option<u64>,
3030 restart_policy: RestartPolicy,
3031 ) -> Result<SupervisedModule, SuperviseError> {
3032 validate_spec(&spec)?;
3033 self.establish_identity(&spec);
3034
3035 let mut runtime = self.runtime_config();
3036 runtime.health = health.clone();
3037 runtime.restart_policy = restart_policy;
3038 if let Some(ms) = drain_timeout_ms {
3039 runtime.drain_timeout = Duration::from_millis(ms);
3040 *runtime
3041 .effective_drain_timeout
3042 .lock()
3043 .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.drain_timeout;
3044 }
3045 if !enabled {
3046 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::disabled()));
3047 return Ok(self.supervised_module(spec, runtime, snapshot, None));
3048 }
3049
3050 #[cfg(target_os = "linux")]
3051 if let Some(pid) = runtime.child_roster.previous_instance_pid(&spec.module_id) {
3052 let mut snapshot = SupervisorSnapshot::starting();
3053 snapshot.waiting_for_previous_pid = Some(pid);
3054 return Ok(self.supervised_module(spec, runtime, Arc::new(Mutex::new(snapshot)), None));
3055 }
3056
3057 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
3058 let spawned = spawn_child(
3059 &spec,
3060 runtime.connection_file_path.as_deref(),
3061 self.supervisor_handle.as_ref(),
3062 &runtime.stderr_ring,
3063 runtime.capture_logs_dir.as_deref(),
3064 &runtime.child_roster,
3065 #[cfg(target_os = "linux")]
3066 runtime.cgroup_placement.as_ref(),
3067 );
3068 #[cfg(test)]
3069 self.test_after_first_spawn.run(&spec.module_id);
3070 match spawned {
3071 Ok(child) => {
3072 self.mark_first_process_running(&spec, &runtime, &snapshot, &child)?;
3073 Ok(self.supervised_module(spec, runtime, snapshot, Some(child)))
3074 }
3075 Err(err) => {
3076 if health.critical {
3077 error!(
3078 module_id = %spec.module_id,
3079 program = %spec.program.display(),
3080 error = %err,
3081 "critical configured module failed to spawn; marking failed and alerting"
3082 );
3083 } else {
3084 error!(
3085 module_id = %spec.module_id,
3086 program = %spec.program.display(),
3087 error = %err,
3088 "configured module failed to spawn; marking failed and continuing"
3089 );
3090 }
3091 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::failed()));
3092 Ok(self.supervised_module(spec, runtime, snapshot, None))
3093 }
3094 }
3095 }
3096
3097 fn runtime_config(&self) -> SupervisorRuntimeConfig {
3098 let effective_drain_timeout = Arc::new(Mutex::new(self.drain_timeout));
3099 SupervisorRuntimeConfig {
3100 scheduled_respawn: Arc::default(),
3101 deferred_reload_reply: Arc::default(),
3102 restart_policy: self.restart_policy,
3103 drain_timeout: self.drain_timeout,
3104 child_roster: self
3107 .child_roster
3108 .for_module(Arc::clone(&effective_drain_timeout)),
3109 effective_drain_timeout,
3110 default_drain_timeout: self.drain_timeout,
3111 health: self.health.clone(),
3112 connection_file_path: self.connection_file_path.clone(),
3113 capture_logs_dir: self.capture_logs_dir.clone(),
3114 forwarding: self.forwarding.clone(),
3115 supervisor_handle: self.supervisor_handle.clone(),
3116 stderr_ring: Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default()))),
3117 terminal_ring: Arc::new(Mutex::new(
3118 TerminalRing::new(
3119 TerminalRingConfig::default(),
3120 self.daemon_start_clock.started_at_ms(),
3121 )
3122 .with_start_clock(self.daemon_start_clock)
3123 .with_journal(self.terminal_journal.clone())
3124 .with_daemon_shutdown(self.child_roster.shutdown_flag()),
3125 )),
3126 spawn_events: self.spawn_events.clone(),
3127 #[cfg(target_os = "linux")]
3128 cgroup_placement: self.cgroup_placement.clone(),
3129 #[cfg(test)]
3130 test_seed_stale_facts_before_enable_spawn: false,
3131 #[cfg(test)]
3132 test_reload_exit_record_gate: None,
3133 }
3134 }
3135
3136 fn supervised_module(
3137 &self,
3138 spec: ModuleSpec,
3139 runtime: SupervisorRuntimeConfig,
3140 snapshot: SharedSnapshot,
3141 child: Option<SupervisedChild>,
3142 ) -> SupervisedModule {
3143 let configuration = Arc::new(Mutex::new(SupervisedConfiguration {
3144 spec: spec.clone(),
3145 health: runtime.health.clone(),
3146 }));
3147 let stderr_ring = Arc::clone(&runtime.stderr_ring);
3148 let terminal_ring = Arc::clone(&runtime.terminal_ring);
3149 let restart_policy = runtime.restart_policy;
3153 let effective_drain_timeout = Arc::clone(&runtime.effective_drain_timeout);
3154 let (tx, rx) = mpsc::channel(4);
3155 let monitor = tokio::spawn(supervise_loop(
3156 spec.clone(),
3157 runtime,
3158 Arc::clone(&self.registry),
3159 Arc::clone(&self.process_liveness),
3160 Arc::clone(&snapshot),
3161 child,
3162 rx,
3163 ));
3164
3165 let module_id = spec.module_id.clone();
3166 let module = SupervisedModule {
3167 inner: Arc::new(SupervisedModuleInner {
3168 module_id: module_id.clone(),
3169 registry: Arc::clone(&self.registry),
3170 snapshot,
3171 configuration,
3172 stderr_ring,
3173 terminal_ring,
3174 commands: tx,
3175 monitor: Mutex::new(Some(monitor)),
3176 restart_policy,
3177 effective_drain_timeout,
3178 provenance_probe: self.provenance_probe.clone(),
3179 }),
3180 };
3181 if let Some(supervisor_handle) = &self.supervisor_handle {
3185 supervisor_handle.insert(module.clone());
3186 }
3187 module
3188 }
3189}
3190
3191impl Default for Supervisor {
3192 fn default() -> Self {
3193 Self::new(Arc::new(Registry::default()), RestartPolicy::default())
3194 }
3195}
3196
3197#[derive(Clone)]
3199pub struct SupervisedModule {
3200 inner: Arc<SupervisedModuleInner>,
3201}
3202
3203struct SupervisedModuleInner {
3204 module_id: String,
3205 registry: Arc<Registry>,
3206 snapshot: SharedSnapshot,
3207 configuration: Arc<Mutex<SupervisedConfiguration>>,
3208 stderr_ring: Arc<Mutex<StderrRing>>,
3209 terminal_ring: Arc<Mutex<TerminalRing>>,
3210 commands: mpsc::Sender<SupervisorCommand>,
3211 monitor: Mutex<Option<JoinHandle<()>>>,
3212 restart_policy: RestartPolicy,
3216 effective_drain_timeout: Arc<Mutex<Duration>>,
3217 provenance_probe: ExecutableIdentityProbe,
3218}
3219
3220impl fmt::Debug for SupervisedModule {
3221 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3222 f.debug_struct("SupervisedModule")
3223 .field("module_id", &self.inner.module_id)
3224 .field("status", &self.status())
3225 .finish_non_exhaustive()
3226 }
3227}
3228
3229impl SupervisedModule {
3230 pub fn module_id(&self) -> &str {
3231 &self.inner.module_id
3232 }
3233
3234 #[cfg(test)]
3238 pub(crate) fn record_health_probe_failure_for_test(
3239 &self,
3240 detail: &str,
3241 ) -> Result<(), SuperviseError> {
3242 update_snapshot(&self.inner.snapshot, Some(&self.inner.module_id), |state| {
3243 state.health.consecutive_failures = state.health.consecutive_failures.saturating_add(1);
3244 state.health.detail = Some(detail.to_string());
3245 })
3246 }
3247
3248 pub fn state(&self) -> Result<ModuleState, SuperviseError> {
3249 Ok(lock_snapshot(&self.inner.snapshot)?.state)
3250 }
3251
3252 pub(crate) fn registration_end_reason(
3253 &self,
3254 ) -> Result<Option<RegistrationEndReason>, SuperviseError> {
3255 let snapshot = lock_snapshot(&self.inner.snapshot)?;
3256 Ok(match snapshot.state {
3257 ModuleState::Draining if snapshot.draining_to_replace => {
3258 Some(RegistrationEndReason::SupervisorRestart)
3259 }
3260 ModuleState::Draining => Some(RegistrationEndReason::SupervisorStop),
3261 ModuleState::Restarting if snapshot.draining_to_replace => {
3262 Some(RegistrationEndReason::SupervisorRestart)
3263 }
3264 _ => None,
3265 })
3266 }
3267
3268 pub fn stderr_tail(
3275 &self,
3276 max_lines: Option<usize>,
3277 max_bytes: Option<usize>,
3278 ) -> StderrTailSnapshot {
3279 self.inner
3280 .stderr_ring
3281 .lock()
3282 .unwrap_or_else(|poisoned| poisoned.into_inner())
3283 .snapshot(max_lines, max_bytes)
3284 }
3285
3286 pub fn terminal_history(&self) -> TerminalHistorySnapshot {
3291 self.inner
3292 .terminal_ring
3293 .lock()
3294 .unwrap_or_else(|poisoned| poisoned.into_inner())
3295 .snapshot()
3296 }
3297
3298 pub fn durable_terminal_history(&self) -> subc_control::TerminalHistory {
3303 durable_terminal_history_of(&self.inner.terminal_ring, &self.inner.module_id)
3304 }
3305
3306 pub(crate) async fn read_durable_terminal_history(
3311 &self,
3312 ) -> Result<subc_control::TerminalHistory, tokio::task::JoinError> {
3313 let terminal_ring = Arc::clone(&self.inner.terminal_ring);
3314 let module_id = self.inner.module_id.clone();
3315 tokio::task::spawn_blocking(move || durable_terminal_history_of(&terminal_ring, &module_id))
3316 .await
3317 }
3318
3319 pub fn status(&self) -> Result<ModuleStatus, SuperviseError> {
3320 self.status_with_snapshot_lock(&self.inner.snapshot, None)
3321 .map(|(status, _)| status)
3322 }
3323
3324 pub(crate) fn record_deliberate_severance(
3325 &self,
3326 identity: ProcessIdentity,
3327 ) -> Result<bool, SuperviseError> {
3328 let mut snapshot = lock_snapshot(&self.inner.snapshot)?;
3329 if snapshot.pid != Some(identity.pid)
3330 || snapshot.process_start_time != Some(identity.start_time)
3331 {
3332 return Ok(false);
3333 }
3334 snapshot.deliberate_severance = Some(identity);
3335 Ok(true)
3336 }
3337
3338 pub(crate) fn status_for_control(
3343 &self,
3344 caller: &'static str,
3345 ) -> Result<ModuleStatus, SuperviseError> {
3346 self.status_with_snapshot_lock(&self.inner.snapshot, Some(caller))
3347 .map(|(status, _)| status)
3348 }
3349
3350 fn status_with_snapshot_lock(
3351 &self,
3352 snapshot: &SharedSnapshot,
3353 caller: Option<&'static str>,
3354 ) -> Result<(ModuleStatus, Option<SpawnedFileIdentity>), SuperviseError> {
3355 let mut guard = match caller {
3356 Some(caller) => lock_snapshot_for_control(snapshot, &self.inner.module_id, caller)?,
3357 None => lock_snapshot(snapshot)?,
3358 };
3359 let restart_count =
3362 guard.crash_restarts_in_window(self.inner.restart_policy.window, Instant::now());
3363 let snapshot = guard.clone();
3364 drop(guard);
3365 let drain_timeout = *self.inner.effective_drain_timeout.lock().map_err(|_| {
3366 SuperviseError::StatePoisoned {
3367 module_id: Some(self.inner.module_id.clone()),
3368 }
3369 })?;
3370 let registration_active = self
3371 .inner
3372 .registry
3373 .get_module(&self.inner.module_id)
3374 .map_err(SuperviseError::Registry)?
3375 .is_some();
3376 let protocol = snapshot
3377 .spawned_protocol
3378 .unwrap_or(self.declared_protocol()?);
3379 let running_process =
3380 snapshot.enabled && snapshot.state == ModuleState::Running && snapshot.process_alive;
3381 let live = match protocol {
3387 ModuleProtocol::Subc => running_process && registration_active,
3388 ModuleProtocol::None => running_process,
3389 };
3390
3391 Ok((
3392 ModuleStatus {
3393 waiting_for_previous_pid: snapshot.waiting_for_previous_pid,
3394 module_id: self.inner.module_id.clone(),
3395 launch_nonce_source: observed_launch_nonce_source(&snapshot),
3396 state: snapshot.state,
3397 enabled: snapshot.enabled,
3398 process_alive: snapshot.process_alive,
3399 registration_active,
3400 protocol,
3401 live,
3402 restart_count,
3403 lifetime_restarts: snapshot.lifetime_restarts,
3404 spawn_generation: snapshot.spawn_generation,
3405 max_restarts: self.inner.restart_policy.max_restarts,
3406 restart_window: self.inner.restart_policy.window,
3407 drain_timeout,
3408 restart_backoff: self.inner.restart_policy.backoff,
3409 restart_max_backoff: self.inner.restart_policy.max_backoff,
3410 pid: snapshot.reported_pid(),
3411 spawned_at_ms: snapshot.spawned_at_ms,
3412 spawned_from: snapshot.spawned_from,
3413 process_start_time: snapshot.process_start_time,
3414 last_exit: snapshot.last_exit,
3415 health: snapshot.health,
3416 },
3417 snapshot.spawned_file_identity.clone(),
3418 ))
3419 }
3420
3421 #[cfg(test)]
3422 pub(crate) fn hold_snapshot_for_test(
3423 &self,
3424 acquired: std::sync::mpsc::Sender<()>,
3425 hold: Duration,
3426 ) -> std::thread::JoinHandle<()> {
3427 let snapshot = Arc::clone(&self.inner.snapshot);
3428 std::thread::spawn(move || {
3429 let _guard = snapshot.lock().expect("test snapshot lock is not poisoned");
3430 acquired
3431 .send(())
3432 .expect("test receiver waits for snapshot lock");
3433 std::thread::sleep(hold);
3434 })
3435 }
3436
3437 pub(crate) async fn status_and_running_image_agreement(
3443 &self,
3444 ) -> Result<(ModuleStatus, subc_control::RunningImageAgreement), SuperviseError> {
3445 let (status, identity) = self.status_with_snapshot_lock(&self.inner.snapshot, None)?;
3446 let image = self
3447 .inner
3448 .provenance_probe
3449 .observe(
3450 status.pid,
3451 status.spawned_from.as_deref(),
3452 identity,
3453 status.process_start_time,
3454 )
3455 .await;
3456 Ok((status, image))
3457 }
3458
3459 pub(crate) async fn running_image_agreement(&self) -> subc_control::RunningImageAgreement {
3460 let snapshot = match lock_snapshot(&self.inner.snapshot) {
3461 Ok(snapshot) => snapshot.clone(),
3462 Err(_) => {
3463 return subc_control::RunningImageAgreement::Unavailable {
3464 reason: subc_control::RunningImageUnavailableReason::NotRunning,
3465 };
3466 }
3467 };
3468 self.inner
3469 .provenance_probe
3470 .observe(
3471 snapshot.reported_pid(),
3472 snapshot.spawned_from.as_deref(),
3473 snapshot.spawned_file_identity.clone(),
3474 snapshot.process_start_time,
3475 )
3476 .await
3477 }
3478
3479 pub(crate) fn child_resource_usage(&self) -> subc_control::ChildResourceUsage {
3482 #[cfg(windows)]
3483 {
3484 let image = lock_snapshot(&self.inner.snapshot)
3485 .ok()
3486 .and_then(|snapshot| {
3487 snapshot
3488 .spawned_file_identity
3489 .as_ref()
3490 .and_then(|identity| identity.windows_image.clone())
3491 });
3492 match image {
3493 Some(image) => {
3494 crate::child_resources::read_windows_handle(image.process(), image.start_time())
3495 }
3496 None => subc_control::ChildResourceUsage::Unavailable {
3497 reason: subc_control::ChildResourceUnavailableReason::NotRunning,
3498 },
3499 }
3500 }
3501 #[cfg(not(windows))]
3502 {
3503 let (pid, start_time) = match lock_snapshot(&self.inner.snapshot) {
3504 Ok(snapshot) => (snapshot.reported_pid(), snapshot.process_start_time),
3505 Err(_) => {
3506 return subc_control::ChildResourceUsage::Unavailable {
3507 reason: subc_control::ChildResourceUnavailableReason::Unreadable,
3508 }
3509 }
3510 };
3511 crate::child_resources::read(pid, start_time)
3512 }
3513 }
3514
3515 pub(crate) fn will_recover_after_connection_loss(&self) -> Result<bool, SuperviseError> {
3516 let mut snapshot = lock_snapshot(&self.inner.snapshot)?;
3517 Ok(match snapshot.state {
3518 ModuleState::Restarting => true,
3519 ModuleState::Failed | ModuleState::Disabled => false,
3520 _ => daemon_will_restart(&mut snapshot, &self.inner.restart_policy, Instant::now()),
3521 })
3522 }
3523
3524 #[cfg(test)]
3525 pub(crate) fn is_warming(&self) -> Result<bool, SuperviseError> {
3526 self.is_warming_with_snapshot_lock(None)
3527 }
3528
3529 pub(crate) fn is_warming_for_control(
3530 &self,
3531 caller: &'static str,
3532 ) -> Result<bool, SuperviseError> {
3533 self.is_warming_with_snapshot_lock(Some(caller))
3534 }
3535
3536 fn is_warming_with_snapshot_lock(
3537 &self,
3538 caller: Option<&'static str>,
3539 ) -> Result<bool, SuperviseError> {
3540 let snapshot = match caller {
3541 Some(caller) => {
3542 lock_snapshot_for_control(&self.inner.snapshot, &self.inner.module_id, caller)?
3543 }
3544 None => lock_snapshot(&self.inner.snapshot)?,
3545 }
3546 .clone();
3547 Ok(matches!(
3548 snapshot.state,
3549 ModuleState::Starting | ModuleState::Running | ModuleState::Restarting
3550 ))
3551 }
3552
3553 pub async fn drain(&self) -> Result<(), SuperviseError> {
3555 self.stop().await
3556 }
3557
3558 pub(crate) async fn retire(&self) -> Result<(), SuperviseError> {
3559 match self.state()? {
3560 ModuleState::Stopped | ModuleState::Failed => return Ok(()),
3561 ModuleState::Starting
3562 | ModuleState::Running
3563 | ModuleState::Unresponsive
3564 | ModuleState::Restarting
3565 | ModuleState::Draining
3566 | ModuleState::Disabled => {}
3567 }
3568
3569 let (reply_tx, reply_rx) = oneshot::channel();
3570 self.inner
3571 .commands
3572 .send(SupervisorCommand::Retire { reply: reply_tx })
3573 .await
3574 .map_err(|_| SuperviseError::CommandClosed {
3575 module_id: self.inner.module_id.clone(),
3576 })?;
3577 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3578 module_id: self.inner.module_id.clone(),
3579 })?
3580 }
3581
3582 pub async fn stop(&self) -> Result<(), SuperviseError> {
3583 match self.state()? {
3584 ModuleState::Stopped | ModuleState::Failed => return Ok(()),
3585 ModuleState::Starting
3586 | ModuleState::Running
3587 | ModuleState::Unresponsive
3588 | ModuleState::Restarting
3589 | ModuleState::Draining
3590 | ModuleState::Disabled => {}
3591 }
3592
3593 let (reply_tx, reply_rx) = oneshot::channel();
3594 self.inner
3595 .commands
3596 .send(SupervisorCommand::Drain { reply: reply_tx })
3597 .await
3598 .map_err(|_| SuperviseError::CommandClosed {
3599 module_id: self.inner.module_id.clone(),
3600 })?;
3601 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3602 module_id: self.inner.module_id.clone(),
3603 })?
3604 }
3605
3606 pub async fn restart(&self, drain_timeout_ms: Option<u64>) -> Result<(), SuperviseError> {
3607 let received_at_generation = lock_snapshot(&self.inner.snapshot)?.spawn_generation;
3608 let (reply_tx, reply_rx) = oneshot::channel();
3609 self.inner
3610 .commands
3611 .send(SupervisorCommand::Restart {
3612 drain_timeout_ms,
3613 received_at_generation,
3614 queued_at: Instant::now(),
3615 reply: reply_tx,
3616 })
3617 .await
3618 .map_err(|_| SuperviseError::CommandClosed {
3619 module_id: self.inner.module_id.clone(),
3620 })?;
3621 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3622 module_id: self.inner.module_id.clone(),
3623 })?
3624 }
3625
3626 pub async fn swap(&self, ready_timeout: Option<Duration>) -> Result<(), SuperviseError> {
3631 let (reply_tx, reply_rx) = oneshot::channel();
3632 self.inner
3633 .commands
3634 .send(SupervisorCommand::Swap {
3635 ready_timeout,
3636 reply: reply_tx,
3637 })
3638 .await
3639 .map_err(|_| SuperviseError::CommandClosed {
3640 module_id: self.inner.module_id.clone(),
3641 })?;
3642 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3643 module_id: self.inner.module_id.clone(),
3644 })?
3645 }
3646
3647 pub async fn reload(&self) -> Result<(), SuperviseError> {
3648 let (reply_tx, reply_rx) = oneshot::channel();
3649 self.inner
3650 .commands
3651 .send(SupervisorCommand::Reload { reply: reply_tx })
3652 .await
3653 .map_err(|_| SuperviseError::CommandClosed {
3654 module_id: self.inner.module_id.clone(),
3655 })?;
3656 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3657 module_id: self.inner.module_id.clone(),
3658 })?
3659 }
3660
3661 pub async fn set_enabled(&self, enabled: bool) -> Result<bool, SuperviseError> {
3662 let (reply_tx, reply_rx) = oneshot::channel();
3663 self.inner
3664 .commands
3665 .send(SupervisorCommand::SetEnabled {
3666 enabled,
3667 reply: reply_tx,
3668 })
3669 .await
3670 .map_err(|_| SuperviseError::CommandClosed {
3671 module_id: self.inner.module_id.clone(),
3672 })?;
3673 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3674 module_id: self.inner.module_id.clone(),
3675 })?
3676 }
3677
3678 pub(crate) fn declared_protocol(&self) -> Result<ModuleProtocol, SuperviseError> {
3682 let configured = self
3683 .inner
3684 .configuration
3685 .lock()
3686 .map_err(|_| SuperviseError::StatePoisoned {
3687 module_id: Some(self.inner.module_id.clone()),
3688 })?
3689 .spec
3690 .protocol;
3691 let state = lock_snapshot(&self.inner.snapshot)?;
3692 Ok(state.spawned_protocol.unwrap_or(configured))
3693 }
3694
3695 pub(crate) fn configuration(&self) -> Result<(ModuleSpec, HealthConfig), SuperviseError> {
3696 let configuration =
3697 self.inner
3698 .configuration
3699 .lock()
3700 .map_err(|_| SuperviseError::StatePoisoned {
3701 module_id: Some(self.inner.module_id.clone()),
3702 })?;
3703 Ok((configuration.spec.clone(), configuration.health.clone()))
3704 }
3705
3706 #[cfg(any(test, feature = "test-support"))]
3710 pub async fn update_spec_for_test(&self, spec: ModuleSpec) -> Result<(), SuperviseError> {
3711 let (_, health) = self.configuration()?;
3712 let drain_timeout_ms = u64::try_from(
3713 self.inner
3714 .effective_drain_timeout
3715 .lock()
3716 .unwrap_or_else(|poisoned| poisoned.into_inner())
3717 .as_millis(),
3718 )
3719 .ok();
3720 self.update_configuration(spec, health, drain_timeout_ms)
3721 .await
3722 }
3723
3724 pub(crate) async fn update_configuration(
3725 &self,
3726 spec: ModuleSpec,
3727 health: HealthConfig,
3728 drain_timeout_ms: Option<u64>,
3729 ) -> Result<(), SuperviseError> {
3730 if spec.module_id != self.inner.module_id {
3731 return Err(SuperviseError::InvalidSpec {
3732 reason: "a supervised module's module_id cannot be changed".to_string(),
3733 });
3734 }
3735 validate_spec(&spec)?;
3736 let (reply_tx, reply_rx) = oneshot::channel();
3737 self.inner
3738 .commands
3739 .send(SupervisorCommand::UpdateConfiguration {
3740 spec: spec.clone(),
3741 health: health.clone(),
3742 drain_timeout_ms,
3743 reply: reply_tx,
3744 })
3745 .await
3746 .map_err(|_| SuperviseError::CommandClosed {
3747 module_id: self.inner.module_id.clone(),
3748 })?;
3749 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3750 module_id: self.inner.module_id.clone(),
3751 })?;
3752 let mut configuration =
3753 self.inner
3754 .configuration
3755 .lock()
3756 .map_err(|_| SuperviseError::StatePoisoned {
3757 module_id: Some(self.inner.module_id.clone()),
3758 })?;
3759 configuration.spec = spec;
3760 configuration.health = health;
3761 Ok(())
3762 }
3763}
3764
3765impl Drop for SupervisedModuleInner {
3766 fn drop(&mut self) {
3767 let Ok(mut monitor) = self.monitor.lock() else {
3768 return;
3769 };
3770 if let Some(monitor) = monitor.as_ref().filter(|monitor| !monitor.is_finished()) {
3771 let _ = update_snapshot(&self.snapshot, Some(&self.module_id), |state| {
3772 state.state = ModuleState::Stopped;
3773 clear_current_process_facts(state);
3774 });
3775 monitor.abort();
3776 }
3777 let _ = monitor.take();
3778 }
3779}
3780
3781#[derive(Debug)]
3782enum SupervisorCommand {
3783 Drain {
3784 reply: oneshot::Sender<Result<(), SuperviseError>>,
3785 },
3786 Retire {
3787 reply: oneshot::Sender<Result<(), SuperviseError>>,
3788 },
3789 Restart {
3790 drain_timeout_ms: Option<u64>,
3795 received_at_generation: u64,
3799 queued_at: Instant,
3802 reply: oneshot::Sender<Result<(), SuperviseError>>,
3803 },
3804 Reload {
3805 reply: oneshot::Sender<Result<(), SuperviseError>>,
3806 },
3807 SetEnabled {
3808 enabled: bool,
3809 reply: oneshot::Sender<Result<bool, SuperviseError>>,
3810 },
3811 UpdateConfiguration {
3812 spec: ModuleSpec,
3813 health: HealthConfig,
3814 drain_timeout_ms: Option<u64>,
3817 reply: oneshot::Sender<()>,
3818 },
3819 Swap {
3820 ready_timeout: Option<Duration>,
3823 reply: oneshot::Sender<Result<(), SuperviseError>>,
3825 },
3826}
3827
3828#[derive(Debug)]
3829pub enum SuperviseError {
3830 InvalidSpec {
3831 reason: String,
3832 },
3833 Spawn {
3834 program: PathBuf,
3835 source: io::Error,
3836 cgroup_path: Option<PathBuf>,
3837 },
3838 Cgroup {
3839 module_id: String,
3840 source: io::Error,
3841 },
3842 LaunchNonce {
3845 reason: String,
3846 },
3847 Wait {
3848 module_id: String,
3849 source: io::Error,
3850 },
3851 Kill {
3852 module_id: String,
3853 source: io::Error,
3854 },
3855 Forwarding(ForwardingError),
3856 Registry(RegistryError),
3857 ReloadUnavailable {
3858 module_id: String,
3859 reason: String,
3860 },
3861 Disabled {
3866 module_id: String,
3867 },
3868 ReloadFailed {
3869 module_id: String,
3870 reason: String,
3871 },
3872 RegistrationStillActive {
3873 module_id: String,
3874 waited: Duration,
3875 },
3876 StatePoisoned {
3877 module_id: Option<String>,
3878 },
3879 CommandClosed {
3880 module_id: String,
3881 },
3882 SwapInProgress {
3886 module_id: String,
3887 },
3888 SwapRefused {
3890 module_id: String,
3891 reason: SwapRefusal,
3892 },
3893 SwapFailed {
3897 module_id: String,
3898 arm: SwapFailureArm,
3899 detail: String,
3900 candidate_exit: Option<ExitReport>,
3903 },
3904}
3905
3906#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3908pub enum SwapRefusal {
3909 OverlapExclusive,
3911 NotRegistered,
3914 ProtocolNone,
3917 NotConfigured,
3920 AlreadySwapping,
3922}
3923
3924impl SwapRefusal {
3925 pub fn as_str(self) -> &'static str {
3926 match self {
3927 Self::OverlapExclusive => "overlap_exclusive",
3928 Self::NotRegistered => "not_registered",
3929 Self::ProtocolNone => "protocol_none",
3930 Self::NotConfigured => "not_configured",
3931 Self::AlreadySwapping => "already_swapping",
3932 }
3933 }
3934}
3935
3936#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3939pub enum SwapFailureArm {
3940 SpawnFailed,
3942 NeverRegistered,
3944 NeverReady,
3946 CandidateExited,
3948 CandidateUnhealthy,
3950 Interrupted,
3954 CutoverLost,
3959}
3960
3961impl SwapFailureArm {
3962 pub fn as_str(self) -> &'static str {
3963 match self {
3964 Self::SpawnFailed => "spawn_failed",
3965 Self::NeverRegistered => "never_registered",
3966 Self::NeverReady => "never_ready",
3967 Self::CandidateExited => "candidate_exited",
3968 Self::CandidateUnhealthy => "candidate_unhealthy",
3969 Self::Interrupted => "interrupted",
3970 Self::CutoverLost => "cutover_lost",
3971 }
3972 }
3973}
3974
3975impl fmt::Display for SuperviseError {
3976 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3977 match self {
3978 Self::InvalidSpec { reason } => write!(f, "invalid module spec: {reason}"),
3979 Self::Spawn {
3980 program,
3981 source,
3982 cgroup_path: Some(cgroup_path),
3983 } => write!(
3984 f,
3985 "failed to place module in cgroup '{}' while spawning '{}': {source}",
3986 cgroup_path.display(),
3987 program.display()
3988 ),
3989 Self::Spawn {
3990 program,
3991 source,
3992 cgroup_path: None,
3993 } => write!(
3994 f,
3995 "failed to spawn module '{}': {source}",
3996 program.display()
3997 ),
3998 Self::Cgroup { module_id, source } => {
3999 write!(
4000 f,
4001 "failed to prepare cgroup for module '{module_id}': {source}"
4002 )
4003 }
4004 Self::LaunchNonce { reason } => {
4005 write!(
4006 f,
4007 "failed to generate reserved-module launch nonce: {reason}"
4008 )
4009 }
4010 Self::Wait { module_id, source } => {
4011 write!(f, "failed to wait for module '{module_id}': {source}")
4012 }
4013 Self::Kill { module_id, source } => {
4014 write!(f, "failed to kill module '{module_id}': {source}")
4015 }
4016 Self::Forwarding(err) => write!(f, "forwarding error: {err}"),
4017 Self::Registry(err) => write!(f, "registry error: {err}"),
4018 Self::ReloadUnavailable { module_id, reason } => {
4019 write!(f, "reload unavailable for module '{module_id}': {reason}")
4020 }
4021 Self::Disabled { module_id } => {
4022 write!(
4023 f,
4024 "module '{module_id}' is disabled; enable it before restart or reload"
4025 )
4026 }
4027 Self::ReloadFailed { module_id, reason } => {
4028 write!(f, "reload failed for module '{module_id}': {reason}")
4029 }
4030 Self::RegistrationStillActive { module_id, waited } => write!(
4031 f,
4032 "module '{module_id}' registration remained active after waiting {waited:?}"
4033 ),
4034 Self::StatePoisoned { module_id } => match module_id {
4035 Some(module_id) => {
4036 write!(f, "supervisor state for module '{module_id}' was poisoned")
4037 }
4038 None => write!(f, "supervisor state was poisoned"),
4039 },
4040 Self::CommandClosed { module_id } => {
4041 write!(
4042 f,
4043 "supervisor command channel for module '{module_id}' is closed"
4044 )
4045 }
4046 Self::SwapInProgress { module_id } => write!(
4047 f,
4048 "module '{module_id}' is being swapped; retry once the swap has cut over or failed, or stop the module to abort the swap"
4049 ),
4050 Self::SwapRefused { module_id, reason } => match reason {
4051 SwapRefusal::OverlapExclusive => write!(
4052 f,
4053 "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"
4054 ),
4055 SwapRefusal::NotRegistered => write!(
4056 f,
4057 "module '{module_id}' is not registered, so there is no serving process to keep while a replacement warms; use a plain restart"
4058 ),
4059 SwapRefusal::ProtocolNone => write!(
4060 f,
4061 "module '{module_id}' is protocol: \"none\" and never registers, so a swap could never see its replacement become ready; use a plain restart"
4062 ),
4063 SwapRefusal::NotConfigured => write!(
4064 f,
4065 "module '{module_id}' cannot be swapped: the supervisor was built without the forwarding table or shared handle a swap needs"
4066 ),
4067 SwapRefusal::AlreadySwapping => {
4068 write!(f, "module '{module_id}' is already being swapped")
4069 }
4070 },
4071 Self::SwapFailed {
4072 module_id,
4073 arm,
4074 detail,
4075 ..
4076 } => write!(
4077 f,
4078 "swap of module '{module_id}' failed ({}): {detail}; the running process was left serving",
4079 arm.as_str()
4080 ),
4081 }
4082 }
4083}
4084
4085impl Error for SuperviseError {
4086 fn source(&self) -> Option<&(dyn Error + 'static)> {
4087 match self {
4088 Self::Spawn { source, .. }
4089 | Self::Cgroup { source, .. }
4090 | Self::Wait { source, .. }
4091 | Self::Kill { source, .. } => Some(source),
4092 Self::Forwarding(err) => Some(err),
4093 Self::Registry(err) => Some(err),
4094 Self::LaunchNonce { .. }
4095 | Self::InvalidSpec { .. }
4096 | Self::ReloadUnavailable { .. }
4097 | Self::Disabled { .. }
4098 | Self::ReloadFailed { .. }
4099 | Self::RegistrationStillActive { .. }
4100 | Self::StatePoisoned { .. }
4101 | Self::CommandClosed { .. }
4102 | Self::SwapInProgress { .. }
4103 | Self::SwapRefused { .. }
4104 | Self::SwapFailed { .. } => None,
4105 }
4106 }
4107}
4108
4109pub(crate) fn validate_spec(spec: &ModuleSpec) -> Result<(), SuperviseError> {
4110 if spec.module_id.trim().is_empty() {
4111 return Err(SuperviseError::InvalidSpec {
4112 reason: "module_id must not be empty".to_string(),
4113 });
4114 }
4115
4116 Ok(())
4117}
4118
4119#[derive(Debug, Default)]
4120struct HealthProbeRuntime {
4121 configured_health: Option<HealthConfig>,
4122 registered_connection: Option<crate::ConnectionId>,
4123 advertised: bool,
4124 next_probe_at: Option<Instant>,
4125 probe_index: u64,
4126}
4127
4128fn running_protocol(spec: &ModuleSpec, snapshot: &SharedSnapshot) -> ModuleProtocol {
4129 lock_snapshot(snapshot)
4130 .ok()
4131 .and_then(|state| state.spawned_protocol)
4132 .unwrap_or(spec.protocol)
4133}
4134
4135impl HealthProbeRuntime {
4136 fn refresh_registration(
4137 &mut self,
4138 spec: &ModuleSpec,
4139 runtime: &SupervisorRuntimeConfig,
4140 registry: &Registry,
4141 snapshot: &SharedSnapshot,
4142 ) {
4143 if self.configured_health.as_ref() != Some(&runtime.health) {
4144 self.configured_health = Some(runtime.health.clone());
4145 self.next_probe_at = None;
4146 self.registered_connection = None;
4147 self.probe_index = 0;
4148 }
4149 if running_protocol(spec, snapshot) == ModuleProtocol::None {
4153 self.registered_connection = None;
4154 self.advertised = runtime.health.http.is_some();
4155 if !self.advertised {
4156 self.next_probe_at = None;
4157 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4158 let unknown = ModuleHealthStatus::default();
4159 if state.health != unknown {
4160 state.health = unknown;
4161 }
4162 });
4163 } else if self.next_probe_at.is_none() {
4164 self.next_probe_at = Some(
4165 Instant::now()
4166 + jittered_health_delay(&spec.module_id, 0, runtime.health.cadence),
4167 );
4168 }
4169 return;
4170 }
4171
4172 let registration = match registry.get_module(&spec.module_id) {
4173 Ok(registration) => registration,
4174 Err(err) => {
4175 warn!(module_id = %spec.module_id, error = %err, "health prober could not read registry");
4176 self.advertised = false;
4177 self.next_probe_at = None;
4178 return;
4179 }
4180 };
4181
4182 let Some(registration) = registration else {
4183 self.registered_connection = None;
4184 self.advertised = false;
4185 self.next_probe_at = None;
4186 return;
4187 };
4188
4189 let advertised = registration
4190 .control_ops
4191 .iter()
4192 .any(|op| op == MODULE_CONTROL_OP_HEALTH_CHECK);
4193 if !advertised {
4194 self.registered_connection = Some(registration.connection_id);
4195 self.advertised = false;
4196 self.next_probe_at = None;
4197 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4198 if state.health.status != SupervisorHealthStatus::Unknown
4199 || state.health.consecutive_failures != 0
4200 || state.health.last_probe_ms.is_some()
4201 || state.health.detail.is_some()
4202 || state.health.metrics.is_some()
4203 {
4204 state.health.status = SupervisorHealthStatus::Unknown;
4205 state.health.consecutive_failures = 0;
4206 state.health.last_probe_ms = None;
4207 state.health.detail = None;
4208 state.health.metrics = None;
4209 }
4210 });
4211 return;
4212 }
4213
4214 let reregistered = self.registered_connection != Some(registration.connection_id);
4215 self.registered_connection = Some(registration.connection_id);
4216 self.advertised = true;
4217 if reregistered || self.next_probe_at.is_none() {
4218 self.probe_index = 0;
4219 self.next_probe_at = Some(
4220 Instant::now() + jittered_health_delay(&spec.module_id, 0, runtime.health.cadence),
4221 );
4222 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4223 state.health.status = SupervisorHealthStatus::Unknown;
4224 state.health.consecutive_failures = 0;
4225 state.health.detail = None;
4226 state.health.metrics = None;
4227 });
4228 }
4229 }
4230
4231 fn wake_after(&self) -> Option<Duration> {
4232 if !self.advertised {
4233 return None;
4234 }
4235 self.next_probe_at
4236 .map(|next| next.saturating_duration_since(Instant::now()))
4237 }
4238
4239 fn due(&self) -> bool {
4240 self.advertised
4241 && self
4242 .next_probe_at
4243 .is_some_and(|next| Instant::now() >= next)
4244 }
4245
4246 fn schedule_next(&mut self, spec: &ModuleSpec, cadence: Duration) {
4247 self.probe_index = self.probe_index.wrapping_add(1);
4248 self.next_probe_at = Some(
4249 Instant::now() + jittered_health_delay(&spec.module_id, self.probe_index, cadence),
4250 );
4251 }
4252}
4253
4254#[derive(Debug)]
4289enum HealthProbeEvidence {
4290 LaneDead,
4292 NoAnswer,
4294 BadAnswer,
4296 Misconfigured,
4298}
4299
4300#[derive(Debug)]
4301struct HealthProbeError {
4302 evidence: HealthProbeEvidence,
4303 message: String,
4304}
4305
4306impl HealthProbeError {
4307 fn lane_dead(message: impl Into<String>) -> Self {
4308 Self::with(HealthProbeEvidence::LaneDead, message)
4309 }
4310
4311 fn no_answer(message: impl Into<String>) -> Self {
4312 Self::with(HealthProbeEvidence::NoAnswer, message)
4313 }
4314
4315 fn bad_answer(message: impl Into<String>) -> Self {
4316 Self::with(HealthProbeEvidence::BadAnswer, message)
4317 }
4318
4319 fn misconfigured(message: impl Into<String>) -> Self {
4320 Self::with(HealthProbeEvidence::Misconfigured, message)
4321 }
4322
4323 fn with(evidence: HealthProbeEvidence, message: impl Into<String>) -> Self {
4324 Self {
4325 evidence,
4326 message: message.into(),
4327 }
4328 }
4329
4330 #[allow(dead_code)]
4344 fn is_proof_of_death(&self) -> bool {
4345 matches!(self.evidence, HealthProbeEvidence::LaneDead)
4346 }
4347
4348 fn label(&self) -> &'static str {
4356 match self.evidence {
4357 HealthProbeEvidence::LaneDead => "lane-dead",
4358 HealthProbeEvidence::NoAnswer => "no-answer",
4359 HealthProbeEvidence::BadAnswer => "bad-answer",
4360 HealthProbeEvidence::Misconfigured => "daemon-misconfigured",
4361 }
4362 }
4363}
4364
4365impl fmt::Display for HealthProbeError {
4366 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4367 f.write_str(&self.message)
4368 }
4369}
4370
4371async fn run_health_probe_cycle(
4372 spec: &ModuleSpec,
4373 runtime: &SupervisorRuntimeConfig,
4374 registry: &Registry,
4375 process_liveness: &SupervisorProcessLiveness,
4376 snapshot: &SharedSnapshot,
4377 child: &mut Option<SupervisedChild>,
4378) {
4379 let now_ms = unix_ms_now();
4380 let http = (running_protocol(spec, snapshot) == ModuleProtocol::None)
4381 .then_some(runtime.health.http.as_deref())
4382 .flatten();
4383 let result = match http {
4384 Some(url) => probe_http_health(url, runtime.health.deadline).await,
4385 None => probe_module_health(&spec.module_id, runtime, None).await,
4386 };
4387 match result {
4388 Ok(report) => {
4389 handle_health_report(
4390 spec,
4391 runtime,
4392 registry,
4393 process_liveness,
4394 snapshot,
4395 child,
4396 report,
4397 now_ms,
4398 )
4399 .await;
4400 }
4401 Err(err) => {
4402 if http.is_some() {
4403 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4404 state.health.status = SupervisorHealthStatus::Failing;
4405 });
4406 }
4407 handle_health_probe_failure(
4408 spec,
4409 runtime,
4410 registry,
4411 process_liveness,
4412 snapshot,
4413 child,
4414 err,
4415 now_ms,
4416 )
4417 .await;
4418 }
4419 }
4420}
4421
4422pub(crate) struct HttpProbeTarget<'a> {
4423 address: std::net::SocketAddr,
4424 localhost: bool,
4425 authority: &'a str,
4426 path: String,
4427}
4428
4429pub(crate) fn parse_http_probe_url(url: &str) -> Result<HttpProbeTarget<'_>, String> {
4432 if url.bytes().any(|b| b <= b' ' || b == 127) || url.contains('#') {
4433 return Err("must not contain whitespace, controls, or a fragment".into());
4434 }
4435 let rest = url
4436 .strip_prefix("http://")
4437 .ok_or("must use plain http://")?;
4438 let split = rest.find(['/', '?']).unwrap_or(rest.len());
4439 let (authority, suffix) = rest.split_at(split);
4440 let (host, port) = if let Some(rest) = authority.strip_prefix("[::1]") {
4441 ("::1", rest)
4442 } else {
4443 let split = authority.find(':').unwrap_or(authority.len());
4444 authority.split_at(split)
4445 };
4446 let ip: std::net::IpAddr = match host {
4447 "127.0.0.1" | "localhost" => std::net::Ipv4Addr::LOCALHOST.into(),
4448 "::1" => std::net::Ipv6Addr::LOCALHOST.into(),
4449 _ => return Err("host must be 127.0.0.1, [::1], or localhost".into()),
4450 };
4451 let port = if port.is_empty() {
4452 80
4453 } else {
4454 port.strip_prefix(':')
4455 .and_then(|p| p.parse::<u16>().ok())
4456 .filter(|p| *p > 0)
4457 .ok_or("must have a valid nonzero TCP port")?
4458 };
4459 let path = if suffix.is_empty() {
4460 "/".into()
4461 } else if suffix.starts_with('?') {
4462 format!("/{suffix}")
4463 } else {
4464 suffix.into()
4465 };
4466 Ok(HttpProbeTarget {
4467 address: std::net::SocketAddr::new(ip, port),
4468 localhost: host == "localhost",
4469 authority,
4470 path,
4471 })
4472}
4473
4474async fn probe_http_health(
4475 url: &str,
4476 deadline: Duration,
4477) -> Result<HealthReport, HealthProbeError> {
4478 use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader};
4479 let target = parse_http_probe_url(url).map_err(HealthProbeError::misconfigured)?;
4480 let mut response_status = String::new();
4483 let mut body = Vec::new();
4484 let probe = async {
4485 let connection = match tokio::net::TcpStream::connect(target.address).await {
4488 Err(_) if target.localhost => {
4489 tokio::net::TcpStream::connect((
4490 std::net::Ipv6Addr::LOCALHOST,
4491 target.address.port(),
4492 ))
4493 .await
4494 }
4495 result => result,
4496 };
4497 let mut stream = connection.map_err(|error| {
4498 HealthProbeError::no_answer(format!("HTTP connect failed: {error}"))
4499 })?;
4500 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
4501 .map_err(|error| HealthProbeError::no_answer(format!("HTTP write failed: {error}")))?;
4502 let mut reader = BufReader::new(stream);
4503 let mut budget = 16 * 1024;
4504 let status = http_line(&mut reader, &mut budget).await?;
4505 let mut words = status.split_ascii_whitespace();
4506 let version = words.next();
4507 let code = words
4508 .next()
4509 .filter(|word| word.len() == 3)
4510 .and_then(|word| word.parse::<u16>().ok());
4511 if !matches!(version, Some("HTTP/1.1" | "HTTP/1.0"))
4512 || !code.is_some_and(|code| (100..600).contains(&code))
4513 {
4514 return Err(HealthProbeError::bad_answer(format!(
4515 "invalid HTTP status: {status}"
4516 )));
4517 }
4518 let code = code.expect("validated status code");
4519 response_status = status.clone();
4520 let mut length = None;
4521 let mut chunked = false;
4522 loop {
4523 let line = http_line(&mut reader, &mut budget).await?;
4524 if line.is_empty() {
4525 break;
4526 }
4527 if let Some((name, value)) = line.split_once(':') {
4528 if name.eq_ignore_ascii_case("content-length") {
4529 length = Some(value.trim().parse::<u64>().map_err(|_| {
4530 HealthProbeError::bad_answer("invalid HTTP Content-Length")
4531 })?);
4532 } else if name.eq_ignore_ascii_case("transfer-encoding") {
4533 chunked = value.trim().eq_ignore_ascii_case("chunked");
4534 }
4535 }
4536 }
4537 if chunked {
4538 while body.len() < 200 {
4539 let line = http_line(&mut reader, &mut budget).await?;
4540 let size = u64::from_str_radix(line.split(';').next().unwrap_or("").trim(), 16)
4541 .map_err(|_| HealthProbeError::bad_answer("invalid HTTP chunk size"))?;
4542 if size == 0 {
4543 break;
4544 }
4545 let count = size.min((200 - body.len()) as u64) as usize;
4546 let start = body.len();
4547 (&mut reader)
4548 .take(count as u64)
4549 .read_to_end(&mut body)
4550 .await
4551 .map_err(|error| {
4552 HealthProbeError::no_answer(format!("HTTP body read failed: {error}"))
4553 })?;
4554 if body.len() - start != count {
4555 return Err(HealthProbeError::bad_answer("truncated HTTP chunk"));
4556 }
4557 if size > count as u64 || body.len() == 200 {
4558 break;
4559 }
4560 if !http_line(&mut reader, &mut budget).await?.is_empty() {
4561 return Err(HealthProbeError::bad_answer("invalid HTTP chunk delimiter"));
4562 }
4563 }
4564 } else {
4565 reader
4566 .take(length.unwrap_or(200).min(200))
4567 .read_to_end(&mut body)
4568 .await
4569 .map_err(|error| {
4570 HealthProbeError::no_answer(format!("HTTP body read failed: {error}"))
4571 })?;
4572 }
4573 if (200..300).contains(&code) {
4574 Ok(HealthReport::ok())
4575 } else {
4576 Err(HealthProbeError::bad_answer(
4577 "HTTP health endpoint returned non-2xx",
4578 ))
4579 }
4580 };
4581 let mut result = timeout(deadline, probe).await.unwrap_or_else(|_| {
4582 Err(HealthProbeError::no_answer(format!(
4583 "HTTP probe timed out after {deadline:?}"
4584 )))
4585 });
4586 if let Err(error) = &mut result {
4587 if !response_status.is_empty() {
4588 error.message = format!(
4589 "{}; {response_status}: {}",
4590 error.message,
4591 String::from_utf8_lossy(&body)
4592 );
4593 }
4594 }
4595 result
4596}
4597
4598async fn http_line(
4599 reader: &mut tokio::io::BufReader<tokio::net::TcpStream>,
4600 remaining: &mut usize,
4601) -> Result<String, HealthProbeError> {
4602 use tokio::io::{AsyncBufReadExt, AsyncReadExt};
4603 let mut line = Vec::new();
4604 (&mut *reader)
4605 .take(*remaining as u64)
4606 .read_until(b'\n', &mut line)
4607 .await
4608 .map_err(|error| {
4609 HealthProbeError::no_answer(format!("HTTP header read failed: {error}"))
4610 })?;
4611 *remaining -= line.len();
4612 if !line.ends_with(b"\r\n") {
4613 return Err(HealthProbeError::bad_answer(
4614 "HTTP headers are incomplete or exceed 16 KiB",
4615 ));
4616 }
4617 line.truncate(line.len() - 2);
4618 String::from_utf8(line).map_err(|_| HealthProbeError::bad_answer("HTTP header is not UTF-8"))
4619}
4620
4621async fn probe_module_health(
4622 module_id: &str,
4623 runtime: &SupervisorRuntimeConfig,
4624 drain_deadline: Option<Instant>,
4625) -> Result<HealthReport, HealthProbeError> {
4626 let Some(forwarding) = runtime.forwarding.as_ref() else {
4627 return Err(HealthProbeError::misconfigured(
4628 "supervisor was not configured with a forwarding table",
4629 ));
4630 };
4631 let probe_started_at = Instant::now();
4632 let mut deadline = probe_started_at + runtime.health.deadline;
4633 if let Some(drain_deadline) = drain_deadline {
4634 deadline = deadline.min(drain_deadline);
4635 }
4636 let pending = if drain_deadline.is_some() {
4637 forwarding.begin_drain_health_probe_rpc_for(
4638 module_id,
4639 MODULE_CONTROL_OP_HEALTH_CHECK,
4640 probe_started_at,
4641 deadline,
4642 )
4643 } else {
4644 forwarding.begin_health_probe_rpc_for(
4645 module_id,
4646 MODULE_CONTROL_OP_HEALTH_CHECK,
4647 probe_started_at,
4648 deadline,
4649 )
4650 }
4651 .map_err(|err| {
4652 HealthProbeError::lane_dead(format!("failed to begin health.check RPC: {err}"))
4655 })?;
4656 await_health_probe(forwarding, pending, deadline, runtime.health.deadline).await
4657}
4658
4659async fn probe_endpoint_health(
4666 endpoint: crate::ModuleEndpointId,
4667 runtime: &SupervisorRuntimeConfig,
4668 deadline_cap: Option<Instant>,
4669) -> Result<HealthReport, HealthProbeError> {
4670 let Some(forwarding) = runtime.forwarding.as_ref() else {
4671 return Err(HealthProbeError::misconfigured(
4672 "supervisor was not configured with a forwarding table",
4673 ));
4674 };
4675 let probe_started_at = Instant::now();
4676 let mut deadline = probe_started_at + runtime.health.deadline;
4677 if let Some(cap) = deadline_cap {
4678 deadline = deadline.min(cap);
4679 }
4680 let pending = forwarding
4681 .begin_endpoint_health_probe_rpc_for(
4682 endpoint,
4683 MODULE_CONTROL_OP_HEALTH_CHECK,
4684 probe_started_at,
4685 deadline,
4686 )
4687 .map_err(|err| {
4688 HealthProbeError::lane_dead(format!("failed to begin health.check RPC: {err}"))
4689 })?;
4690 await_health_probe(forwarding, pending, deadline, runtime.health.deadline).await
4691}
4692
4693async fn await_health_probe(
4695 forwarding: &ForwardingTable,
4696 pending: PendingModuleControlRpc,
4697 deadline: Instant,
4698 probe_budget: Duration,
4699) -> Result<HealthReport, HealthProbeError> {
4700 let PendingModuleControlRpc {
4701 endpoint,
4702 module_sink,
4703 negotiated_ver,
4704 corr,
4705 receiver,
4706 } = pending;
4707 let body = serde_json::to_vec(&ModuleControlRequest::HealthCheck {}).map_err(|err| {
4708 HealthProbeError::misconfigured(format!("failed to encode health.check: {err}"))
4709 })?;
4710 let frame = Frame::build_with_version(
4711 negotiated_ver,
4712 FrameType::Request,
4713 control_flags(),
4714 0,
4715 0,
4716 corr,
4717 body,
4718 )
4719 .map_err(|err| {
4720 HealthProbeError::misconfigured(format!("failed to build health.check frame: {err}"))
4721 })?;
4722
4723 match timeout_at(deadline, module_sink.send(frame)).await {
4729 Ok(Ok(())) => {}
4730 Ok(Err(err)) => {
4731 let _ = forwarding.cancel_module_control_rpc(endpoint, corr);
4732 return Err(HealthProbeError::lane_dead(format!(
4735 "failed to send health.check: {err}"
4736 )));
4737 }
4738 Err(_elapsed) => {
4739 let _ = forwarding.cancel_module_control_rpc(endpoint, corr);
4740 return Err(HealthProbeError::no_answer(
4744 "health.check send timed out before enqueue (module egress full)",
4745 ));
4746 }
4747 }
4748
4749 match timeout_at(deadline, receiver).await {
4750 Ok(Ok(ModuleControlRpcOutcome::Response(response))) => {
4754 response.health_report().ok_or_else(|| {
4755 HealthProbeError::bad_answer("health.check RPC returned a non-health response")
4756 })
4757 }
4758 Ok(Ok(ModuleControlRpcOutcome::Rejected(body))) => Err(HealthProbeError::bad_answer(
4759 format!("health.check rejected: {}", body.message),
4760 )),
4761 Ok(Ok(ModuleControlRpcOutcome::ModuleGone(message))) => {
4762 Err(HealthProbeError::lane_dead(message))
4763 }
4764 Ok(Ok(ModuleControlRpcOutcome::MalformedResponse(message))) => {
4765 Err(HealthProbeError::bad_answer(message))
4766 }
4767 Ok(Ok(ModuleControlRpcOutcome::UnexpectedOp { expected, actual })) => {
4768 Err(HealthProbeError::bad_answer(format!(
4769 "expected module-control op '{expected}', got '{actual}'"
4770 )))
4771 }
4772 Ok(Ok(ModuleControlRpcOutcome::DeadlineElapsed)) => Err(HealthProbeError::bad_answer(
4776 "module answered health.check after its daemon deadline",
4777 )),
4778 Ok(Err(_)) => Err(HealthProbeError::misconfigured(
4779 "health.check waiter was canceled before the module responded",
4780 )),
4781 Err(_) => {
4782 let _ = forwarding.tombstone_health_probe_rpc(endpoint, corr);
4783 Err(HealthProbeError::no_answer(format!(
4784 "module did not answer health.check within {probe_budget:?}"
4785 )))
4786 }
4787 }
4788}
4789
4790#[allow(clippy::too_many_arguments)]
4791async fn handle_health_report(
4792 spec: &ModuleSpec,
4793 runtime: &SupervisorRuntimeConfig,
4794 registry: &Registry,
4795 process_liveness: &SupervisorProcessLiveness,
4796 snapshot: &SharedSnapshot,
4797 child: &mut Option<SupervisedChild>,
4798 report: HealthReport,
4799 now_ms: u64,
4800) {
4801 let status = supervisor_health_status(report.status);
4802 let detail = report.detail.clone();
4803 let metrics = truncate_health_metrics(report.metrics);
4804 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4805 state.health.status = status;
4806 state.health.last_probe_ms = Some(now_ms);
4807 state.health.detail = detail.clone();
4808 state.health.metrics = metrics.clone();
4809 state.health.consecutive_failures = 0;
4810 });
4811
4812 let action = match report.status {
4813 HealthStatus::Ok => return,
4814 HealthStatus::Degraded => runtime.health.on_degraded,
4815 HealthStatus::Failing => runtime.health.on_failing,
4816 };
4817 apply_l3_health_action(
4818 spec,
4819 runtime,
4820 registry,
4821 process_liveness,
4822 snapshot,
4823 child,
4824 status,
4825 detail.as_deref(),
4826 action,
4827 now_ms,
4828 )
4829 .await;
4830}
4831
4832#[allow(clippy::too_many_arguments)]
4833async fn handle_health_probe_failure(
4834 spec: &ModuleSpec,
4835 runtime: &SupervisorRuntimeConfig,
4836 registry: &Registry,
4837 process_liveness: &SupervisorProcessLiveness,
4838 snapshot: &SharedSnapshot,
4839 child: &mut Option<SupervisedChild>,
4840 err: HealthProbeError,
4841 now_ms: u64,
4842) {
4843 let threshold = runtime.health.failure_threshold.max(1);
4844 let mut failures = 0;
4845 let detail = format!("[{}] {err}", err.label());
4850 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4851 if state.spawned_protocol.unwrap_or(spec.protocol) == ModuleProtocol::Subc {
4854 state.health.status = SupervisorHealthStatus::Unknown;
4855 }
4856 state.health.last_probe_ms = Some(now_ms);
4857 state.health.consecutive_failures = state.health.consecutive_failures.saturating_add(1);
4858 state.health.detail = Some(detail.clone());
4859 state.health.metrics = None;
4860 failures = state.health.consecutive_failures;
4861 });
4862
4863 if failures < threshold {
4864 warn!(
4865 module_id = %spec.module_id,
4866 consecutive_failures = failures,
4867 threshold,
4868 evidence = err.label(),
4869 detail = %detail,
4870 "health.check probe failed"
4871 );
4872 return;
4873 }
4874
4875 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4876 state.state = ModuleState::Unresponsive;
4877 state.health.status = SupervisorHealthStatus::Unresponsive;
4878 });
4879 if runtime.health.critical {
4883 error!(
4884 module_id = %spec.module_id,
4885 status = "unresponsive",
4886 evidence = err.label(),
4887 detail = %detail,
4888 "critical module health alert"
4889 );
4890 } else {
4891 warn!(
4892 module_id = %spec.module_id,
4893 status = "unresponsive",
4894 evidence = err.label(),
4895 detail = %detail,
4896 "module health threshold breached"
4897 );
4898 }
4899 if let Err(err) = health_restart_child(
4900 spec,
4901 runtime,
4902 registry,
4903 process_liveness,
4904 snapshot,
4905 child,
4906 SupervisorHealthStatus::Unresponsive,
4907 Some(&detail),
4908 now_ms,
4909 )
4910 .await
4911 {
4912 error!(module_id = %spec.module_id, error = %err, "health-triggered restart failed");
4913 }
4914}
4915
4916#[allow(clippy::too_many_arguments)]
4917async fn apply_l3_health_action(
4918 spec: &ModuleSpec,
4919 runtime: &SupervisorRuntimeConfig,
4920 registry: &Registry,
4921 process_liveness: &SupervisorProcessLiveness,
4922 snapshot: &SharedSnapshot,
4923 child: &mut Option<SupervisedChild>,
4924 status: SupervisorHealthStatus,
4925 detail: Option<&str>,
4926 action: HealthAction,
4927 now_ms: u64,
4928) {
4929 record_health_action(snapshot, &spec.module_id, action.to_string(), now_ms);
4930 match action {
4931 HealthAction::Report => {
4932 info!(
4933 module_id = %spec.module_id,
4934 status = ?status,
4935 detail,
4936 "module reported non-ok health"
4937 );
4938 }
4939 HealthAction::Alert => {
4940 error!(
4941 module_id = %spec.module_id,
4942 status = ?status,
4943 detail,
4944 "module health alert"
4945 );
4946 }
4947 HealthAction::Restart => {
4948 if let Err(err) = health_restart_child(
4949 spec,
4950 runtime,
4951 registry,
4952 process_liveness,
4953 snapshot,
4954 child,
4955 status,
4956 detail,
4957 now_ms,
4958 )
4959 .await
4960 {
4961 error!(module_id = %spec.module_id, error = %err, "health-triggered restart failed");
4962 }
4963 }
4964 }
4965}
4966
4967#[allow(clippy::too_many_arguments)]
4968async fn health_restart_child(
4969 spec: &ModuleSpec,
4970 runtime: &SupervisorRuntimeConfig,
4971 registry: &Registry,
4972 process_liveness: &SupervisorProcessLiveness,
4973 snapshot: &SharedSnapshot,
4974 child: &mut Option<SupervisedChild>,
4975 status: SupervisorHealthStatus,
4976 detail: Option<&str>,
4977 now_ms: u64,
4978) -> Result<(), SuperviseError> {
4979 let (enabled, schedule) = {
4980 let mut state = lock_snapshot(snapshot)?;
4981 let enabled = state.enabled;
4982 let schedule = if enabled {
4983 state.next_crash_restart(&runtime.restart_policy, Instant::now())
4984 } else {
4985 None
4986 };
4987 (enabled, schedule)
4988 };
4989
4990 if !enabled {
4991 return Err(SuperviseError::Disabled {
4992 module_id: spec.module_id.clone(),
4993 });
4994 }
4995
4996 if schedule.is_none() {
4997 record_health_action(snapshot, &spec.module_id, "failed".to_string(), now_ms);
4998 error!(
4999 module_id = %spec.module_id,
5000 status = ?status,
5001 detail,
5002 max_restarts = runtime.restart_policy.max_restarts,
5003 window_secs = runtime.restart_policy.window.as_secs(),
5004 reason = %runtime.restart_policy.budget_exhausted_detail(),
5005 "health restart budget exhausted; marking module failed"
5006 );
5007 let stop_notice = begin_forwarding_drain_if_configured(
5008 spec,
5009 runtime,
5010 registry,
5011 snapshot,
5012 Some(true),
5013 RouteCloseReason::Disable,
5014 )
5015 .await?;
5016 update_snapshot(snapshot, Some(&spec.module_id), |state| {
5017 state.drain_disposition_detail = Some(runtime.restart_policy.budget_exhausted_detail());
5018 })?;
5019 drain_optional_child(
5020 &spec.module_id,
5021 spec.protocol,
5022 stop_notice,
5023 registry,
5024 runtime.forwarding.as_deref(),
5025 snapshot,
5026 &runtime.terminal_ring,
5027 &runtime.spawn_events,
5028 child,
5029 runtime.drain_timeout,
5030 ModuleState::Failed,
5031 Some(true),
5032 )
5033 .await?;
5034 process_liveness.untrack_if_current(&spec.module_id, snapshot);
5035 return Ok(());
5036 }
5037
5038 let schedule = schedule.expect("a health restart must have a crash-restart schedule");
5039 let mut restart_count = 0;
5040 update_snapshot(snapshot, Some(&spec.module_id), |state| {
5041 restart_count = state.crash_restarts.len();
5042 state.state = ModuleState::Unresponsive;
5043 state.health.status = status;
5044 state.health.last_action = Some(HealthAction::Restart.to_string());
5045 state.health.last_action_ms = Some(now_ms);
5046 })?;
5047 warn!(
5048 module_id = %spec.module_id,
5049 status = ?status,
5050 detail,
5051 restart_count,
5052 restart_in_window = schedule.restart_in_window,
5053 delay_ms = schedule.delay.as_millis() as u64,
5054 "health-triggered module restart"
5055 );
5056
5057 let stop_notice = begin_forwarding_drain_if_configured(
5058 spec,
5059 runtime,
5060 registry,
5061 snapshot,
5062 Some(true),
5063 RouteCloseReason::Restart,
5064 )
5065 .await?;
5066 drain_optional_child(
5067 &spec.module_id,
5068 spec.protocol,
5069 stop_notice,
5070 registry,
5071 runtime.forwarding.as_deref(),
5072 snapshot,
5073 &runtime.terminal_ring,
5074 &runtime.spawn_events,
5075 child,
5076 runtime.drain_timeout,
5077 ModuleState::Restarting,
5078 Some(true),
5079 )
5080 .await?;
5081 schedule_respawn(
5082 runtime,
5083 snapshot,
5084 &spec.module_id,
5085 schedule.delay,
5086 RespawnKind::Spawn,
5087 )
5088}
5089
5090fn cancel_deferred_reload(runtime: &SupervisorRuntimeConfig, module_id: &str, reason: &str) {
5091 if let Some(reply) = runtime
5092 .deferred_reload_reply
5093 .lock()
5094 .unwrap_or_else(|p| p.into_inner())
5095 .take()
5096 {
5097 let _ = reply.send(Err(SuperviseError::ReloadFailed {
5098 module_id: module_id.to_string(),
5099 reason: reason.to_string(),
5100 }));
5101 }
5102}
5103
5104fn schedule_respawn(
5105 runtime: &SupervisorRuntimeConfig,
5106 snapshot: &SharedSnapshot,
5107 module_id: &str,
5108 delay: Duration,
5109 kind: RespawnKind,
5110) -> Result<(), SuperviseError> {
5111 cancel_deferred_reload(runtime, module_id, "respawn superseded by another restart");
5112 update_snapshot(snapshot, Some(module_id), |state| {
5113 state.respawn_pending = true
5114 })?;
5115 *runtime
5116 .scheduled_respawn
5117 .lock()
5118 .unwrap_or_else(|p| p.into_inner()) = Some(PendingRespawn {
5119 deadline: Instant::now() + delay,
5120 kind,
5121 });
5122 Ok(())
5123}
5124
5125fn record_health_action(snapshot: &SharedSnapshot, module_id: &str, action: String, now_ms: u64) {
5126 let _ = update_snapshot(snapshot, Some(module_id), |state| {
5127 state.health.last_action = Some(action);
5128 state.health.last_action_ms = Some(now_ms);
5129 });
5130}
5131
5132fn supervisor_health_status(status: HealthStatus) -> SupervisorHealthStatus {
5133 match status {
5134 HealthStatus::Ok => SupervisorHealthStatus::Ok,
5135 HealthStatus::Degraded => SupervisorHealthStatus::Degraded,
5136 HealthStatus::Failing => SupervisorHealthStatus::Failing,
5137 }
5138}
5139
5140fn truncate_health_metrics(metrics: Option<Value>) -> Option<Value> {
5152 let metrics = metrics?;
5153 match serde_json::to_vec(&metrics) {
5154 Ok(encoded) if encoded.len() > MAX_HEALTH_METRICS_BYTES => Some(serde_json::json!({
5155 "truncated": true,
5156 "original_bytes": encoded.len(),
5157 })),
5158 Ok(_) | Err(_) => Some(metrics),
5159 }
5160}
5161
5162fn jittered_health_delay(module_id: &str, probe_index: u64, cadence: Duration) -> Duration {
5168 if cadence.is_zero() {
5169 return Duration::ZERO;
5170 }
5171 let cadence_ms = cadence.as_millis() as u64;
5172 if cadence_ms == 0 {
5188 return cadence;
5189 }
5190 let jitter_span = (cadence_ms / 10).max(1);
5205 let hash = module_id.as_bytes().iter().fold(
5206 probe_index.wrapping_mul(0x9E37_79B9_7F4A_7C15),
5207 |acc, byte| {
5208 acc.wrapping_mul(1099511628211)
5209 .wrapping_add(u64::from(*byte))
5210 },
5211 );
5212 cadence + Duration::from_millis(hash % jitter_span)
5213}
5214
5215#[cfg(test)]
5216mod tests {
5217 use super::*;
5218
5219 #[test]
5220 fn readding_a_module_clears_its_rescan_removal_tombstone() {
5221 let handle = SupervisorHandle::new();
5222 let module_id = "readded-tombstone";
5223 handle.record_rescan_removal(module_id);
5224 assert!(handle.removal_tombstone_age_ms(module_id).is_some());
5225
5226 handle.apply_identity_configuration(&ModuleSpec {
5227 module_id: module_id.to_string(),
5228 program: PathBuf::from("/test/module"),
5229 args: Vec::new(),
5230 env: Vec::new(),
5231 reserved: false,
5232 reserved_prefixes: Vec::new(),
5233 protocol: ModuleProtocol::Subc,
5234 overlap: Default::default(),
5235 });
5236
5237 assert!(
5238 handle.removal_tombstone_age_ms(module_id).is_none(),
5239 "a re-added module must not retain a stale removal tombstone"
5240 );
5241 }
5242
5243 #[derive(Debug, PartialEq, Eq)]
5246 struct OwnerInSpawnWindow {
5247 module_id: String,
5248 configured: bool,
5249 on_roster: bool,
5250 admission_refusal: Option<&'static str>,
5251 }
5252
5253 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5264 async fn a_new_module_is_configured_before_its_first_process_can_register() {
5265 use crate::scopes::ScopeTable;
5266 use subc_protocol::{error_codes, scope::ScopeSelector, Principal};
5267
5268 let dir = cortexkit_test_support::ScratchDir::new("configured-before-spawn");
5269 let stub = |module_id: &str, program: PathBuf| ModuleSpec {
5270 module_id: module_id.to_string(),
5271 program,
5272 args: Vec::new(),
5273 env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
5274 .into_iter()
5275 .map(|key| (key.to_string(), dir.path().display().to_string()))
5276 .chain([("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string())])
5277 .collect(),
5278 reserved: false,
5279 reserved_prefixes: Vec::new(),
5280 protocol: ModuleProtocol::Subc,
5281 overlap: Default::default(),
5282 };
5283 let live = super::terminal_history_tests::fake_aft_stub_path();
5284 let missing = dir.path().join("definitely-missing-module");
5285
5286 let handle = SupervisorHandle::new();
5287 let mut supervisor =
5288 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5289 .with_handle(handle.clone());
5290 let observed = Arc::new(Mutex::new(Vec::<OwnerInSpawnWindow>::new()));
5291 let hook_handle = handle.clone();
5292 let hook_observed = Arc::clone(&observed);
5293 supervisor.test_after_first_spawn = AfterFirstSpawnHook(Some(Arc::new(move |module_id| {
5294 let configured = hook_handle.is_configured(module_id);
5297 let selector = ScopeSelector {
5298 owner: Principal::Reserved {
5299 module_id: module_id.to_string(),
5300 },
5301 scope_ref: "s".to_string(),
5302 scope_epoch: Some(1),
5303 };
5304 let carrier = Principal::Reserved {
5305 module_id: "carrier".to_string(),
5306 };
5307 let admission_refusal = match ScopeTable::new(Vec::<String>::new())
5308 .admit(&carrier, module_id, &selector, configured)
5309 {
5310 Ok(_) => None,
5311 Err(refusal) => Some(refusal.code),
5312 };
5313 hook_observed.lock().unwrap().push(OwnerInSpawnWindow {
5314 module_id: module_id.to_string(),
5315 configured,
5316 on_roster: hook_handle.get(module_id).is_some(),
5317 admission_refusal,
5318 });
5319 })));
5320
5321 let plain = supervisor.spawn(stub("plain", live.clone())).unwrap();
5322 let configured = supervisor
5323 .supervise_configured(stub("configured", live.clone()), true)
5324 .unwrap();
5325 let with_health = supervisor
5326 .supervise_configured_with_health(
5327 stub("with-health", live.clone()),
5328 true,
5329 HealthConfig::default(),
5330 None,
5331 RestartPolicy::default(),
5332 )
5333 .unwrap();
5334 let failed = supervisor
5337 .supervise_configured_with_health(
5338 stub("failed-spawn", missing.clone()),
5339 true,
5340 HealthConfig::default(),
5341 None,
5342 RestartPolicy::default(),
5343 )
5344 .unwrap();
5345 assert!(supervisor.spawn(stub("spawn-error", missing)).is_err());
5348
5349 let expected = [
5350 "plain",
5351 "configured",
5352 "with-health",
5353 "failed-spawn",
5354 "spawn-error",
5355 ]
5356 .into_iter()
5357 .map(|module_id| OwnerInSpawnWindow {
5358 module_id: module_id.to_string(),
5359 configured: true,
5360 on_roster: false,
5361 admission_refusal: Some(error_codes::SCOPE_NOT_SYNCED),
5362 })
5363 .collect::<Vec<_>>();
5364 assert_eq!(*observed.lock().unwrap(), expected);
5365
5366 for module_id in ["plain", "configured", "with-health", "failed-spawn"] {
5367 assert!(
5368 handle.get(module_id).is_some(),
5369 "{module_id} is on the roster"
5370 );
5371 assert!(
5372 handle.is_configured(module_id),
5373 "{module_id} stays configured"
5374 );
5375 }
5376 assert!(handle.get("spawn-error").is_none());
5377 assert!(
5378 !handle.is_configured("spawn-error"),
5379 "a plain spawn that failed must not leave its module marked configured"
5380 );
5381
5382 handle.retire("failed-spawn");
5384 assert!(!handle.is_configured("failed-spawn"));
5385
5386 for module in [plain, configured, with_health] {
5387 module.stop().await.unwrap();
5388 }
5389 drop(failed);
5390 }
5391
5392 fn stale_process_snapshot(state: ModuleState, enabled: bool) -> SharedSnapshot {
5393 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(state, enabled)));
5394 update_snapshot(&snapshot, Some("stale-process-facts"), |snapshot| {
5395 snapshot.process_alive = true;
5396 snapshot.pid = Some(41);
5397 snapshot.spawned_at_ms = Some(42);
5398 snapshot.spawned_from = Some(PathBuf::from("/spawned/module"));
5399 snapshot.spawned_file_identity = Some(SpawnedFileIdentity {
5400 device: 43,
5401 inode: 44,
5402 #[cfg(windows)]
5403 windows_image: None,
5404 });
5405 })
5406 .unwrap();
5407 snapshot
5408 }
5409
5410 fn assert_snapshot_process_facts_cleared(snapshot: &SharedSnapshot) {
5411 let snapshot = lock_snapshot(snapshot).unwrap();
5412 assert!(!snapshot.process_alive);
5413 assert_eq!(snapshot.pid, None);
5414 assert_eq!(snapshot.spawned_at_ms, None);
5415 assert_eq!(snapshot.spawned_from, None);
5416 assert_eq!(snapshot.spawned_file_identity, None);
5417 }
5418
5419 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5420 async fn failed_enable_spawn_clears_preexisting_current_process_facts() {
5421 let supervisor =
5422 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5423 let mut runtime = supervisor.runtime_config();
5424 runtime.test_seed_stale_facts_before_enable_spawn = true;
5425 let snapshot = stale_process_snapshot(ModuleState::Disabled, false);
5426 let mut child = None;
5427 let spec = ModuleSpec {
5428 module_id: "failed-enable-clears-facts".to_string(),
5429 program: PathBuf::from("/definitely/missing/failed-enable-module"),
5430 args: Vec::new(),
5431 env: Vec::new(),
5432 reserved: false,
5433 reserved_prefixes: Vec::new(),
5434 protocol: ModuleProtocol::Subc,
5435 overlap: Default::default(),
5436 };
5437
5438 let result = set_child_enabled(
5439 &spec,
5440 &runtime,
5441 &supervisor.registry,
5442 &supervisor.process_liveness,
5443 &snapshot,
5444 &mut child,
5445 true,
5446 )
5447 .await;
5448
5449 assert!(matches!(result, Err(SuperviseError::Spawn { .. })));
5450 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
5451 assert_snapshot_process_facts_cleared(&snapshot);
5452 }
5453
5454 #[tokio::test]
5455 async fn start_revives_stranded_restarting_but_not_pending_backoff() {
5456 let supervisor =
5457 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5458 let runtime = supervisor.runtime_config();
5459 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(
5460 ModuleState::Restarting,
5461 true,
5462 )));
5463 let spec = ModuleSpec {
5464 module_id: "start-stranded-restarting".to_string(),
5465 program: super::terminal_history_tests::fake_aft_stub_path(),
5466 args: Vec::new(),
5467 env: vec![("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string())],
5468 reserved: false,
5469 reserved_prefixes: Vec::new(),
5470 protocol: ModuleProtocol::None,
5471 overlap: Default::default(),
5472 };
5473 let mut child = None;
5474 lock_snapshot(&snapshot).unwrap().respawn_pending = true;
5475 assert!(!super::set_child_enabled(
5476 &spec,
5477 &runtime,
5478 &Registry::default(),
5479 &supervisor.process_liveness,
5480 &snapshot,
5481 &mut child,
5482 true
5483 )
5484 .await
5485 .unwrap());
5486 assert!(child.is_none());
5487 lock_snapshot(&snapshot).unwrap().respawn_pending = false;
5488 assert!(super::set_child_enabled(
5489 &spec,
5490 &runtime,
5491 &Registry::default(),
5492 &supervisor.process_liveness,
5493 &snapshot,
5494 &mut child,
5495 true
5496 )
5497 .await
5498 .unwrap());
5499 assert_eq!(
5500 lock_snapshot(&snapshot).unwrap().state,
5501 ModuleState::Running
5502 );
5503 let mut child = child.unwrap();
5504 child.start_kill().unwrap();
5505 child.wait().await.unwrap();
5506 }
5507
5508 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5509 async fn failed_reload_spawn_clears_current_process_facts() {
5510 let supervisor =
5511 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5512 let mut runtime = supervisor.runtime_config();
5513 runtime.restart_policy = RestartPolicy::new(0, Duration::ZERO);
5514 let snapshot = stale_process_snapshot(ModuleState::Running, true);
5515 let mut child = None;
5516 let spec = ModuleSpec {
5517 module_id: "failed-reload-clears-facts".to_string(),
5518 program: PathBuf::from("/unused/failed-reload-module"),
5519 args: Vec::new(),
5520 env: Vec::new(),
5521 reserved: false,
5522 reserved_prefixes: Vec::new(),
5523 protocol: ModuleProtocol::Subc,
5524 overlap: Default::default(),
5525 };
5526
5527 let result = handle_reload_spawn_failure(
5528 &spec,
5529 &runtime,
5530 &supervisor.process_liveness,
5531 &snapshot,
5532 &mut child,
5533 "forced reload spawn failure".to_string(),
5534 )
5535 .await;
5536
5537 assert!(matches!(result, Err(SuperviseError::ReloadFailed { .. })));
5538 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
5539 assert_snapshot_process_facts_cleared(&snapshot);
5540 }
5541
5542 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5543 async fn dropping_a_module_with_an_active_monitor_clears_current_process_facts() {
5544 let supervisor =
5545 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5546 let snapshot = stale_process_snapshot(ModuleState::Running, true);
5547 let module = supervisor.supervised_module(
5548 ModuleSpec {
5549 module_id: "drop-clears-facts".to_string(),
5550 program: PathBuf::from("/unused/drop-module"),
5551 args: Vec::new(),
5552 env: Vec::new(),
5553 reserved: false,
5554 reserved_prefixes: Vec::new(),
5555 protocol: ModuleProtocol::Subc,
5556 overlap: Default::default(),
5557 },
5558 supervisor.runtime_config(),
5559 Arc::clone(&snapshot),
5560 None,
5561 );
5562 assert!(!module
5563 .inner
5564 .monitor
5565 .lock()
5566 .unwrap()
5567 .as_ref()
5568 .unwrap()
5569 .is_finished());
5570
5571 drop(module);
5572
5573 assert_eq!(
5574 lock_snapshot(&snapshot).unwrap().state,
5575 ModuleState::Stopped
5576 );
5577 assert_snapshot_process_facts_cleared(&snapshot);
5578 }
5579
5580 #[cfg(unix)]
5581 #[tokio::test]
5582 async fn rescan_preserves_running_protocol_until_respawn() {
5583 let dir = cortexkit_test_support::ScratchDir::new("rescan-protocol");
5584 let initial = ModuleSpec {
5585 module_id: "rescan-protocol".into(),
5586 program: PathBuf::from("/bin/sleep"),
5587 args: vec!["60".into()],
5588 env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
5589 .into_iter()
5590 .map(|key| (key.into(), dir.path().to_string_lossy().into_owned()))
5591 .collect(),
5592 reserved: false,
5593 reserved_prefixes: vec![],
5594 protocol: ModuleProtocol::None,
5595 overlap: Default::default(),
5596 };
5597 let supervisor =
5598 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5599 let module = supervisor.spawn(initial.clone()).unwrap();
5600 assert!(module.status().unwrap().live);
5601 let mut next = initial;
5602 next.protocol = ModuleProtocol::Subc;
5603 module
5604 .update_configuration(next.clone(), HealthConfig::default(), None)
5605 .await
5606 .unwrap();
5607 assert!(
5608 module.status().unwrap().live,
5609 "rescan must not require HELLO from the old non-wire process"
5610 );
5611 let runtime = supervisor.runtime_config();
5612 let action = on_child_exit(
5613 &next,
5614 RestartPolicy::default(),
5615 &supervisor.registry,
5616 &module.inner.snapshot,
5617 &runtime.terminal_ring,
5618 &runtime.spawn_events,
5619 &runtime.child_roster,
5620 ExitReport {
5621 kind: ExitKind::Clean,
5622 code: Some(0),
5623 signal: None,
5624 at_ms: unix_ms_now(),
5625 },
5626 )
5627 .await;
5628 assert!(
5629 matches!(action, NextAction::Restart { .. }),
5630 "the old non-wire process's clean exit must restart"
5631 );
5632 module.drain().await.unwrap();
5633 }
5634
5635 #[cfg(unix)]
5636 #[tokio::test]
5637 async fn orphan_identity_matches_path_names_and_shebang_interpreters() {
5638 use std::os::unix::fs::PermissionsExt;
5639 let dir = cortexkit_test_support::ScratchDir::new("spawn-image-identity");
5640 let script = dir.join("module.sh");
5641 std::fs::write(&script, "#!/bin/sh\nwhile :; do sleep 0.1; done\n").unwrap();
5642 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o700)).unwrap();
5643 let record_path = dir.join("live-children.json");
5644 let supervisor =
5645 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5646 .with_live_children_record(&record_path);
5647 for (program, args) in [
5648 (PathBuf::from("sleep"), vec!["60".into()]),
5649 (script, vec![]),
5650 ] {
5651 let spec = ModuleSpec {
5652 module_id: "image-identity".into(),
5653 program: program.clone(),
5654 args,
5655 env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
5656 .into_iter()
5657 .map(|key| (key.into(), dir.to_string_lossy().into_owned()))
5658 .collect(),
5659 reserved: false,
5660 reserved_prefixes: vec![],
5661 protocol: ModuleProtocol::None,
5662 overlap: Default::default(),
5663 };
5664 let module = supervisor.spawn(spec).unwrap();
5665 #[cfg(target_os = "macos")]
5666 {
5667 let deadline = Instant::now() + Duration::from_secs(5);
5670 while crate::live_children::read_record(&record_path)
5671 .unwrap()
5672 .iter()
5673 .all(|entry| entry.executable.is_none())
5674 {
5675 assert!(Instant::now() < deadline, "module image was not confirmed");
5676 tokio::time::sleep(Duration::from_millis(5)).await;
5677 }
5678 }
5679 let entry = crate::live_children::read_record(&record_path)
5680 .unwrap()
5681 .pop()
5682 .unwrap();
5683 let observed = subc_os::Process::open(entry.pid)
5684 .unwrap()
5685 .unwrap()
5686 .observe()
5687 .unwrap();
5688 let verdict = crate::live_children::identity_verdict(&entry, entry.pid, &observed);
5689 module.drain().await.unwrap();
5690 assert_eq!(
5691 verdict,
5692 crate::live_children::IdentityVerdict::Matches,
5693 "program {program:?}: recorded {entry:?}, observed {observed:?}"
5694 );
5695 }
5696 }
5697
5698 #[cfg(unix)]
5699 fn http_fixture(
5700 dir: &std::path::Path,
5701 url: &str,
5702 threshold: u32,
5703 ) -> crate::daemon_config::ConfiguredModule {
5704 let path = dir.join("subc.jsonc");
5705 std::fs::write(&path, serde_json::json!({"version": 1, "modules": {"http-process": {
5706 "program": "/bin/sleep", "args": ["60"], "protocol": "none",
5707 "env": {"XDG_DATA_HOME":dir,"XDG_RUNTIME_DIR":dir,"XDG_CONFIG_HOME":dir},
5708 "health": {"http":url,"cadence_ms":20,"deadline_ms":200,"failure_threshold":threshold},
5709 "restart": {"backoff_ms":1,"max_backoff_ms":1}, "drain_timeout_ms":10
5710 }}}).to_string()).unwrap();
5711 crate::daemon_config::load(&path)
5712 .unwrap()
5713 .unwrap()
5714 .modules
5715 .pop()
5716 .unwrap()
5717 }
5718
5719 #[cfg(unix)]
5720 async fn wait_http_health(module: &SupervisedModule, status: SupervisorHealthStatus) {
5721 timeout(Duration::from_secs(5), async {
5722 loop {
5723 if module.status().unwrap().health.status == status {
5724 break;
5725 }
5726 sleep(Duration::from_millis(5)).await;
5727 }
5728 })
5729 .await
5730 .unwrap_or_else(|_| {
5731 panic!(
5732 "expected {status:?}, got {:?}",
5733 module.status().unwrap().health
5734 )
5735 });
5736 }
5737
5738 #[cfg(unix)]
5739 #[tokio::test]
5740 async fn http_health_status_flips_ok_failing_ok() {
5741 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5742 let dir = cortexkit_test_support::ScratchDir::new("http-status-flips");
5743 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5744 let status = Arc::new(std::sync::atomic::AtomicU16::new(200));
5745 let serving_status = status.clone();
5746 let url = format!("http://{}/healthz", listener.local_addr().unwrap());
5747 let server = tokio::spawn(async move {
5748 loop {
5749 let (mut stream, _) = listener.accept().await.unwrap();
5750 let mut request = [0u8; 2048];
5751 let count = stream.read(&mut request).await.unwrap();
5752 assert!(count > 0, "a probe must send an HTTP request");
5753 let code = serving_status.load(std::sync::atomic::Ordering::SeqCst);
5754 let body = if code == 200 {
5755 "ready"
5756 } else {
5757 "{\"status\":\"unavailable\",\"error\":\"scratch failure\"}"
5758 };
5759 let response = format!("HTTP/1.1 {code} scratch\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());
5760 let _ = stream.write_all(response.as_bytes()).await;
5761 }
5762 });
5763 let configured = http_fixture(&dir, &url, 1000);
5764 let module =
5765 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5766 .supervise_configured_with_health(
5767 configured.module_spec(),
5768 true,
5769 configured.health,
5770 configured.drain_timeout_ms,
5771 configured.restart,
5772 )
5773 .unwrap();
5774 wait_http_health(&module, SupervisorHealthStatus::Ok).await;
5775 status.store(503, std::sync::atomic::Ordering::SeqCst);
5776 wait_http_health(&module, SupervisorHealthStatus::Failing).await;
5777 assert!(module
5778 .status()
5779 .unwrap()
5780 .health
5781 .detail
5782 .unwrap()
5783 .contains("scratch failure"));
5784 status.store(200, std::sync::atomic::Ordering::SeqCst);
5785 wait_http_health(&module, SupervisorHealthStatus::Ok).await;
5786 assert_eq!(module.status().unwrap().health.consecutive_failures, 0);
5787 let before = module.status().unwrap();
5788 let (spec, mut health) = module.configuration().unwrap();
5789 health.http = None;
5790 module
5791 .update_configuration(spec.clone(), health.clone(), Some(10))
5792 .await
5793 .unwrap();
5794 wait_http_health(&module, SupervisorHealthStatus::Unknown).await;
5795 health.http = Some(url);
5796 module
5797 .update_configuration(spec, health, Some(10))
5798 .await
5799 .unwrap();
5800 wait_http_health(&module, SupervisorHealthStatus::Ok).await;
5801 assert_eq!(
5802 module.status().unwrap().pid,
5803 before.pid,
5804 "changing a probe must apply live, not restart its process"
5805 );
5806 let (spec, mut health) = module.configuration().unwrap();
5807 health.failure_threshold = 2;
5808 module
5809 .update_configuration(spec, health, Some(10))
5810 .await
5811 .unwrap();
5812 status.store(503, std::sync::atomic::Ordering::SeqCst);
5813 timeout(Duration::from_secs(5), async {
5814 while module.status().unwrap().spawn_generation == before.spawn_generation {
5815 sleep(Duration::from_millis(5)).await;
5816 }
5817 })
5818 .await
5819 .expect("sustained HTTP 503 responses must trigger the health restart policy");
5820 module.drain().await.unwrap();
5821 server.abort();
5822 }
5823
5824 #[cfg(unix)]
5825 #[tokio::test]
5826 async fn http_health_no_listener_restarts_after_consecutive_failures() {
5827 let dir = cortexkit_test_support::ScratchDir::new("http-refused");
5828 let unused = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5829 let url = format!("http://{}/healthz", unused.local_addr().unwrap());
5830 drop(unused);
5831 let configured = http_fixture(&dir, &url, 2);
5832 let module =
5833 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5834 .supervise_configured_with_health(
5835 configured.module_spec(),
5836 true,
5837 configured.health,
5838 configured.drain_timeout_ms,
5839 configured.restart,
5840 )
5841 .unwrap();
5842 let before = module.status().unwrap().spawn_generation;
5843 timeout(Duration::from_secs(5), async {
5844 loop {
5845 let status = module.status().unwrap();
5846 if status.spawn_generation > before {
5847 assert!(status.lifetime_restarts > 0);
5848 break;
5849 }
5850 sleep(Duration::from_millis(5)).await;
5851 }
5852 })
5853 .await
5854 .expect("sustained HTTP refusal must trigger the health restart policy");
5855 module.drain().await.unwrap();
5856 }
5857
5858 #[cfg(unix)]
5859 #[tokio::test]
5860 async fn http_health_timeout_honours_deadline() {
5861 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5862 let url = format!("http://{}/healthz", listener.local_addr().unwrap());
5863 let server = tokio::spawn(async move {
5864 let _held = listener.accept().await.unwrap();
5865 std::future::pending::<()>().await;
5866 });
5867 let error = timeout(
5868 Duration::from_secs(1),
5869 probe_http_health(&url, Duration::from_millis(10)),
5870 )
5871 .await
5872 .expect("the probe must enforce its own deadline")
5873 .unwrap_err();
5874 server.abort();
5875 assert!(error.to_string().contains("timed out"));
5876 }
5877
5878 #[cfg(unix)]
5879 #[tokio::test]
5880 async fn http_health_localhost_reaches_an_ipv6_only_listener() {
5881 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5882 let listener = tokio::net::TcpListener::bind("[::1]:0").await.unwrap();
5883 let url = format!(
5884 "http://localhost:{}/healthz",
5885 listener.local_addr().unwrap().port()
5886 );
5887 let server = tokio::spawn(async move {
5888 let (mut stream, _) = listener.accept().await.unwrap();
5889 let mut request = [0u8; 2048];
5890 assert!(stream.read(&mut request).await.unwrap() > 0);
5891 stream
5892 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
5893 .await
5894 .unwrap();
5895 });
5896 assert_eq!(
5901 probe_http_health(&url, Duration::from_secs(10))
5902 .await
5903 .unwrap()
5904 .status,
5905 HealthStatus::Ok
5906 );
5907 server.await.unwrap();
5908 }
5909
5910 #[cfg(unix)]
5911 #[tokio::test]
5912 async fn http_health_timeout_keeps_partial_status_and_body() {
5913 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5914 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5915 let url = format!("http://{}/healthz", listener.local_addr().unwrap());
5916 let server = tokio::spawn(async move {
5917 let (mut stream, _) = listener.accept().await.unwrap();
5918 let mut request = [0u8; 2048];
5919 assert!(stream.read(&mut request).await.unwrap() > 0);
5920 stream
5921 .write_all(
5922 b"HTTP/1.1 503 Unavailable\r\nContent-Length: 100\r\n\r\npartial diagnostic",
5923 )
5924 .await
5925 .unwrap();
5926 std::future::pending::<()>().await;
5927 });
5928 let error = probe_http_health(&url, Duration::from_secs(1))
5929 .await
5930 .unwrap_err()
5931 .to_string();
5932 server.abort();
5933 assert!(
5934 error.contains("timed out")
5935 && error.contains("503 Unavailable")
5936 && error.contains("partial diagnostic"),
5937 "{error}"
5938 );
5939 }
5940
5941 #[cfg(unix)]
5942 #[tokio::test]
5943 async fn http_health_diagnostic_body_is_bounded_to_200_bytes() {
5944 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5945 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5946 let url = format!("http://{}/healthz", listener.local_addr().unwrap());
5947 let server = tokio::spawn(async move {
5948 let (mut stream, _) = listener.accept().await.unwrap();
5949 let mut request = [0u8; 2048];
5950 assert!(stream.read(&mut request).await.unwrap() > 0);
5951 let body = format!("{}not-in-diagnostic", "x".repeat(400));
5952 let response = format!(
5953 "HTTP/1.1 503 Unavailable\r\nContent-Length: {}\r\n\r\n{body}",
5954 body.len()
5955 );
5956 stream.write_all(response.as_bytes()).await.unwrap();
5957 });
5958 let error = probe_http_health(&url, Duration::from_secs(1))
5959 .await
5960 .unwrap_err()
5961 .to_string();
5962 server.await.unwrap();
5963 assert!(error.ends_with(&"x".repeat(200)), "{error}");
5964 assert_eq!(error.split(": ").last().unwrap().len(), 200);
5965 assert!(!error.contains("not-in-diagnostic"));
5966 }
5967
5968 #[cfg(unix)]
5969 #[tokio::test]
5970 async fn http_health_real_nats_server_monitoring() {
5971 if std::process::Command::new("nats-server")
5972 .arg("--version")
5973 .env("XDG_DATA_HOME", std::env::temp_dir())
5974 .env("XDG_RUNTIME_DIR", std::env::temp_dir())
5975 .env("XDG_CONFIG_HOME", std::env::temp_dir())
5976 .output()
5977 .is_err()
5978 {
5979 eprintln!(
5980 "skipped: http_health_real_nats_server_monitoring (nats-server is not on PATH)"
5981 );
5982 return;
5983 }
5984 let dir = cortexkit_test_support::ScratchDir::new("nats-http-monitoring");
5985 let monitor = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5986 let port = monitor.local_addr().unwrap().port();
5987 let client = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5988 let client_port = client.local_addr().unwrap().port();
5989 let config = dir.join("server.conf");
5990 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();
5991 let mut configured = http_fixture(&dir, &format!("http://127.0.0.1:{port}/healthz"), 1000);
5992 configured.program = PathBuf::from("nats-server");
5993 configured.args = vec!["-c".into(), config.to_string_lossy().into_owned()];
5994 drop(monitor);
5995 drop(client);
5996 let module =
5997 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5998 .supervise_configured_with_health(
5999 configured.module_spec(),
6000 true,
6001 configured.health,
6002 configured.drain_timeout_ms,
6003 configured.restart,
6004 )
6005 .unwrap();
6006 wait_http_health(&module, SupervisorHealthStatus::Ok).await;
6007 module.drain().await.unwrap();
6008 }
6009
6010 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
6011 async fn configuration_update_does_not_replace_captured_running_process_facts() {
6012 let supervisor =
6013 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
6014 let snapshot = stale_process_snapshot(ModuleState::Running, true);
6015 let initial = ModuleSpec {
6016 module_id: "rescan-preserves-spawn-facts".to_string(),
6017 program: PathBuf::from("/spawned/module"),
6018 args: Vec::new(),
6019 env: Vec::new(),
6020 reserved: false,
6021 reserved_prefixes: Vec::new(),
6022 protocol: ModuleProtocol::Subc,
6023 overlap: Default::default(),
6024 };
6025 let module = supervisor.supervised_module(
6026 initial.clone(),
6027 supervisor.runtime_config(),
6028 snapshot,
6029 None,
6030 );
6031 let before = module.status().unwrap();
6032 let mut replacement = initial;
6033 replacement.program = PathBuf::from("/rescanned/replacement-module");
6034
6035 module
6036 .update_configuration(replacement, HealthConfig::default(), None)
6037 .await
6038 .unwrap();
6039
6040 let after = module.status().unwrap();
6041 assert_eq!(after.pid, before.pid);
6042 assert_eq!(after.spawned_at_ms, before.spawned_at_ms);
6043 assert_eq!(after.spawned_from, before.spawned_from);
6044 drop(module);
6045 }
6046}
6047
6048fn unix_ms_now() -> u64 {
6049 SystemTime::now()
6050 .duration_since(UNIX_EPOCH)
6051 .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64)
6052 .unwrap_or(0)
6053}
6054
6055async fn supervise_loop(
6056 mut spec: ModuleSpec,
6057 mut runtime: SupervisorRuntimeConfig,
6058 registry: Arc<Registry>,
6059 process_liveness: Arc<SupervisorProcessLiveness>,
6060 snapshot: SharedSnapshot,
6061 mut child: Option<SupervisedChild>,
6062 mut commands: mpsc::Receiver<SupervisorCommand>,
6063) {
6064 let mut health_probe = HealthProbeRuntime::default();
6065 let mut registration_changes = match registry.subscribe_module_changes(&spec.module_id) {
6069 Ok(changes) => Some(changes),
6070 Err(err) => {
6071 warn!(module_id = %spec.module_id, error = %err, "health prober could not subscribe to registry");
6074 None
6075 }
6076 };
6077 let mut pending_respawn: Option<PendingRespawn> = None;
6081 let mut requeued: VecDeque<SupervisorCommand> = VecDeque::new();
6084 #[cfg(target_os = "linux")]
6085 let mut previous_instance_recheck = tokio::time::interval_at(
6086 Instant::now() + Duration::from_secs(5),
6087 Duration::from_secs(5),
6088 );
6089 #[cfg(target_os = "linux")]
6090 previous_instance_recheck.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
6091 loop {
6092 #[cfg(test)]
6093 {
6094 lock_snapshot(&snapshot).unwrap().actor_select = None;
6095 }
6096 #[cfg(target_os = "macos")]
6097 if let Some(active) = child.as_mut() {
6098 active.confirm_privacy_exec().await;
6099 }
6100 if let Some(scheduled) = runtime
6101 .scheduled_respawn
6102 .lock()
6103 .unwrap_or_else(|p| p.into_inner())
6104 .take()
6105 {
6106 pending_respawn = Some(scheduled);
6107 }
6108 if pending_respawn.is_some() && (child.is_some() || !respawn_still_pending(&snapshot)) {
6109 pending_respawn = None;
6110 cancel_deferred_reload(
6111 &runtime,
6112 &spec.module_id,
6113 "respawn cancelled by a supervisor command",
6114 );
6115 }
6116 if child.is_none()
6117 && pending_respawn.is_none()
6118 && lock_snapshot(&snapshot).is_ok_and(|state| state.waiting_for_previous_pid.is_none())
6119 {
6120 cancel_deferred_reload(
6121 &runtime,
6122 &spec.module_id,
6123 "respawn cancelled before a replacement was spawned",
6124 );
6125 let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| {
6126 state.respawn_pending = false;
6127 state.coalesced_restart_pending = false;
6128 if matches!(
6129 state.state,
6130 ModuleState::Restarting
6131 | ModuleState::Starting
6132 | ModuleState::Draining
6133 | ModuleState::Unresponsive
6134 ) {
6135 error!(module_id = %spec.module_id, state = ?state.state,
6136 "supervision operation ended without a child or pending respawn; marking failed so start can retry");
6137 state.state = ModuleState::Failed;
6138 clear_current_process_facts(state);
6139 }
6140 });
6141 }
6142 if let Some(command) = requeued.pop_front() {
6143 if !handle_supervisor_command(
6144 command,
6145 &mut spec,
6146 &mut runtime,
6147 ®istry,
6148 &process_liveness,
6149 &snapshot,
6150 &mut child,
6151 &mut commands,
6152 &mut requeued,
6153 )
6154 .await
6155 {
6156 return;
6157 }
6158 if child.is_some() || !respawn_still_pending(&snapshot) {
6159 pending_respawn = None;
6160 let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| {
6161 state.respawn_pending = false
6162 });
6163 }
6164 continue;
6165 }
6166 if child.is_some() {
6167 #[cfg(test)]
6168 {
6169 lock_snapshot(&snapshot).unwrap().actor_turns += 1;
6170 }
6171 health_probe.refresh_registration(&spec, &runtime, ®istry, &snapshot);
6172 let wake_after = health_probe.wake_after();
6173 let probe_sleep = async move {
6174 match wake_after {
6175 Some(delay) => sleep(delay).await,
6176 None => std::future::pending().await,
6177 }
6178 };
6179 tokio::pin!(probe_sleep);
6180 let wire_child = running_protocol(&spec, &snapshot) == ModuleProtocol::Subc;
6185 #[cfg(test)]
6186 {
6187 let registration_pending = wire_child
6188 && registration_changes
6189 .as_ref()
6190 .is_some_and(|changes| changes.has_changed().unwrap());
6191 let mut state = lock_snapshot(&snapshot).unwrap();
6192 state.actor_select = (!registration_pending).then_some(ActorSelectCheckpoint {
6193 generation: state.spawn_generation,
6194 turn: state.actor_turns,
6195 registered_connection: health_probe.registered_connection,
6196 next_probe_at: health_probe.next_probe_at,
6197 wake_after,
6198 });
6199 }
6200 let active_child = child.as_mut().expect("child checked above");
6201 tokio::select! {
6202 wait_result = active_child.wait() => {
6203 #[cfg(test)]
6204 { lock_snapshot(&snapshot).unwrap().actor_select = None; }
6205 let exit_report = match wait_result {
6214 Ok(status) => classify_reaped_child_exit(&snapshot, active_child, &status),
6215 Err(err) => {
6216 active_child.drain_stderr(&spec.module_id).await;
6217 fail_after_wait_error(
6218 &spec.module_id,
6219 &snapshot,
6220 &runtime.terminal_ring,
6221 &runtime.spawn_events,
6222 format!("failed to wait for supervised module: {err}"),
6223 );
6224 untrack_if_registration_released(
6225 &process_liveness,
6226 ®istry,
6227 &spec.module_id,
6228 &snapshot,
6229 );
6230 error!(module_id = %spec.module_id, error = %err, "failed to wait for supervised module");
6231 child = None;
6232 continue;
6233 }
6234 };
6235 active_child.drain_stderr(&spec.module_id).await;
6236
6237 let next = on_child_exit(
6238 &spec,
6239 runtime.restart_policy,
6240 ®istry,
6241 &snapshot,
6242 &runtime.terminal_ring,
6243 &runtime.spawn_events,
6244 &runtime.child_roster,
6245 exit_report,
6246 ).await;
6247 active_child.release_roster();
6250 match next {
6251 NextAction::Stop { registration_released } => {
6252 if registration_released {
6253 process_liveness.untrack_if_current(&spec.module_id, &snapshot);
6254 }
6255 child = None;
6256 }
6257 NextAction::Restart { schedule } => {
6258 let delay = schedule.map_or(
6259 runtime.restart_policy.delay_for_restart(0),
6260 |schedule| schedule.delay,
6261 );
6262 if let Some(schedule) = schedule {
6263 log_crash_respawn(&spec.module_id, schedule);
6264 }
6265 child = None;
6273 pending_respawn = Some(PendingRespawn { deadline: Instant::now() + delay, kind: RespawnKind::Spawn });
6274 let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| state.respawn_pending = true);
6275 }
6276 }
6277 }
6278 command = commands.recv() => {
6279 #[cfg(test)]
6280 { lock_snapshot(&snapshot).unwrap().actor_select = None; }
6281 let Some(command) = command else {
6282 return;
6283 };
6284 if !handle_supervisor_command(
6285 command,
6286 &mut spec,
6287 &mut runtime,
6288 ®istry,
6289 &process_liveness,
6290 &snapshot,
6291 &mut child,
6292 &mut commands,
6293 &mut requeued,
6294 ).await {
6295 return;
6296 }
6297 }
6298 _ = async {
6299 match registration_changes.as_mut() {
6300 Some(changes) => { let _ = changes.changed().await; }
6301 None => std::future::pending().await,
6302 }
6303 }, if wire_child => {
6304 #[cfg(test)]
6305 { lock_snapshot(&snapshot).unwrap().actor_select = None; }
6306 }
6307 _ = &mut probe_sleep => {
6308 #[cfg(test)]
6309 { lock_snapshot(&snapshot).unwrap().actor_select = None; }
6310 if health_probe.due() {
6311 run_health_probe_cycle(
6312 &spec,
6313 &runtime,
6314 ®istry,
6315 &process_liveness,
6316 &snapshot,
6317 &mut child,
6318 ).await;
6319 if child.is_some() {
6320 health_probe.schedule_next(&spec, runtime.health.cadence);
6321 }
6322 }
6323 }
6324 }
6325 } else if let Some(pending) = pending_respawn {
6326 tokio::select! {
6327 _ = sleep_until(pending.deadline) => {
6328 pending_respawn = None;
6329 let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| state.respawn_pending = false);
6330 if !respawn_still_pending(&snapshot) {
6334 continue;
6335 }
6336 if runtime.child_roster.is_closed() {
6341 let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| {
6342 state.state = ModuleState::Stopped;
6343 });
6344 debug!(module_id = %spec.module_id, "crash respawn cancelled by daemon shutdown");
6345 continue;
6346 }
6347 if let Err(err) = release_dead_registration(
6348 ®istry,
6349 runtime.forwarding.as_deref(),
6350 &snapshot,
6351 &spec.module_id,
6352 ).await {
6353 fail_snapshot(&snapshot, Some(&spec.module_id), None);
6354 error!(module_id = %spec.module_id, error = %err, "registration did not release before restart");
6355 continue;
6356 }
6357
6358 if matches!(pending.kind, RespawnKind::Reload) {
6359 let reply = runtime.deferred_reload_reply.lock().unwrap_or_else(|p| p.into_inner()).take();
6360 let result = finish_reload_child(&spec, &runtime, ®istry, &process_liveness, &snapshot, &mut child).await;
6361 if let Some(reply) = reply { let _ = reply.send(result); }
6362 continue;
6363 }
6364 process_liveness.track(spec.module_id.clone(), Arc::clone(&snapshot));
6365 match spawn_and_mark_running(&spec, &runtime, &snapshot) {
6366 Ok(next_child) => {
6367 child = Some(next_child);
6368 debug!(module_id = %spec.module_id, "supervised module restarted after crash");
6369 }
6370 Err(err) => {
6371 fail_snapshot(&snapshot, Some(&spec.module_id), None);
6372 process_liveness.untrack_if_current(&spec.module_id, &snapshot);
6373 error!(module_id = %spec.module_id, error = %err, "failed to restart supervised module");
6374 }
6375 }
6376 }
6377 command = commands.recv() => {
6378 let Some(command) = command else {
6379 return;
6380 };
6381 if !handle_supervisor_command(
6382 command,
6383 &mut spec,
6384 &mut runtime,
6385 ®istry,
6386 &process_liveness,
6387 &snapshot,
6388 &mut child,
6389 &mut commands,
6390 &mut requeued,
6391 ).await {
6392 return;
6393 }
6394 if child.is_some() || !respawn_still_pending(&snapshot) {
6399 pending_respawn = None;
6400 let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| state.respawn_pending = false);
6401 }
6402 }
6403 }
6404 } else {
6405 #[cfg(target_os = "linux")]
6406 let command = if lock_snapshot(&snapshot)
6407 .is_ok_and(|state| state.waiting_for_previous_pid.is_some())
6408 {
6409 tokio::select! {
6410 command = commands.recv() => command,
6411 _ = previous_instance_recheck.tick() => {
6412 if runtime.child_roster.previous_instance_pid(&spec.module_id).is_none() {
6413 let enabled = lock_snapshot(&snapshot).is_ok_and(|state| state.enabled);
6414 if enabled && !runtime.child_roster.is_closed() {
6415 process_liveness.track(spec.module_id.clone(), Arc::clone(&snapshot));
6416 match spawn_and_mark_running(&spec, &runtime, &snapshot) {
6417 Ok(next_child) => child = Some(next_child),
6418 Err(error) => {
6419 fail_snapshot(&snapshot, Some(&spec.module_id), None);
6420 error!(module_id = %spec.module_id, %error, "failed to start module after its previous instance exited");
6421 }
6422 }
6423 } else {
6424 let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| {
6425 state.waiting_for_previous_pid = None;
6426 });
6427 }
6428 }
6429 continue;
6430 }
6431 }
6432 } else {
6433 commands.recv().await
6434 };
6435 #[cfg(not(target_os = "linux"))]
6436 let command = commands.recv().await;
6437 let Some(command) = command else {
6438 return;
6439 };
6440 if !handle_supervisor_command(
6441 command,
6442 &mut spec,
6443 &mut runtime,
6444 ®istry,
6445 &process_liveness,
6446 &snapshot,
6447 &mut child,
6448 &mut commands,
6449 &mut requeued,
6450 )
6451 .await
6452 {
6453 return;
6454 }
6455 }
6456 }
6457}
6458
6459fn log_crash_respawn(module_id: &str, schedule: CrashRestartSchedule) {
6460 info!(
6461 module_id,
6462 restart_in_window = schedule.restart_in_window,
6463 delay_ms = schedule.delay.as_millis() as u64,
6464 "respawning after crash"
6465 );
6466}
6467
6468fn respawn_still_pending(snapshot: &SharedSnapshot) -> bool {
6474 matches!(
6475 lock_snapshot(snapshot),
6476 Ok(state) if state.enabled && state.state == ModuleState::Restarting
6477 )
6478}
6479
6480enum NextAction {
6481 Stop {
6482 registration_released: bool,
6483 },
6484 Restart {
6485 schedule: Option<CrashRestartSchedule>,
6486 },
6487}
6488
6489#[allow(clippy::too_many_arguments)]
6490async fn handle_supervisor_command(
6491 command: SupervisorCommand,
6492 spec: &mut ModuleSpec,
6493 runtime: &mut SupervisorRuntimeConfig,
6494 registry: &Arc<Registry>,
6495 process_liveness: &SupervisorProcessLiveness,
6496 snapshot: &SharedSnapshot,
6497 child: &mut Option<SupervisedChild>,
6498 commands: &mut mpsc::Receiver<SupervisorCommand>,
6499 requeued: &mut VecDeque<SupervisorCommand>,
6500) -> bool {
6501 #[cfg(target_os = "linux")]
6502 let command = if let Some(pid) = runtime.child_roster.previous_instance_pid(&spec.module_id) {
6503 match command {
6504 SupervisorCommand::Restart { reply, .. }
6505 | SupervisorCommand::Reload { reply }
6506 | SupervisorCommand::Swap { reply, .. } => {
6507 let _ = reply.send(Err(previous_instance_error(spec, pid)));
6508 return true;
6509 }
6510 SupervisorCommand::SetEnabled {
6511 enabled: true,
6512 reply,
6513 } => {
6514 let _ = reply.send(Err(previous_instance_error(spec, pid)));
6515 return true;
6516 }
6517 other => other,
6518 }
6519 } else {
6520 command
6521 };
6522 match command {
6523 SupervisorCommand::Drain { reply } => {
6524 let result = drain_optional_child(
6527 &spec.module_id,
6528 spec.protocol,
6529 StopNotice::NotSent,
6530 registry,
6531 runtime.forwarding.as_deref(),
6532 snapshot,
6533 &runtime.terminal_ring,
6534 &runtime.spawn_events,
6535 child,
6536 runtime.drain_timeout,
6537 ModuleState::Stopped,
6538 None,
6539 )
6540 .await;
6541 let registration_released = result.is_ok();
6542 let _ = reply.send(result);
6543 if registration_released {
6544 process_liveness.untrack_if_current(&spec.module_id, snapshot);
6545 }
6546 false
6547 }
6548 SupervisorCommand::Retire { reply } => {
6549 let result = async {
6550 let stop_notice = begin_forwarding_drain_if_configured(
6551 spec,
6552 runtime,
6553 registry,
6554 snapshot,
6555 None,
6556 RouteCloseReason::Disable,
6557 )
6558 .await?;
6559 drain_optional_child(
6560 &spec.module_id,
6561 spec.protocol,
6562 stop_notice,
6563 registry,
6564 runtime.forwarding.as_deref(),
6565 snapshot,
6566 &runtime.terminal_ring,
6567 &runtime.spawn_events,
6568 child,
6569 runtime.drain_timeout,
6570 ModuleState::Stopped,
6571 None,
6572 )
6573 .await
6574 }
6575 .await;
6576 let registration_released = result.is_ok();
6577 let _ = reply.send(result);
6578 if registration_released {
6579 process_liveness.untrack_if_current(&spec.module_id, snapshot);
6580 }
6581 false
6582 }
6583 SupervisorCommand::Restart {
6584 drain_timeout_ms,
6585 received_at_generation,
6586 queued_at,
6587 reply,
6588 } => {
6589 info!(
6593 module_id = %spec.module_id,
6594 queued_ms = u64::try_from(queued_at.elapsed().as_millis()).unwrap_or(u64::MAX),
6595 "restart command dequeued"
6596 );
6597 let validation = match lock_snapshot(snapshot) {
6609 Ok(state) if !state.enabled => Err(SuperviseError::Disabled {
6610 module_id: spec.module_id.clone(),
6611 }),
6612 Ok(_) => Ok(()),
6613 Err(err) => Err(err),
6614 };
6615 let initiated = validation.is_ok();
6616 let _ = reply.send(validation);
6617 let satisfied_by_generation = if initiated && child.is_some() {
6628 lock_snapshot(snapshot).ok().and_then(|state| {
6629 (state.spawn_generation > received_at_generation
6630 && !state.configuration_updated_since_spawn)
6631 .then_some(state.spawn_generation)
6632 })
6633 } else {
6634 None
6635 };
6636 let satisfied_by_pending = initiated
6637 && child.is_none()
6638 && lock_snapshot(snapshot).ok().is_some_and(|mut state| {
6639 let pending = state.respawn_pending && !state.configuration_updated_since_spawn;
6640 if pending {
6641 state.coalesced_restart_pending = true;
6642 }
6643 pending
6644 });
6645 if satisfied_by_pending {
6646 debug!(module_id = %spec.module_id, "restart coalesced into the pending replacement");
6647 } else if let Some(generation) = satisfied_by_generation {
6648 info!(
6649 module_id = %spec.module_id,
6650 received_at_generation,
6651 "restart already satisfied by generation {generation}; not restarting again"
6652 );
6653 } else if initiated {
6654 let drain_timeout = drain_timeout_ms
6657 .map(Duration::from_millis)
6658 .unwrap_or(runtime.drain_timeout);
6659 if let Err(err) = restart_child(
6660 spec,
6661 runtime,
6662 registry,
6663 process_liveness,
6664 snapshot,
6665 child,
6666 drain_timeout,
6667 )
6668 .await
6669 {
6670 warn!(
6671 module_id = %spec.module_id,
6672 error = %err,
6673 "operator restart failed after initiation ack; module state carries the outcome"
6674 );
6675 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
6676 state.state = ModuleState::Failed;
6677 clear_current_process_facts(state);
6678 });
6679 }
6680 }
6681 true
6682 }
6683 SupervisorCommand::Reload { reply } => {
6684 let result =
6685 reload_child(spec, runtime, registry, process_liveness, snapshot, child).await;
6686 if result.is_ok()
6687 && runtime
6688 .scheduled_respawn
6689 .lock()
6690 .unwrap_or_else(|p| p.into_inner())
6691 .as_ref()
6692 .is_some_and(|pending| matches!(pending.kind, RespawnKind::Reload))
6693 {
6694 *runtime
6695 .deferred_reload_reply
6696 .lock()
6697 .unwrap_or_else(|p| p.into_inner()) = Some(reply);
6698 } else {
6699 let _ = reply.send(result);
6700 }
6701 true
6702 }
6703 SupervisorCommand::SetEnabled { enabled, reply } => {
6704 let result = set_child_enabled(
6705 spec,
6706 runtime,
6707 registry,
6708 process_liveness,
6709 snapshot,
6710 child,
6711 enabled,
6712 )
6713 .await;
6714 let _ = reply.send(result);
6715 true
6716 }
6717 SupervisorCommand::UpdateConfiguration {
6718 spec: next_spec,
6719 health,
6720 drain_timeout_ms,
6721 reply,
6722 } => {
6723 if let Some(handle) = &runtime.supervisor_handle {
6724 handle.apply_identity_configuration(&next_spec);
6725 }
6726 *spec = next_spec;
6727 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
6728 state.configuration_updated_since_spawn = true;
6729 });
6730 let health_changed = runtime.health != health;
6731 runtime.health = health;
6732 if health_changed {
6735 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
6736 state.health = ModuleHealthStatus::default();
6737 });
6738 }
6739 runtime.drain_timeout = drain_timeout_ms
6740 .map(Duration::from_millis)
6741 .unwrap_or(runtime.default_drain_timeout);
6742 *runtime
6743 .effective_drain_timeout
6744 .lock()
6745 .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.drain_timeout;
6746 let _ = reply.send(());
6747 true
6748 }
6749 SupervisorCommand::Swap {
6750 ready_timeout,
6751 reply,
6752 } => {
6753 let end = swap::run_swap(
6754 spec,
6755 runtime,
6756 registry,
6757 process_liveness,
6758 snapshot,
6759 child,
6760 commands,
6761 ready_timeout.unwrap_or(DEFAULT_SWAP_READY_TIMEOUT),
6762 reply,
6763 )
6764 .await;
6765 requeued.extend(end.requeue);
6766 true
6767 }
6768 }
6769}
6770
6771async fn restart_child(
6772 spec: &ModuleSpec,
6773 runtime: &SupervisorRuntimeConfig,
6774 registry: &Registry,
6775 process_liveness: &SupervisorProcessLiveness,
6776 snapshot: &SharedSnapshot,
6777 child: &mut Option<SupervisedChild>,
6778 drain_timeout: Duration,
6779) -> Result<(), SuperviseError> {
6780 if !lock_snapshot(snapshot)?.enabled {
6782 return Err(SuperviseError::Disabled {
6783 module_id: spec.module_id.clone(),
6784 });
6785 }
6786 let stop_notice = begin_forwarding_drain_with_timeout(
6787 spec,
6788 runtime,
6789 registry,
6790 snapshot,
6791 None,
6792 RouteCloseReason::Restart,
6793 drain_timeout,
6794 )
6795 .await?;
6796
6797 if child.is_some() {
6798 drain_optional_child(
6799 &spec.module_id,
6800 spec.protocol,
6801 stop_notice,
6802 registry,
6803 runtime.forwarding.as_deref(),
6804 snapshot,
6805 &runtime.terminal_ring,
6806 &runtime.spawn_events,
6807 child,
6808 drain_timeout,
6809 ModuleState::Restarting,
6810 Some(true),
6811 )
6812 .await?;
6813 } else {
6814 update_snapshot(snapshot, Some(&spec.module_id), |state| {
6815 state.enabled = true;
6816 state.state = ModuleState::Restarting;
6817 clear_current_process_facts(state);
6818 })?;
6819 release_dead_registration(
6820 registry,
6821 runtime.forwarding.as_deref(),
6822 snapshot,
6823 &spec.module_id,
6824 )
6825 .await?;
6826 }
6827
6828 reset_restart_count(snapshot, &spec.module_id)?;
6829 process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
6830 schedule_respawn(
6831 runtime,
6832 snapshot,
6833 &spec.module_id,
6834 runtime.restart_policy.backoff,
6835 RespawnKind::Spawn,
6836 )
6837}
6838
6839async fn reload_child(
6840 spec: &ModuleSpec,
6841 runtime: &SupervisorRuntimeConfig,
6842 registry: &Registry,
6843 process_liveness: &SupervisorProcessLiveness,
6844 snapshot: &SharedSnapshot,
6845 child: &mut Option<SupervisedChild>,
6846) -> Result<(), SuperviseError> {
6847 if !lock_snapshot(snapshot)?.enabled {
6849 return Err(SuperviseError::Disabled {
6850 module_id: spec.module_id.clone(),
6851 });
6852 }
6853 let stop_notice = begin_forwarding_drain(
6854 spec,
6855 runtime,
6856 registry,
6857 snapshot,
6858 Some(true),
6859 RouteCloseReason::Reload,
6860 )
6861 .await?;
6862
6863 if child.is_some() {
6864 drain_optional_child(
6865 &spec.module_id,
6866 spec.protocol,
6867 stop_notice,
6868 registry,
6869 runtime.forwarding.as_deref(),
6870 snapshot,
6871 &runtime.terminal_ring,
6872 &runtime.spawn_events,
6873 child,
6874 runtime.drain_timeout,
6875 ModuleState::Restarting,
6876 Some(true),
6877 )
6878 .await?;
6879 } else {
6880 update_snapshot(snapshot, Some(&spec.module_id), |state| {
6881 state.enabled = true;
6882 state.state = ModuleState::Restarting;
6883 clear_current_process_facts(state);
6884 })?;
6885 release_dead_registration(
6886 registry,
6887 runtime.forwarding.as_deref(),
6888 snapshot,
6889 &spec.module_id,
6890 )
6891 .await?;
6892 }
6893
6894 reset_restart_count(snapshot, &spec.module_id)?;
6895 process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
6896 schedule_respawn(
6897 runtime,
6898 snapshot,
6899 &spec.module_id,
6900 runtime.restart_policy.backoff,
6901 RespawnKind::Reload,
6902 )
6903}
6904
6905async fn finish_reload_child(
6906 spec: &ModuleSpec,
6907 runtime: &SupervisorRuntimeConfig,
6908 registry: &Registry,
6909 process_liveness: &SupervisorProcessLiveness,
6910 snapshot: &SharedSnapshot,
6911 child: &mut Option<SupervisedChild>,
6912) -> Result<(), SuperviseError> {
6913 process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
6914 let next_child = match spawn_and_mark_running(spec, runtime, snapshot) {
6915 Ok(next_child) => next_child,
6916 Err(err) => {
6917 return handle_reload_spawn_failure(
6918 spec,
6919 runtime,
6920 process_liveness,
6921 snapshot,
6922 child,
6923 format!("new child failed to spawn: {err}"),
6924 )
6925 .await;
6926 }
6927 };
6928 *child = Some(next_child);
6929
6930 let wait_outcome = {
6931 let active_child = child.as_mut().expect("new reload child was just stored");
6932 wait_for_registration_after_reload(
6933 registry,
6934 &spec.module_id,
6935 snapshot,
6936 active_child,
6937 REGISTRY_RELEASE_TIMEOUT,
6938 )
6939 .await?
6940 };
6941
6942 match wait_outcome {
6943 RegistrationWaitOutcome::Registered => {
6944 debug!(module_id = %spec.module_id, "supervised module reloaded and registered");
6945 Ok(())
6946 }
6947 RegistrationWaitOutcome::Exited(exit_report) => {
6948 if let Some(active_child) = child.as_mut() {
6949 active_child.drain_stderr(&spec.module_id).await;
6950 }
6951 let mut exited_child = child.take().expect("exited reload child is still stored");
6954 #[cfg(test)]
6955 if let Some(gate) = &runtime.test_reload_exit_record_gate {
6956 gate.reached.notify_one();
6957 gate.resume.notified().await;
6958 }
6959 let result = handle_reload_child_registration_failure(
6960 spec,
6961 runtime,
6962 registry,
6963 process_liveness,
6964 snapshot,
6965 child,
6966 ReloadRegistrationFailure {
6967 exit_report: registration_failure_exit_report(exit_report),
6968 reason: exited_child
6969 .spawn_failure
6970 .clone()
6971 .unwrap_or_else(|| "new child exited before registering".to_string()),
6972 },
6973 )
6974 .await;
6975 exited_child.release_roster();
6976 result
6977 }
6978 RegistrationWaitOutcome::TimedOut => {
6979 let mut timed_out_child = child
6980 .take()
6981 .expect("timed-out reload child is still running");
6982 timed_out_child
6983 .start_kill()
6984 .map_err(|source| SuperviseError::Kill {
6985 module_id: spec.module_id.clone(),
6986 source,
6987 })?;
6988 let status = timed_out_child
6989 .wait()
6990 .await
6991 .map_err(|source| SuperviseError::Wait {
6992 module_id: spec.module_id.clone(),
6993 source,
6994 })?;
6995 timed_out_child.drain_stderr(&spec.module_id).await;
6996 handle_reload_child_registration_failure(
6997 spec,
6998 runtime,
6999 registry,
7000 process_liveness,
7001 snapshot,
7002 child,
7003 ReloadRegistrationFailure {
7004 exit_report: registration_failure_exit_report(classify_reaped_child_exit(
7005 snapshot,
7006 &timed_out_child,
7007 &status,
7008 )),
7009 reason: format!(
7010 "new child did not register within {:?}",
7011 REGISTRY_RELEASE_TIMEOUT
7012 ),
7013 },
7014 )
7015 .await
7016 }
7017 }
7018}
7019
7020async fn set_child_enabled(
7021 spec: &ModuleSpec,
7022 runtime: &SupervisorRuntimeConfig,
7023 registry: &Registry,
7024 process_liveness: &SupervisorProcessLiveness,
7025 snapshot: &SharedSnapshot,
7026 child: &mut Option<SupervisedChild>,
7027 enabled: bool,
7028) -> Result<bool, SuperviseError> {
7029 let (current_enabled, current_state, respawn_pending) = {
7030 let state = lock_snapshot(snapshot)?;
7031 (state.enabled, state.state, state.respawn_pending)
7032 };
7033 let revive_terminal = enabled
7041 && current_enabled
7042 && child.is_none()
7043 && (matches!(current_state, ModuleState::Failed | ModuleState::Stopped)
7044 || (current_state == ModuleState::Restarting && !respawn_pending));
7045 if current_enabled == enabled && !revive_terminal {
7046 return Ok(false);
7047 }
7048
7049 if enabled {
7050 update_snapshot(snapshot, Some(&spec.module_id), |state| {
7051 state.enabled = true;
7052 state.state = ModuleState::Starting;
7053 clear_current_process_facts(state);
7054 })?;
7055 #[cfg(test)]
7056 if runtime.test_seed_stale_facts_before_enable_spawn {
7057 update_snapshot(snapshot, Some(&spec.module_id), |state| {
7058 state.process_alive = true;
7059 state.pid = Some(41);
7060 state.spawned_at_ms = Some(42);
7061 state.spawned_from = Some(PathBuf::from("/spawned/module"));
7062 state.spawned_file_identity = Some(SpawnedFileIdentity {
7063 device: 43,
7064 inode: 44,
7065 #[cfg(windows)]
7066 windows_image: None,
7067 });
7068 })?;
7069 }
7070 release_dead_registration(
7071 registry,
7072 runtime.forwarding.as_deref(),
7073 snapshot,
7074 &spec.module_id,
7075 )
7076 .await?;
7077 reset_restart_count(snapshot, &spec.module_id)?;
7078 process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
7079 let next_child = match spawn_and_mark_running(spec, runtime, snapshot) {
7080 Ok(next_child) => next_child,
7081 Err(err) => {
7082 if let Err(state_err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
7083 state.state = ModuleState::Failed;
7084 clear_current_process_facts(state);
7085 }) {
7086 error!(module_id = %spec.module_id, error = %state_err, "failed to record enable spawn failure");
7087 }
7088 process_liveness.untrack_if_current(&spec.module_id, snapshot);
7089 return Err(err);
7090 }
7091 };
7092 *child = Some(next_child);
7093 debug!(module_id = %spec.module_id, "supervised module enabled");
7094 Ok(true)
7095 } else {
7096 let stop_notice = begin_forwarding_drain_if_configured(
7097 spec,
7098 runtime,
7099 registry,
7100 snapshot,
7101 Some(false),
7102 RouteCloseReason::Disable,
7103 )
7104 .await?;
7105 drain_optional_child(
7106 &spec.module_id,
7107 spec.protocol,
7108 stop_notice,
7109 registry,
7110 runtime.forwarding.as_deref(),
7111 snapshot,
7112 &runtime.terminal_ring,
7113 &runtime.spawn_events,
7114 child,
7115 runtime.drain_timeout,
7116 ModuleState::Disabled,
7117 Some(false),
7118 )
7119 .await?;
7120 debug!(module_id = %spec.module_id, "supervised module disabled");
7121 Ok(true)
7122 }
7123}
7124
7125#[allow(clippy::too_many_arguments)]
7126async fn on_child_exit(
7127 spec: &ModuleSpec,
7128 policy: RestartPolicy,
7129 registry: &Registry,
7130 snapshot: &SharedSnapshot,
7131 terminal_ring: &Arc<Mutex<TerminalRing>>,
7132 spawn_events: &SpawnEventFeed,
7133 roster: &ChildRoster,
7134 exit_report: ExitReport,
7135) -> NextAction {
7136 if roster.is_closed() {
7142 return on_child_exit_during_daemon_shutdown(
7143 spec,
7144 registry,
7145 snapshot,
7146 terminal_ring,
7147 spawn_events,
7148 exit_report,
7149 )
7150 .await;
7151 }
7152 let unrequested_clean_exit_of_protocol_none = exit_report.kind == ExitKind::Clean
7167 && running_protocol(spec, snapshot) == ModuleProtocol::None;
7168 match exit_report.kind {
7169 ExitKind::Clean if !unrequested_clean_exit_of_protocol_none => {
7170 info!(
7171 module_id = %spec.module_id,
7172 exit_code = ?exit_report.code,
7173 exit_signal = ?exit_report.signal,
7174 "supervised module exited cleanly"
7175 );
7176 if let Err(err) = publish_after_terminal_record(
7177 &spec.module_id,
7178 || {
7179 record_terminal(
7180 &spec.module_id,
7181 terminal_ring,
7182 spawn_events,
7183 &exit_report,
7184 TerminalDisposition::Stopped,
7185 )
7186 },
7187 || {
7188 update_snapshot(snapshot, Some(&spec.module_id), |state| {
7189 state.state = ModuleState::Stopped;
7190 clear_current_process_facts(state);
7191 state.last_exit = Some(exit_report.clone());
7192 })
7193 },
7194 ) {
7195 error!(module_id = %spec.module_id, error = %err, "failed to publish clean module exit");
7196 }
7197 let registration_released = match wait_for_registration_release(
7198 registry,
7199 &spec.module_id,
7200 REGISTRY_RELEASE_TIMEOUT,
7201 )
7202 .await
7203 {
7204 Ok(()) => true,
7205 Err(err) => {
7206 warn!(module_id = %spec.module_id, error = %err, "registration still active after clean exit");
7207 false
7208 }
7209 };
7210 NextAction::Stop {
7211 registration_released,
7212 }
7213 }
7214 ExitKind::Clean | ExitKind::Crash => {
7215 if unrequested_clean_exit_of_protocol_none {
7216 warn!(
7217 module_id = %spec.module_id,
7218 exit_code = ?exit_report.code,
7219 exit_signal = ?exit_report.signal,
7220 "protocol-none module exited cleanly without a stop request; handling it as a crash"
7221 );
7222 } else {
7223 warn!(
7224 module_id = %spec.module_id,
7225 exit_code = ?exit_report.code,
7226 exit_signal = ?exit_report.signal,
7227 "supervised module exited abnormally (crash)"
7228 );
7229 }
7230 let mut restart_schedule = None;
7231 let mut disposition = TerminalDisposition::Disabled;
7232 let mut final_state = ModuleState::Disabled;
7233 let mut disposition_detail = lock_snapshot(snapshot)
7237 .ok()
7238 .and_then(|mut state| state.spawn_failure.take());
7239 let now = Instant::now();
7240 if let Err(err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
7241 if state.enabled {
7242 if let Some(schedule) = state.next_crash_restart(&policy, now) {
7243 final_state = ModuleState::Restarting;
7244 restart_schedule = Some(schedule);
7245 disposition = TerminalDisposition::Restarting;
7246 } else {
7247 final_state = ModuleState::Failed;
7248 disposition = TerminalDisposition::Failed;
7249 let budget = policy.budget_exhausted_detail();
7250 disposition_detail =
7251 Some(disposition_detail.take().map_or_else(
7252 || budget.clone(),
7253 |cause| format!("{cause}; {budget}"),
7254 ));
7255 }
7256 } else {
7257 disposition = TerminalDisposition::Disabled;
7258 }
7259 }) {
7260 error!(module_id = %spec.module_id, error = %err, "failed to record crashed module exit");
7261 return NextAction::Stop {
7262 registration_released: false,
7263 };
7264 }
7265 if disposition == TerminalDisposition::Failed {
7266 error!(
7271 module_id = %spec.module_id,
7272 max_restarts = policy.max_restarts,
7273 window_secs = policy.window.as_secs(),
7274 "module stopped: {}",
7275 policy.budget_exhausted_detail()
7276 );
7277 }
7278 if let Err(err) = publish_after_terminal_record(
7279 &spec.module_id,
7280 || {
7281 record_terminal_with_detail(
7282 &spec.module_id,
7283 terminal_ring,
7284 spawn_events,
7285 &exit_report,
7286 disposition,
7287 disposition_detail,
7288 )
7289 },
7290 || {
7291 update_snapshot(snapshot, Some(&spec.module_id), |state| {
7292 state.state = final_state;
7293 clear_current_process_facts(state);
7294 state.last_exit = Some(exit_report.clone());
7295 })
7296 },
7297 ) {
7298 error!(module_id = %spec.module_id, error = %err, "failed to publish crashed module exit");
7299 }
7300
7301 if let Some(schedule) = restart_schedule {
7302 NextAction::Restart {
7303 schedule: Some(schedule),
7304 }
7305 } else {
7306 let registration_released = match wait_for_registration_release(
7307 registry,
7308 &spec.module_id,
7309 REGISTRY_RELEASE_TIMEOUT,
7310 )
7311 .await
7312 {
7313 Ok(()) => true,
7314 Err(err) => {
7315 warn!(module_id = %spec.module_id, error = %err, "registration still active after failed module");
7316 false
7317 }
7318 };
7319 NextAction::Stop {
7320 registration_released,
7321 }
7322 }
7323 }
7324 ExitKind::DeliberateSeverance => {
7325 warn!(
7326 module_id = %spec.module_id,
7327 exit_code = ?exit_report.code,
7328 exit_signal = ?exit_report.signal,
7329 "supervised module exited after deliberate connection severance"
7330 );
7331 let should_restart = match lock_snapshot(snapshot) {
7332 Ok(state) => state.enabled,
7333 Err(err) => {
7334 error!(module_id = %spec.module_id, error = %err, "failed to read deliberately severed module state");
7335 return NextAction::Stop {
7336 registration_released: false,
7337 };
7338 }
7339 };
7340 let final_state = if should_restart {
7341 ModuleState::Restarting
7342 } else {
7343 ModuleState::Disabled
7344 };
7345 if let Err(err) = publish_after_terminal_record(
7346 &spec.module_id,
7347 || {
7348 record_terminal(
7349 &spec.module_id,
7350 terminal_ring,
7351 spawn_events,
7352 &exit_report,
7353 terminal_disposition(final_state),
7354 )
7355 },
7356 || {
7357 update_snapshot(snapshot, Some(&spec.module_id), |state| {
7358 state.state = final_state;
7359 clear_current_process_facts(state);
7360 state.last_exit = Some(exit_report.clone());
7361 state.lifetime_restarts += 1;
7362 })
7363 },
7364 ) {
7365 error!(module_id = %spec.module_id, error = %err, "failed to publish deliberately severed module exit");
7366 return NextAction::Stop {
7367 registration_released: false,
7368 };
7369 }
7370
7371 if should_restart {
7372 NextAction::Restart { schedule: None }
7373 } else {
7374 let registration_released = match wait_for_registration_release(
7375 registry,
7376 &spec.module_id,
7377 REGISTRY_RELEASE_TIMEOUT,
7378 )
7379 .await
7380 {
7381 Ok(()) => true,
7382 Err(err) => {
7383 warn!(module_id = %spec.module_id, error = %err, "registration still active after deliberately severed module exit");
7384 false
7385 }
7386 };
7387 NextAction::Stop {
7388 registration_released,
7389 }
7390 }
7391 }
7392 }
7393}
7394
7395async fn on_child_exit_during_daemon_shutdown(
7396 spec: &ModuleSpec,
7397 registry: &Registry,
7398 snapshot: &SharedSnapshot,
7399 terminal_ring: &Arc<Mutex<TerminalRing>>,
7400 spawn_events: &SpawnEventFeed,
7401 exit_report: ExitReport,
7402) -> NextAction {
7403 info!(
7404 module_id = %spec.module_id,
7405 exit_code = ?exit_report.code,
7406 exit_signal = ?exit_report.signal,
7407 exit_kind = ?exit_report.kind,
7408 "supervised module exited during daemon shutdown; not restarting it"
7409 );
7410 if let Err(err) = publish_after_terminal_record(
7411 &spec.module_id,
7412 || {
7413 record_terminal(
7414 &spec.module_id,
7415 terminal_ring,
7416 spawn_events,
7417 &exit_report,
7418 TerminalDisposition::DaemonShutdown,
7419 )
7420 },
7421 || {
7422 update_snapshot(snapshot, Some(&spec.module_id), |state| {
7423 state.state = ModuleState::Stopped;
7424 clear_current_process_facts(state);
7425 state.last_exit = Some(exit_report.clone());
7426 })
7427 },
7428 ) {
7429 error!(module_id = %spec.module_id, error = %err, "failed to publish module exit during daemon shutdown");
7430 }
7431 let registration_released =
7432 wait_for_registration_release(registry, &spec.module_id, REGISTRY_RELEASE_TIMEOUT)
7433 .await
7434 .is_ok();
7435 NextAction::Stop {
7436 registration_released,
7437 }
7438}
7439
7440fn publish_after_terminal_record<T>(
7445 module_id: &str,
7446 record: impl FnOnce(),
7447 publish: impl FnOnce() -> T,
7448) -> T {
7449 let recorded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(record));
7450 let published = publish();
7451 if let Err(panic) = recorded {
7452 error!(
7453 module_id,
7454 "terminal recording panicked; published final state before resuming unwind"
7455 );
7456 std::panic::resume_unwind(panic);
7457 }
7458 published
7459}
7460
7461fn fail_after_wait_error(
7462 module_id: &str,
7463 snapshot: &SharedSnapshot,
7464 terminal_ring: &Arc<Mutex<TerminalRing>>,
7465 spawn_events: &SpawnEventFeed,
7466 detail: String,
7467) {
7468 let report = wait_error_exit_report();
7469 publish_after_terminal_record(
7470 module_id,
7471 || {
7472 record_terminal_with_detail(
7473 module_id,
7474 terminal_ring,
7475 spawn_events,
7476 &report,
7477 TerminalDisposition::Failed,
7478 Some(detail),
7479 )
7480 },
7481 || fail_snapshot(snapshot, Some(module_id), Some(report.clone())),
7482 );
7483}
7484
7485#[cfg(test)]
7486fn record_wait_error_terminal(
7487 module_id: &str,
7488 terminal_ring: &Arc<Mutex<TerminalRing>>,
7489 spawn_events: &SpawnEventFeed,
7490) {
7491 record_terminal(
7492 module_id,
7493 terminal_ring,
7494 spawn_events,
7495 &wait_error_exit_report(),
7496 TerminalDisposition::Failed,
7497 );
7498}
7499
7500fn record_terminal(
7501 module_id: &str,
7502 terminal_ring: &Arc<Mutex<TerminalRing>>,
7503 spawn_events: &SpawnEventFeed,
7504 exit_report: &ExitReport,
7505 disposition: TerminalDisposition,
7506) {
7507 record_terminal_with_detail(
7508 module_id,
7509 terminal_ring,
7510 spawn_events,
7511 exit_report,
7512 disposition,
7513 None,
7514 );
7515}
7516
7517fn durable_terminal_history_of(
7521 terminal_ring: &Mutex<TerminalRing>,
7522 module_id: &str,
7523) -> subc_control::TerminalHistory {
7524 let read = terminal_ring
7525 .lock()
7526 .unwrap_or_else(|p| p.into_inner())
7527 .capture_durable_history();
7528 read.read(module_id)
7529}
7530
7531fn record_terminal_with_detail(
7532 module_id: &str,
7533 terminal_ring: &Arc<Mutex<TerminalRing>>,
7534 spawn_events: &SpawnEventFeed,
7535 exit_report: &ExitReport,
7536 disposition: TerminalDisposition,
7537 disposition_detail: Option<String>,
7538) {
7539 #[cfg(test)]
7540 terminal_history_tests::before_terminal_record(spawn_events);
7541 spawn_events.emit_exited(module_id, exit_report.code, exit_report.signal);
7542 let record = TerminalRecord {
7543 exit_code: exit_report.code,
7544 exit_signal: exit_report.signal,
7545 at_ms: exit_report.at_ms,
7546 disposition,
7547 exit_kind: exit_report.kind.into(),
7548 disposition_detail,
7549 };
7550 terminal_ring
7551 .lock()
7552 .unwrap_or_else(|poisoned| poisoned.into_inner())
7553 .record_exit(module_id, record);
7554}
7555
7556fn untrack_if_registration_released(
7557 process_liveness: &SupervisorProcessLiveness,
7558 registry: &Registry,
7559 module_id: &str,
7560 snapshot: &SharedSnapshot,
7561) {
7562 match registry.get_module(module_id) {
7563 Ok(None) => process_liveness.untrack_if_current(module_id, snapshot),
7564 Ok(Some(_)) => {}
7565 Err(err) => {
7566 warn!(module_id, error = %err, "could not determine whether supervisor liveness can be untracked");
7567 }
7568 }
7569}
7570
7571#[cfg(test)]
7585fn apply_wire_spawn_args(
7586 command: &mut Command,
7587 spec: &ModuleSpec,
7588 connection_file_path: Option<&std::path::Path>,
7589 handle: Option<&SupervisorHandle>,
7590) -> Result<Option<NonceHandoff>, SuperviseError> {
7591 apply_wire_spawn_args_for_role(
7592 command,
7593 spec,
7594 connection_file_path,
7595 handle,
7596 SpawnRole::Plain,
7597 )
7598}
7599
7600#[cfg(unix)]
7605type NonceHandoff = subc_os::LaunchNonceHandoff;
7606#[cfg(windows)]
7607type NonceHandoff = subc_os::LaunchNoncePipeHandoff;
7608#[cfg(not(any(unix, windows)))]
7609type NonceHandoff = std::convert::Infallible;
7610
7611fn apply_wire_spawn_args_for_role(
7632 command: &mut Command,
7633 spec: &ModuleSpec,
7634 connection_file_path: Option<&std::path::Path>,
7635 handle: Option<&SupervisorHandle>,
7636 role: SpawnRole,
7637) -> Result<Option<NonceHandoff>, SuperviseError> {
7638 command.env(SUBC_MODULE_ID_ENV, &spec.module_id);
7639 command.env_remove(subc_os::LAUNCH_NONCE_FD_ENV);
7644 #[cfg(windows)]
7645 {
7646 command.env_remove(subc_os::LAUNCH_NONCE_PIPE_ENV);
7647 command.env_remove(subc_os::LAUNCH_NONCE_PIPE_FALLBACK_ENV);
7648 }
7649 command.env_remove(SUBC_LAUNCH_NONCE_ENV);
7651 if spec.protocol == ModuleProtocol::None {
7652 return Ok(None);
7653 }
7654 if let Some(connection_file_path) = connection_file_path {
7655 command.arg(SUBC_ARG).arg(connection_file_path);
7656 }
7657
7658 let nonce = generate_launch_nonce()?;
7662 if let Some(handle) = handle {
7663 match role {
7664 SpawnRole::Plain => {
7665 handle.set_spawn_nonce(&spec.module_id, nonce.clone());
7666 if spec.reserved {
7667 handle.set_reserved_nonce(&spec.module_id, nonce.clone());
7668 }
7669 }
7670 SpawnRole::SwapCandidate => handle.open_swap(&spec.module_id, nonce.clone()),
7671 }
7672 }
7673 #[cfg(unix)]
7674 let handoff = {
7675 let handoff =
7676 subc_os::LaunchNonceHandoff::new(&nonce).map_err(|source| SuperviseError::Spawn {
7677 program: spec.program.clone(),
7678 source,
7679 cgroup_path: None,
7680 })?;
7681 command.env(subc_os::LAUNCH_NONCE_FD_ENV, handoff.fd_env_value());
7682 Some(handoff)
7683 };
7684 #[cfg(windows)]
7685 let handoff = prepare_windows_nonce_pipe(command, spec, &nonce);
7686 #[cfg(not(any(unix, windows)))]
7687 let handoff = None;
7688 #[cfg(not(unix))]
7692 command.env(SUBC_LAUNCH_NONCE_ENV, nonce);
7693 Ok(handoff)
7694}
7695
7696fn apply_child_env(command: &mut Command, spec: &ModuleSpec) {
7697 command.env_remove(CK_LOG_ENV);
7698 command.env_remove(SUBC_SPAWN_ROLE_ENV);
7705 for (key, value) in &spec.env {
7706 if matches!(
7710 key.as_str(),
7711 CAPTURE_MAX_FILE_MB_ENV | CAPTURE_KEEP_ENV | CAPTURE_MAX_AGE_DAYS_ENV
7712 ) || key == SUBC_SPAWN_ROLE_ENV
7713 {
7714 continue;
7715 }
7716 command.env(key, value);
7717 }
7718}
7719
7720#[cfg(windows)]
7721fn prepare_windows_nonce_pipe(
7722 command: &mut Command,
7723 spec: &ModuleSpec,
7724 nonce: &str,
7725) -> Option<NonceHandoff> {
7726 match subc_os::LaunchNoncePipeHandoff::new(nonce) {
7727 Ok(handoff) => {
7728 command.env(subc_os::LAUNCH_NONCE_PIPE_ENV, handoff.name());
7729 command.env(subc_os::LAUNCH_NONCE_PIPE_FALLBACK_ENV, "env");
7734 Some(handoff)
7735 }
7736 Err(error) => {
7737 warn!(module_id = %spec.module_id, %error, "could not prepare launch nonce pipe; retaining the environment handoff");
7738 None
7739 }
7740 }
7741}
7742
7743#[cfg(windows)]
7744fn start_windows_nonce_pipe(
7745 handoff: Option<NonceHandoff>,
7746 pid: u32,
7747 spec: &ModuleSpec,
7748) -> Option<subc_os::LaunchNoncePipeDelivery> {
7749 handoff.and_then(|handoff| match handoff.serve(pid, REGISTRY_RELEASE_TIMEOUT) {
7753 Ok(delivery) => Some(delivery),
7754 Err(error) => {
7755 warn!(module_id = %spec.module_id, %error, "could not serve launch nonce pipe; retaining the environment handoff");
7756 None
7757 }
7758 })
7759}
7760
7761fn observed_launch_nonce_source(snapshot: &SupervisorSnapshot) -> Option<String> {
7762 #[cfg(windows)]
7763 if snapshot.process_alive && snapshot.spawned_protocol == Some(ModuleProtocol::Subc) {
7764 return Some(
7765 if snapshot
7766 .pipe_consumed
7767 .as_ref()
7768 .is_some_and(|consumed| consumed.get().is_some())
7769 {
7770 "pipe"
7771 } else {
7772 "env"
7773 }
7774 .to_owned(),
7775 );
7776 }
7777 let _ = snapshot;
7778 None
7779}
7780
7781#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7784enum SpawnRole {
7785 Plain,
7786 SwapCandidate,
7787}
7788
7789fn apply_spawn_role(command: &mut Command, role: SpawnRole) {
7792 if role == SpawnRole::SwapCandidate {
7793 command.env(SUBC_SPAWN_ROLE_ENV, SPAWN_ROLE_SWAP_CANDIDATE);
7794 }
7795}
7796
7797#[cfg(target_os = "linux")]
7798fn previous_instance_error(spec: &ModuleSpec, pid: u32) -> SuperviseError {
7799 SuperviseError::Spawn {
7800 program: spec.program.clone(),
7801 source: io::Error::new(io::ErrorKind::WouldBlock, format!(
7802 "waiting: previous instance (pid {pid}) is still running but hidden; it cannot be identified"
7803 )),
7804 cgroup_path: None,
7805 }
7806}
7807
7808fn spawn_child(
7809 spec: &ModuleSpec,
7810 connection_file_path: Option<&std::path::Path>,
7811 handle: Option<&SupervisorHandle>,
7812 ring: &Arc<Mutex<StderrRing>>,
7813 capture_logs_dir: Option<&std::path::Path>,
7814 roster: &ChildRoster,
7815 #[cfg(target_os = "linux")] cgroup_placement: Option<&subc_cgroup::Placement>,
7816) -> Result<SupervisedChild, SuperviseError> {
7817 spawn_child_in_slot(
7818 spec,
7819 connection_file_path,
7820 handle,
7821 ring,
7822 capture_logs_dir,
7823 roster,
7824 #[cfg(target_os = "linux")]
7825 cgroup_placement,
7826 SpawnRole::Plain,
7827 false,
7828 )
7829}
7830
7831#[allow(clippy::too_many_arguments)]
7844fn spawn_child_in_slot(
7845 spec: &ModuleSpec,
7846 connection_file_path: Option<&std::path::Path>,
7847 handle: Option<&SupervisorHandle>,
7848 ring: &Arc<Mutex<StderrRing>>,
7849 capture_logs_dir: Option<&std::path::Path>,
7850 roster: &ChildRoster,
7851 #[cfg(target_os = "linux")] cgroup_placement: Option<&subc_cgroup::Placement>,
7852 role: SpawnRole,
7853 alternate_slot: bool,
7854) -> Result<SupervisedChild, SuperviseError> {
7855 #[cfg(target_os = "linux")]
7856 if let Some(pid) = roster.previous_instance_pid(&spec.module_id) {
7857 return Err(previous_instance_error(spec, pid));
7858 }
7859 if roster.is_closed() {
7860 return Err(SuperviseError::Spawn {
7861 program: spec.program.clone(),
7862 source: io::Error::other("the daemon is shutting down; not starting a new process"),
7863 cgroup_path: None,
7864 });
7865 }
7866 #[cfg(target_os = "linux")]
7867 let cgroup_name = {
7868 if cgroup_placement.is_none() {
7873 swap::cgroup_name(&spec.module_id, alternate_slot)
7874 } else {
7875 let nonce = generate_launch_nonce()?;
7876 let suffix = u128::from_str_radix(&nonce[..32], 16).expect("hex launch nonce");
7877 let mut end = spec.module_id.len().min(64);
7880 while !spec.module_id.is_char_boundary(end) {
7881 end -= 1;
7882 }
7883 format!(
7884 "{}_{suffix}",
7885 swap::cgroup_name(&spec.module_id[..end], alternate_slot)
7886 )
7887 }
7888 };
7889 #[cfg(not(target_os = "linux"))]
7890 let _ = alternate_slot;
7891 #[cfg(target_os = "macos")]
7892 let (mut command, privacy_exec, exec_ack) = privacy_command(spec, roster)?;
7893 #[cfg(windows)]
7894 let image_capture = windows_spawn_capture(spec).map_err(|source| SuperviseError::Spawn {
7895 program: spec.program.clone(),
7896 source,
7897 cgroup_path: None,
7898 })?;
7899 #[cfg(windows)]
7900 let mut command = Command::new(image_capture.path());
7901 #[cfg(not(any(target_os = "macos", windows)))]
7902 let mut command = Command::new(&spec.program);
7903 command.args(&spec.args);
7904 apply_child_env(&mut command, spec);
7934 apply_spawn_role(&mut command, role);
7935 let nonce_handoff =
7936 apply_wire_spawn_args_for_role(&mut command, spec, connection_file_path, handle, role)?;
7937
7938 #[cfg(target_os = "linux")]
7939 let cgroup_path = cgroup_placement
7940 .map(|placement| placement.module_path(&cgroup_name))
7941 .transpose()
7942 .map_err(|source| SuperviseError::Cgroup {
7943 module_id: spec.module_id.clone(),
7944 source,
7945 })?;
7946 #[cfg(not(target_os = "linux"))]
7947 let cgroup_path: Option<PathBuf> = None;
7948 #[cfg(target_os = "linux")]
7949 if let Some(path) = &cgroup_path {
7950 if let Err(error) = apply_cgroup_placement(&mut command, spec, path) {
7951 if let Some(placement) = cgroup_placement {
7952 remove_module_cgroup(placement, &cgroup_name);
7953 }
7954 return Err(error);
7955 }
7956 }
7957
7958 let output_sink = if let Some(logs_dir) = capture_logs_dir {
7959 let path = logs_dir.join(format!("{}.stderr.log", spec.module_id));
7960 match ChildOutputSink::open(&path, capture_retention(spec)) {
7961 Ok(sink) => sink,
7962 Err(error) => {
7963 warn!(
7964 module_id = %spec.module_id,
7965 path = %path.display(),
7966 error = %error,
7967 "could not open child output capture file; forwarding to stderr"
7968 );
7969 ChildOutputSink::Stderr
7970 }
7971 }
7972 } else {
7973 ChildOutputSink::Stderr
7974 };
7975
7976 command.stdout(Stdio::piped());
7977 command.stderr(Stdio::piped());
7978 command.kill_on_drop(true);
7979 #[cfg(unix)]
7996 command.process_group(0);
7997 command.stdin(Stdio::null());
7998 #[cfg(unix)]
8002 if let Some(handoff) = nonce_handoff {
8003 handoff.install_last(command.as_std_mut());
8004 }
8005 #[cfg(not(any(unix, windows)))]
8006 let _ = nonce_handoff;
8007
8008 #[cfg(windows)]
8013 subc_jobobject::suspend_on_create_async(&mut command);
8014 let mut child = match command.spawn() {
8015 Ok(child) => child,
8016 Err(source) => {
8017 #[cfg(target_os = "linux")]
8018 if let Some(placement) = cgroup_placement {
8019 remove_module_cgroup(placement, &cgroup_name);
8020 }
8021 return Err(SuperviseError::Spawn {
8022 program: spec.program.clone(),
8023 source,
8024 cgroup_path,
8025 });
8026 }
8027 };
8028 #[cfg(target_os = "macos")]
8033 drop(exec_ack);
8034
8035 #[cfg(windows)]
8040 let windows_image =
8041 Arc::new(
8042 image_capture
8043 .bind_tokio(&child)
8044 .map_err(|source| SuperviseError::Spawn {
8045 program: spec.program.clone(),
8046 source,
8047 cgroup_path: None,
8048 })?,
8049 );
8050 #[cfg(windows)]
8052 let job = contain_spawned_child(&child, spec)?;
8053 let spawned_at_ms = unix_ms_now();
8054 #[cfg(not(windows))]
8055 let spawned_from = spec.program.clone();
8056 #[cfg(windows)]
8057 let spawned_from = windows_image.path().to_path_buf();
8058 #[cfg(not(windows))]
8059 let spawned_file_identity = spawned_file_identity(&spawned_from);
8060 #[cfg(windows)]
8061 let spawned_file_identity = Some(SpawnedFileIdentity {
8062 device: 0,
8063 inode: 0,
8064 windows_image: Some(Arc::clone(&windows_image)),
8065 });
8066 let pid = child.id().ok_or_else(|| SuperviseError::Spawn {
8067 program: spec.program.clone(),
8068 source: io::Error::other("spawned child exposed no live pid"),
8069 cgroup_path: cgroup_path.clone(),
8070 })?;
8071 #[cfg(windows)]
8072 let nonce_delivery = start_windows_nonce_pipe(nonce_handoff, pid, spec);
8073 #[cfg(not(windows))]
8074 let process_start_time = crate::provenance::process_start_time(pid);
8075 #[cfg(windows)]
8076 let process_start_time = Some(windows_image.start_time());
8077 let process_identity = process_start_time.map(|start_time| ProcessIdentity { pid, start_time });
8078 #[cfg(all(test, target_os = "macos"))]
8079 privacy_exec_boundary_tests::before_image_sample(spec, pid);
8080 #[cfg(not(windows))]
8086 let recorded_image = observe_spawned_image(pid);
8087 #[cfg(target_os = "macos")]
8091 let recorded_image = if privacy_exec.is_some() {
8092 None
8093 } else {
8094 recorded_image
8095 };
8096 #[cfg(target_os = "linux")]
8097 let recorded_cgroup_name = cgroup_path.as_ref().map(|_| cgroup_name.clone());
8098 #[cfg(not(target_os = "linux"))]
8099 let recorded_cgroup_name = None;
8100 let roster_guard = roster.admit(
8101 spec.module_id.clone(),
8102 pid,
8103 spec.protocol,
8104 process_start_time,
8105 crate::child_roster::RecordedIdentity {
8106 #[cfg(not(windows))]
8107 start_time: recorded_image.map(|image| image.start_time),
8108 #[cfg(windows)]
8109 start_time: process_start_time,
8110 #[cfg(not(windows))]
8111 executable: recorded_image
8112 .and_then(|image| image.executable)
8113 .map(crate::live_children::ExecutableIdentity::from),
8114 #[cfg(windows)]
8115 executable: Some(crate::live_children::ExecutableIdentity::from(
8116 windows_image.identity(),
8117 )),
8118 cgroup_name: recorded_cgroup_name,
8119 #[cfg(target_os = "linux")]
8120 cgroup_placement: cgroup_placement.cloned(),
8121 },
8122 );
8123 if roster.is_closed() {
8132 #[cfg(target_os = "linux")]
8134 kill_module_cgroup(cgroup_placement, &cgroup_name);
8135 if let Err(error) = child.start_kill() {
8136 debug!(module_id = %spec.module_id, pid, %error, "kill of a process spawned during daemon shutdown failed; it may already have exited");
8137 }
8138 #[cfg(target_os = "linux")]
8139 if let Some(placement) = cgroup_placement {
8140 while matches!(child.try_wait(), Ok(None)) {
8144 std::thread::yield_now();
8145 }
8146 if matches!(
8147 subc_cgroup::kill_module(Some(placement), &cgroup_name),
8148 subc_cgroup::KillOutcome::Killed
8149 ) {
8150 if let Ok(path) = placement.module_path(&cgroup_name) {
8151 while std::fs::read_to_string(path.join("cgroup.events"))
8152 .ok()
8153 .is_some_and(|events| events.lines().any(|line| line == "populated 1"))
8154 {
8155 std::thread::yield_now();
8156 }
8157 }
8158 }
8159 remove_module_cgroup(placement, &cgroup_name);
8160 }
8161 drop(roster_guard);
8162 return Err(SuperviseError::Spawn {
8163 program: spec.program.clone(),
8164 source: io::Error::other(
8165 "the daemon began shutting down while this process was starting; ended it",
8166 ),
8167 cgroup_path,
8168 });
8169 }
8170
8171 let stdout_pump = match child.stdout.take() {
8172 Some(stdout) => Some(tokio::spawn(pump_stdout_to(stdout, output_sink.clone()))),
8173 None => {
8174 warn!(
8175 module_id = %spec.module_id,
8176 "spawned child exposed no stdout pipe; file capture will be incomplete"
8177 );
8178 None
8179 }
8180 };
8181 let stderr_pump = match child.stderr.take() {
8182 Some(stderr) => {
8183 let generation = ring
8184 .lock()
8185 .unwrap_or_else(|poisoned| poisoned.into_inner())
8186 .begin_process();
8187 Some(StderrPump {
8188 task: tokio::spawn(pump_stderr_to(
8189 stderr,
8190 Arc::clone(ring),
8191 generation,
8192 output_sink,
8193 )),
8194 generation,
8195 })
8196 }
8197 None => {
8198 ring.lock()
8202 .unwrap_or_else(|poisoned| poisoned.into_inner())
8203 .mark_not_captured("stderr pipe was not available on spawn");
8204 warn!(
8205 module_id = %spec.module_id,
8206 "spawned child exposed no stderr pipe; tail will be unavailable"
8207 );
8208 None
8209 }
8210 };
8211
8212 Ok(SupervisedChild {
8213 child,
8214 #[cfg(windows)]
8215 nonce_delivery,
8216 protocol: spec.protocol,
8217 #[cfg(target_os = "linux")]
8218 module_id: cgroup_name,
8219 #[cfg(target_os = "linux")]
8220 cgroup_placement: cgroup_placement.cloned(),
8221 #[cfg(windows)]
8222 job,
8223 stdout_pump,
8224 stderr_pump,
8225 stderr_ring: Arc::clone(ring),
8226 spawned_at_ms,
8227 spawned_from,
8228 spawned_file_identity,
8229 process_start_time,
8230 process_identity,
8231 pid,
8232 roster_guard: Some(roster_guard),
8233 #[cfg(target_os = "macos")]
8234 privacy_exec,
8235 #[cfg(target_os = "macos")]
8236 report_ready: Arc::new(OnceLock::new()),
8237 spawn_failure: None,
8238 })
8239}
8240
8241#[cfg(target_os = "linux")]
8242pub(crate) fn kill_module_cgroup(placement: Option<&subc_cgroup::Placement>, module_id: &str) {
8243 use subc_cgroup::KillOutcome;
8244 match subc_cgroup::kill_module(placement, module_id) {
8245 KillOutcome::Killed => {}
8246 KillOutcome::NotPlaced | KillOutcome::Unsupported => {
8247 debug!(
8248 module_id,
8249 "cgroup tree kill unavailable; using direct-child kill"
8250 );
8251 }
8252 KillOutcome::IoError { path, error } => {
8253 warn!(module_id, path = %path.display(), %error, "cgroup tree kill failed; using direct-child kill");
8254 }
8255 }
8256}
8257
8258#[cfg(windows)]
8272fn contain_spawned_child(
8273 child: &Child,
8274 spec: &ModuleSpec,
8275) -> Result<Option<subc_jobobject::JobObject>, SuperviseError> {
8276 let module_id = spec.module_id.as_str();
8277 let Some(pid) = child.id() else {
8278 warn!(
8281 module_id,
8282 "spawned child had already exited before containment; no job object attached"
8283 );
8284 return Ok(None);
8285 };
8286
8287 let job = match subc_jobobject::JobObject::new() {
8288 Ok(job) => job,
8289 Err(source) => {
8290 warn!(
8291 module_id,
8292 error = %source,
8293 "could not create a job object; this module's helper processes will not be \
8294 reaped on teardown"
8295 );
8296 resume_suspended_child(pid, spec)?;
8299 return Ok(None);
8300 }
8301 };
8302
8303 if let Err(source) = job.assign(child) {
8304 warn!(
8305 module_id,
8306 error = %source,
8307 "could not assign the child to its job object; this module's helper processes \
8308 will not be reaped on teardown"
8309 );
8310 resume_suspended_child(pid, spec)?;
8311 return Ok(None);
8312 }
8313
8314 resume_suspended_child(pid, spec)?;
8315 Ok(Some(job))
8316}
8317
8318#[cfg(windows)]
8323fn resume_suspended_child(pid: u32, spec: &ModuleSpec) -> Result<(), SuperviseError> {
8324 if let Err(source) = subc_jobobject::resume_main_thread(pid) {
8325 let _ = std::process::Command::new("taskkill.exe")
8329 .args(["/PID", &pid.to_string(), "/T", "/F"])
8330 .stdin(Stdio::null())
8331 .stdout(Stdio::null())
8332 .stderr(Stdio::null())
8333 .status();
8334 return Err(SuperviseError::Spawn {
8335 program: spec.program.clone(),
8336 source,
8337 cgroup_path: None,
8338 });
8339 }
8340 Ok(())
8341}
8342
8343#[cfg(target_os = "linux")]
8344fn remove_module_cgroup(placement: &subc_cgroup::Placement, module_id: &str) {
8345 match placement.remove_module(module_id) {
8346 Ok(()) => debug!(module_id, "removed module cgroup after process exit"),
8347 Err(error) => warn!(
8348 module_id,
8349 error = %error,
8350 "could not remove module cgroup after process exit; continuing teardown"
8351 ),
8352 }
8353}
8354
8355#[cfg(target_os = "linux")]
8356async fn cleanup_reaped_cgroup(placement: &subc_cgroup::Placement, module_id: &str) {
8357 if matches!(
8361 subc_cgroup::kill_module(Some(placement), module_id),
8362 subc_cgroup::KillOutcome::Killed
8363 ) {
8364 if let Ok(path) = placement.module_path(module_id) {
8365 while std::fs::read_to_string(path.join("cgroup.events"))
8366 .ok()
8367 .is_some_and(|events| events.lines().any(|line| line == "populated 1"))
8368 {
8369 sleep(Duration::from_millis(1)).await;
8370 }
8371 }
8372 }
8373 remove_module_cgroup(placement, module_id);
8374}
8375
8376#[cfg(target_os = "linux")]
8377fn apply_cgroup_placement(
8378 command: &mut Command,
8379 spec: &ModuleSpec,
8380 path: &std::path::Path,
8381) -> Result<(), SuperviseError> {
8382 subc_cgroup::apply(command, path).map_err(|source| SuperviseError::Cgroup {
8383 module_id: spec.module_id.clone(),
8384 source,
8385 })
8386}
8387
8388fn capture_retention(spec: &ModuleSpec) -> Retention {
8389 let defaults = Retention::default();
8390 let value = |name: &str| {
8391 spec.env
8392 .iter()
8393 .rev()
8394 .find_map(|(key, value)| (key == name).then_some(value.as_str()))
8395 };
8396 Retention {
8397 max_file_mb: value(CAPTURE_MAX_FILE_MB_ENV)
8398 .and_then(|value| value.parse().ok())
8399 .unwrap_or(defaults.max_file_mb),
8400 keep: value(CAPTURE_KEEP_ENV)
8401 .and_then(|value| value.parse().ok())
8402 .unwrap_or(defaults.keep),
8403 max_age_days: value(CAPTURE_MAX_AGE_DAYS_ENV)
8404 .and_then(|value| value.parse().ok())
8405 .unwrap_or(defaults.max_age_days),
8406 }
8407}
8408
8409fn generate_launch_nonce() -> Result<String, SuperviseError> {
8412 let mut bytes = [0u8; 32];
8413 getrandom::getrandom(&mut bytes).map_err(|source| SuperviseError::LaunchNonce {
8414 reason: source.to_string(),
8415 })?;
8416 let mut hex = String::with_capacity(64);
8417 for b in bytes {
8418 use std::fmt::Write;
8419 let _ = write!(hex, "{b:02x}");
8420 }
8421 Ok(hex)
8422}
8423
8424fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
8427 if a.len() != b.len() {
8428 return false;
8429 }
8430 let mut diff = 0u8;
8431 for (x, y) in a.iter().zip(b.iter()) {
8432 diff |= x ^ y;
8433 }
8434 diff == 0
8435}
8436
8437#[cfg(not(windows))]
8441fn observe_spawned_image(pid: u32) -> Option<subc_os::Observation> {
8442 subc_os::Process::open(pid)
8443 .ok()
8444 .flatten()
8445 .and_then(|process| process.observe())
8446}
8447
8448#[cfg(windows)]
8451fn windows_spawn_capture(spec: &ModuleSpec) -> io::Result<subc_os::ExecutableCapture> {
8452 let mut program = spec.program.clone();
8453 if program.is_absolute() || program.components().count() > 1 {
8454 match subc_os::ExecutableCapture::open(&program) {
8457 Ok(capture) => return Ok(capture),
8458 Err(error)
8459 if error.kind() == io::ErrorKind::NotFound && program.extension().is_none() => {}
8460 Err(error) => return Err(error),
8461 }
8462 program.set_extension("exe");
8463 return subc_os::ExecutableCapture::open(&program);
8464 }
8465 if program.extension().is_none() {
8466 program.set_extension("exe");
8467 }
8468 let custom_path = spec
8469 .env
8470 .iter()
8471 .rev()
8472 .find(|(key, _)| key.eq_ignore_ascii_case("PATH"));
8473 let mut directories = Vec::new();
8474 if let Some(parent) = std::env::current_exe()?.parent() {
8477 directories.push(parent.to_path_buf());
8478 }
8479 directories.push(std::env::current_dir()?);
8480 if let Some(windows) = std::env::var_os("SystemRoot") {
8481 let windows = PathBuf::from(windows);
8482 directories.push(windows.join("System32"));
8483 directories.push(windows.clone());
8484 }
8485 let path = custom_path
8486 .map(|(_, value)| std::ffi::OsString::from(value))
8487 .or_else(|| std::env::var_os("PATH"));
8488 if let Some(path) = path {
8489 directories.extend(std::env::split_paths(&path));
8490 }
8491 for directory in directories {
8492 match subc_os::ExecutableCapture::open(&directory.join(&program)) {
8493 Ok(capture) => return Ok(capture),
8494 Err(error) if error.kind() == io::ErrorKind::NotFound => continue,
8495 Err(error) => return Err(error),
8496 }
8497 }
8498 Err(io::Error::new(
8499 io::ErrorKind::NotFound,
8500 "resolved executable not found",
8501 ))
8502}
8503
8504fn spawn_and_mark_running(
8505 spec: &ModuleSpec,
8506 runtime: &SupervisorRuntimeConfig,
8507 snapshot: &SharedSnapshot,
8508) -> Result<SupervisedChild, SuperviseError> {
8509 let child = spawn_child(
8510 spec,
8511 runtime.connection_file_path.as_deref(),
8512 runtime.supervisor_handle.as_ref(),
8513 &runtime.stderr_ring,
8514 runtime.capture_logs_dir.as_deref(),
8515 &runtime.child_roster,
8516 #[cfg(target_os = "linux")]
8517 runtime.cgroup_placement.as_ref(),
8518 )?;
8519 set_running(snapshot, &child, &spec.module_id, &runtime.spawn_events)?;
8520 Ok(child)
8521}
8522
8523enum RegistrationWaitOutcome {
8524 Registered,
8525 Exited(ExitReport),
8526 TimedOut,
8527}
8528
8529struct ReloadRegistrationFailure {
8530 exit_report: ExitReport,
8531 reason: String,
8532}
8533
8534#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8535enum BusyGaugeObservation {
8536 Quiescent,
8537 Busy,
8538 Omitted,
8539}
8540
8541fn busy_gauge_observation(metrics: Option<&Value>, gauges: &[String]) -> BusyGaugeObservation {
8542 let Some(metrics) = metrics.and_then(Value::as_object) else {
8543 return BusyGaugeObservation::Omitted;
8544 };
8545 let mut sum = 0u128;
8546 for gauge in gauges {
8547 let Some(value) = metrics.get(gauge) else {
8548 return BusyGaugeObservation::Omitted;
8549 };
8550 let Some(value) = value.as_u64() else {
8551 return BusyGaugeObservation::Busy;
8552 };
8553 sum = sum.saturating_add(u128::from(value));
8554 }
8555 if sum == 0 {
8556 BusyGaugeObservation::Quiescent
8557 } else {
8558 BusyGaugeObservation::Busy
8559 }
8560}
8561
8562fn declared_busy_gauges(
8563 registry: &Registry,
8564 module_id: &str,
8565) -> Result<Vec<String>, SuperviseError> {
8566 busy_gauges_of(
8567 registry
8568 .get_module(module_id)
8569 .map_err(SuperviseError::Registry)?,
8570 )
8571}
8572
8573fn declared_busy_gauges_for_connection(
8577 registry: &Registry,
8578 connection_id: ConnectionId,
8579) -> Result<Vec<String>, SuperviseError> {
8580 busy_gauges_of(
8581 registry
8582 .get_module_by_connection(connection_id)
8583 .map_err(SuperviseError::Registry)?,
8584 )
8585}
8586
8587fn busy_gauges_of(
8588 registration: Option<crate::registry::ModuleRegistration>,
8589) -> Result<Vec<String>, SuperviseError> {
8590 let Some(registration) = registration else {
8591 return Ok(Vec::new());
8592 };
8593 let Some(self_signals) = registration.manifest.self_signals else {
8594 return Ok(Vec::new());
8595 };
8596
8597 let mut gauges = Vec::new();
8598 for declaration in self_signals {
8599 if declaration.kind != SelfSignalKind::Busy {
8600 continue;
8601 }
8602 match declaration.anchored_to {
8603 SignalAnchor::HealthGauges { gauges: declared } if !declared.is_empty() => {
8604 gauges.extend(declared)
8605 }
8606 _ => {
8607 gauges.push(String::new());
8610 }
8611 }
8612 }
8613 Ok(gauges)
8614}
8615
8616async fn wait_for_forwarding_quiescence(
8621 forwarding: &ForwardingTable,
8622 module_id: &str,
8623 runtime: &SupervisorRuntimeConfig,
8624 endpoint: crate::ModuleEndpointId,
8625 deadline: Instant,
8626 busy_gauges: &[String],
8627 scope: DrainScope,
8628) -> Result<bool, SuperviseError> {
8629 let mut gauges_quiescent = busy_gauges.is_empty();
8630 let mut next_probe_at = Instant::now();
8631 let mut omission_counted = false;
8632
8633 loop {
8634 let now = Instant::now();
8635 if !busy_gauges.is_empty() && now >= next_probe_at && now < deadline {
8636 let report = match scope {
8637 DrainScope::Active => probe_module_health(module_id, runtime, Some(deadline)).await,
8638 DrainScope::Endpoint(endpoint) => {
8639 probe_endpoint_health(endpoint, runtime, Some(deadline)).await
8640 }
8641 };
8642 gauges_quiescent = match report {
8643 Ok(report) => match busy_gauge_observation(report.metrics.as_ref(), busy_gauges) {
8644 BusyGaugeObservation::Quiescent => true,
8645 BusyGaugeObservation::Busy => false,
8646 BusyGaugeObservation::Omitted => {
8647 if !omission_counted {
8648 forwarding
8649 .counters()
8650 .increment_drains_with_undeclared_gauge();
8651 omission_counted = true;
8652 }
8653 false
8654 }
8655 },
8656 Err(err) => {
8657 warn!(
8658 module_id,
8659 error = %err,
8660 "drain health.check did not produce declared busy gauges; treating module as busy"
8661 );
8662 false
8663 }
8664 };
8665 next_probe_at = Instant::now() + runtime.health.cadence.max(REGISTRY_RELEASE_POLL);
8666 }
8667
8668 let in_flight = forwarding
8669 .endpoint_in_flight_count(endpoint)
8670 .map_err(SuperviseError::Forwarding)?;
8671 if in_flight == 0 && gauges_quiescent {
8672 return Ok(true);
8673 }
8674
8675 let now = Instant::now();
8676 if now >= deadline {
8677 return Ok(false);
8678 }
8679 let mut wait = deadline
8680 .saturating_duration_since(now)
8681 .min(REGISTRY_RELEASE_POLL);
8682 if !busy_gauges.is_empty() {
8683 wait = wait.min(next_probe_at.saturating_duration_since(now));
8684 }
8685 sleep(wait).await;
8686 }
8687}
8688
8689fn drained_after_quiescence_wait(wait_result: &Result<bool, SuperviseError>) -> bool {
8697 match wait_result {
8698 Ok(drained) => *drained,
8699 Err(_) => false,
8700 }
8701}
8702
8703fn send_route_goodbyes(forwarding: &ForwardingTable, released_routes: Vec<GoodbyeTarget>) {
8704 for released in released_routes {
8705 let frame = match Frame::build_with_version(
8706 released.negotiated_ver,
8707 FrameType::Goodbye,
8708 control_flags(),
8709 released.channel,
8710 released.epoch,
8711 0,
8712 Vec::new(),
8713 ) {
8714 Ok(frame) => frame,
8715 Err(err) => {
8716 warn!(
8717 route_channel = released.channel,
8718 error = %err,
8719 "failed to build supervisor drain route GOODBYE frame"
8720 );
8721 continue;
8722 }
8723 };
8724 if !released.close_on_delivery_failure() {
8725 crate::forwarding::send_module_route_goodbye(
8726 &forwarding.counters(),
8727 &released.sink,
8728 frame,
8729 released.module_id.as_deref(),
8730 "supervisor drain",
8731 );
8732 continue;
8733 }
8734 if let Err(err) = released.sink.try_send(frame) {
8735 warn!(
8736 target_connection_id = released.connection_id.get(),
8737 route_channel = released.channel,
8738 error = %err,
8739 "supervisor drain route GOODBYE was not delivered to client; closing target connection"
8740 );
8741 let _ = forwarding.escalate_client_delivery_failure(
8742 released.connection_id,
8743 released.channel,
8744 released.epoch,
8745 CloseReason::new(
8746 "route_goodbye_delivery_failed",
8747 format!(
8748 "failed to enqueue supervisor drain route GOODBYE for channel {}: {err}",
8749 released.channel
8750 ),
8751 ),
8752 crate::forwarding::UndeliveredFrame {
8753 module_id: released.module_id.as_deref(),
8754 sink: &released.sink,
8755 },
8756 );
8757 }
8758 }
8759}
8760
8761fn send_module_draining(
8762 module_id: &str,
8763 reason: RouteCloseReason,
8764 deadline_ms: u64,
8765 target: &ModuleDrainTarget,
8766) {
8767 let body = match serde_json::to_vec(&ModuleControlCommand::Draining {
8768 reason,
8769 deadline_ms,
8770 }) {
8771 Ok(body) => body,
8772 Err(err) => {
8773 warn!(
8774 module_id,
8775 error = %err,
8776 "failed to encode module draining command"
8777 );
8778 return;
8779 }
8780 };
8781 let frame = match Frame::build_with_version(
8782 target.negotiated_ver,
8783 FrameType::Push,
8784 control_flags(),
8785 0,
8786 0,
8787 0,
8788 body,
8789 ) {
8790 Ok(frame) => frame,
8791 Err(err) => {
8792 warn!(
8793 module_id,
8794 error = %err,
8795 "failed to build module draining command frame"
8796 );
8797 return;
8798 }
8799 };
8800 if let Err(err) = target.sink.try_send(frame) {
8801 warn!(
8802 module_id,
8803 target_connection_id = target.endpoint.connection_id.get(),
8804 error = %err,
8805 "module draining command was not delivered to peer"
8806 );
8807 }
8808}
8809
8810fn module_goodbye_frame(module_id: &str, negotiated_ver: u8) -> Option<Frame> {
8812 match Frame::build_with_version(
8813 negotiated_ver,
8814 FrameType::Goodbye,
8815 control_flags(),
8816 0,
8817 0,
8818 0,
8819 Vec::new(),
8820 ) {
8821 Ok(frame) => Some(frame),
8822 Err(err) => {
8823 warn!(
8824 module_id,
8825 error = %err,
8826 "failed to build module GOODBYE frame"
8827 );
8828 None
8829 }
8830 }
8831}
8832
8833#[cfg(unix)]
8847async fn send_module_goodbyes_for_daemon_shutdown(
8848 forwarding: &Arc<ForwardingTable>,
8849 reason: &CloseReason,
8850 wait_for_flush: bool,
8851) {
8852 const GOODBYE_BUDGET: Duration = Duration::from_millis(500);
8853 let targets = match forwarding.module_connections() {
8854 Ok(targets) => targets,
8855 Err(err) => {
8856 warn!(error = %err, "could not list module connections for shutdown GOODBYE");
8857 return;
8858 }
8859 };
8860 let deadline = Instant::now() + GOODBYE_BUDGET;
8861 let mut sends = tokio::task::JoinSet::new();
8862 for target in targets {
8863 let Some(frame) = module_goodbye_frame(&target.module_id, target.negotiated_ver) else {
8864 continue;
8865 };
8866 if !wait_for_flush {
8867 if let Err(err) = target.sink.try_send(frame) {
8868 debug!(
8869 module_id = %target.module_id,
8870 error = %err,
8871 "shutdown module GOODBYE was not queued"
8872 );
8873 }
8874 continue;
8875 }
8876 let forwarding = Arc::clone(forwarding);
8877 let reason = reason.clone();
8878 sends.spawn(async move {
8879 match timeout_at(deadline, target.sink.send_flushed(frame)).await {
8880 Ok(Ok(())) => {}
8881 Ok(Err(err)) => debug!(
8882 module_id = %target.module_id,
8883 error = %err,
8884 "module connection closed before its shutdown GOODBYE was written"
8885 ),
8886 Err(_) => warn!(
8887 module_id = %target.module_id,
8888 budget = ?GOODBYE_BUDGET,
8889 "shutdown module GOODBYE was not written within its budget; closing anyway"
8890 ),
8891 }
8892 forwarding.request_connection_close(target.endpoint.connection_id, reason);
8893 });
8894 }
8895 while sends.join_next().await.is_some() {}
8897}
8898
8899fn send_module_goodbye(module_id: &str, forwarding: &ForwardingTable, target: &ModuleDrainTarget) {
8900 let Some(frame) = module_goodbye_frame(module_id, target.negotiated_ver) else {
8901 return;
8902 };
8903 if let Err(err) = target.sink.try_send(frame) {
8904 warn!(
8905 module_id,
8906 target_connection_id = target.endpoint.connection_id.get(),
8907 error = %err,
8908 "supervisor drain module GOODBYE was not delivered to peer; closing module connection"
8909 );
8910 forwarding.request_connection_close(
8911 target.endpoint.connection_id,
8912 CloseReason::new(
8913 "module_goodbye_delivery_failed",
8914 format!("failed to enqueue supervisor drain module GOODBYE for module '{module_id}': {err}"),
8915 ),
8916 );
8917 }
8918}
8919
8920#[derive(Clone, Copy)]
8921struct ForwardingDrainContext<'a> {
8922 spec: &'a ModuleSpec,
8923 runtime: &'a SupervisorRuntimeConfig,
8924 registry: &'a Registry,
8925 scope: DrainScope,
8926}
8927
8928#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8930enum DrainScope {
8931 Active,
8934 Endpoint(crate::ModuleEndpointId),
8939}
8940
8941#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8949enum StopNotice {
8950 SentOverConnection,
8953 NoConnection,
8957 NotSent,
8961}
8962
8963async fn begin_forwarding_drain(
8964 spec: &ModuleSpec,
8965 runtime: &SupervisorRuntimeConfig,
8966 registry: &Registry,
8967 snapshot: &SharedSnapshot,
8968 enabled: Option<bool>,
8969 reason: RouteCloseReason,
8970) -> Result<StopNotice, SuperviseError> {
8971 let Some(forwarding) = runtime.forwarding.as_ref() else {
8972 return Err(SuperviseError::ReloadUnavailable {
8973 module_id: spec.module_id.clone(),
8974 reason: "supervisor was not configured with a forwarding table".to_string(),
8975 });
8976 };
8977
8978 begin_forwarding_drain_with(
8979 forwarding,
8980 ForwardingDrainContext {
8981 spec,
8982 runtime,
8983 registry,
8984 scope: DrainScope::Active,
8985 },
8986 snapshot,
8987 enabled,
8988 reason,
8989 runtime.drain_timeout,
8990 )
8991 .await
8992}
8993
8994async fn begin_forwarding_drain_if_configured(
8995 spec: &ModuleSpec,
8996 runtime: &SupervisorRuntimeConfig,
8997 registry: &Registry,
8998 snapshot: &SharedSnapshot,
8999 enabled: Option<bool>,
9000 reason: RouteCloseReason,
9001) -> Result<StopNotice, SuperviseError> {
9002 begin_forwarding_drain_with_timeout(
9003 spec,
9004 runtime,
9005 registry,
9006 snapshot,
9007 enabled,
9008 reason,
9009 runtime.drain_timeout,
9010 )
9011 .await
9012}
9013
9014async fn begin_forwarding_drain_with_timeout(
9018 spec: &ModuleSpec,
9019 runtime: &SupervisorRuntimeConfig,
9020 registry: &Registry,
9021 snapshot: &SharedSnapshot,
9022 enabled: Option<bool>,
9023 reason: RouteCloseReason,
9024 drain_timeout: Duration,
9025) -> Result<StopNotice, SuperviseError> {
9026 let Some(forwarding) = runtime.forwarding.as_ref() else {
9027 return Ok(StopNotice::NotSent);
9028 };
9029
9030 begin_forwarding_drain_with(
9031 forwarding,
9032 ForwardingDrainContext {
9033 spec,
9034 runtime,
9035 registry,
9036 scope: DrainScope::Active,
9037 },
9038 snapshot,
9039 enabled,
9040 reason,
9041 drain_timeout,
9042 )
9043 .await
9044}
9045
9046async fn begin_forwarding_drain_with(
9047 forwarding: &ForwardingTable,
9048 context: ForwardingDrainContext<'_>,
9049 snapshot: &SharedSnapshot,
9050 enabled: Option<bool>,
9051 reason: RouteCloseReason,
9052 drain_timeout: Duration,
9053) -> Result<StopNotice, SuperviseError> {
9054 let ForwardingDrainContext {
9055 spec,
9056 runtime,
9057 registry,
9058 scope,
9059 } = context;
9060 debug_assert_ne!(reason, RouteCloseReason::Crash);
9061 let terminal = matches!(reason, RouteCloseReason::Disable);
9062 let drain_started_at = Instant::now();
9063 let drain_deadline = drain_started_at + drain_timeout;
9064 let deadline_ms =
9065 unix_ms_now().saturating_add(u64::try_from(drain_timeout.as_millis()).unwrap_or(u64::MAX));
9066 let busy_gauges = match scope {
9067 DrainScope::Active => declared_busy_gauges(registry, &spec.module_id)?,
9068 DrainScope::Endpoint(endpoint) => {
9069 declared_busy_gauges_for_connection(registry, endpoint.connection_id)?
9070 }
9071 };
9072
9073 let gate_started = Instant::now();
9076 let drain_target = match scope {
9077 DrainScope::Active => forwarding.begin_module_drain(&spec.module_id, reason),
9078 DrainScope::Endpoint(endpoint) => forwarding.begin_endpoint_drain(endpoint, reason),
9079 }
9080 .map_err(SuperviseError::Forwarding)?;
9081 info!(
9086 module_id = %spec.module_id,
9087 ?reason,
9088 gate_ms = u64::try_from(gate_started.elapsed().as_millis()).unwrap_or(u64::MAX),
9089 connected = drain_target.is_some(),
9090 "module drain began; route admission closed"
9091 );
9092 if scope == DrainScope::Active {
9093 update_snapshot(snapshot, Some(&spec.module_id), |state| {
9094 state.state = ModuleState::Draining;
9095 state.draining_to_replace =
9096 matches!(reason, RouteCloseReason::Restart | RouteCloseReason::Reload);
9097 if let Some(enabled) = enabled {
9098 state.enabled = enabled;
9099 }
9100 })?;
9101 }
9102
9103 let Some(target) = drain_target.as_ref() else {
9104 return Ok(StopNotice::NoConnection);
9108 };
9109 {
9110 send_module_draining(&spec.module_id, reason, deadline_ms, target);
9111 let routes = forwarding
9112 .endpoint_routes(target.endpoint)
9113 .map_err(SuperviseError::Forwarding)?;
9114 let routes_notified = routes.len();
9115 crate::control::send_route_control_pushes(
9116 forwarding,
9117 routes.clone(),
9118 ClientControlPush::RouteClosing {
9119 module_id: spec.module_id.clone(),
9120 channels: Vec::new(),
9121 reason,
9122 },
9123 );
9124 send_route_goodbyes(forwarding, target.abandoned_bindings.clone());
9125
9126 let wait_result = wait_for_forwarding_quiescence(
9132 forwarding,
9133 &spec.module_id,
9134 runtime,
9135 target.endpoint,
9136 drain_deadline,
9137 &busy_gauges,
9138 scope,
9139 )
9140 .await;
9141 let drained = drained_after_quiescence_wait(&wait_result);
9142 if let Err(err) = &wait_result {
9143 error!(
9144 module_id = %spec.module_id,
9145 ?reason,
9146 error = %err,
9147 "forwarding quiescence wait failed after route.closing; forcing route.closed(drained: false) so the client is not left waiting on an unfulfilled promise"
9148 );
9149 } else if !drained {
9150 let holdouts = forwarding
9156 .endpoint_drain_holdouts(target.endpoint)
9157 .unwrap_or_default();
9158 warn!(
9159 module_id = %spec.module_id,
9160 waited = ?drain_timeout,
9161 ?reason,
9162 held_requests = holdouts.requests,
9163 held_routes = holdouts.routes,
9164 total_routes = holdouts.total_routes,
9165 top_connections = ?holdouts.top_connections,
9166 held = %holdouts
9169 .held
9170 .iter()
9171 .map(|(channel, corr)| format!("{channel}:{corr}"))
9172 .collect::<Vec<_>>()
9173 .join(","),
9174 "route drain timed out before request quiescence; forcing teardown"
9175 );
9176 }
9177 crate::control::send_route_control_pushes(
9178 forwarding,
9179 routes,
9180 ClientControlPush::RouteClosed {
9181 module_id: spec.module_id.clone(),
9182 channels: Vec::new(),
9183 reason,
9184 drained,
9185 abandoned: target.abandoned_bindings.len() as u32,
9186 excluded_subscriptions: target.excluded_subscriptions,
9187 terminal: Some(terminal),
9188 },
9189 );
9190 wait_result?;
9191
9192 let released_routes = match forwarding.release_module_endpoint_routes(target.endpoint) {
9198 Ok(routes) => routes,
9199 Err(err) => {
9200 warn!(
9201 module_id = %spec.module_id,
9202 ?reason,
9203 error = %err,
9204 "failed to release module endpoint routes after route.closed; module GOODBYE will still be sent"
9205 );
9206 send_module_goodbye(&spec.module_id, forwarding, target);
9207 return Err(SuperviseError::Forwarding(err));
9208 }
9209 };
9210 let route_goodbye_count = released_routes.len();
9211 send_route_goodbyes(forwarding, released_routes);
9212 send_module_goodbye(&spec.module_id, forwarding, target);
9213
9214 info!(
9220 module_id = %spec.module_id,
9221 ?reason,
9222 routes_notified,
9223 route_goodbyes = route_goodbye_count,
9224 abandoned_reservations = target.abandoned_bindings.len(),
9225 excluded_subscriptions = target.excluded_subscriptions,
9226 drained,
9227 "module drain complete; consumers notified via route.closing/route.closed pushes and per-route GOODBYE frames"
9228 );
9229 }
9230
9231 Ok(StopNotice::SentOverConnection)
9232}
9233
9234async fn wait_for_registration_after_reload(
9237 registry: &Registry,
9238 module_id: &str,
9239 snapshot: &SharedSnapshot,
9240 child: &mut SupervisedChild,
9241 wait: Duration,
9242) -> Result<RegistrationWaitOutcome, SuperviseError> {
9243 wait_for_slot_registration(
9244 registry,
9245 crate::registry::RegistrationSlot::Active(module_id),
9246 module_id,
9247 snapshot,
9248 child,
9249 wait,
9250 )
9251 .await
9252}
9253
9254async fn wait_for_slot_registration(
9262 registry: &Registry,
9263 slot: crate::registry::RegistrationSlot<'_>,
9264 module_id: &str,
9265 snapshot: &SharedSnapshot,
9266 child: &mut SupervisedChild,
9267 wait: Duration,
9268) -> Result<RegistrationWaitOutcome, SuperviseError> {
9269 let deadline = Instant::now() + wait;
9270 loop {
9271 if registry
9272 .registration(slot)
9273 .map_err(SuperviseError::Registry)?
9274 .is_some()
9275 {
9276 return Ok(RegistrationWaitOutcome::Registered);
9277 }
9278
9279 let now = Instant::now();
9280 if now >= deadline {
9281 return Ok(RegistrationWaitOutcome::TimedOut);
9282 }
9283 let remaining = deadline.saturating_duration_since(now);
9284 let poll = remaining.min(REGISTRY_RELEASE_POLL);
9285
9286 tokio::select! {
9287 wait_result = child.wait() => {
9288 let status = wait_result.map_err(|source| SuperviseError::Wait {
9289 module_id: module_id.to_string(),
9290 source,
9291 })?;
9292 return Ok(RegistrationWaitOutcome::Exited(classify_reaped_child_exit(
9293 snapshot,
9294 child,
9295 &status,
9296 )));
9297 }
9298 _ = sleep(poll) => {}
9299 }
9300 }
9301}
9302
9303fn registration_failure_exit_report(mut exit_report: ExitReport) -> ExitReport {
9304 if exit_report.kind != ExitKind::DeliberateSeverance {
9307 exit_report.kind = ExitKind::Crash;
9308 }
9309 exit_report
9310}
9311
9312async fn handle_reload_child_registration_failure(
9313 spec: &ModuleSpec,
9314 runtime: &SupervisorRuntimeConfig,
9315 registry: &Registry,
9316 process_liveness: &SupervisorProcessLiveness,
9317 snapshot: &SharedSnapshot,
9318 _child: &mut Option<SupervisedChild>,
9319 failure: ReloadRegistrationFailure,
9320) -> Result<(), SuperviseError> {
9321 let ReloadRegistrationFailure {
9322 exit_report,
9323 reason,
9324 } = failure;
9325 match on_child_exit(
9326 spec,
9327 runtime.restart_policy,
9328 registry,
9329 snapshot,
9330 &runtime.terminal_ring,
9331 &runtime.spawn_events,
9332 &runtime.child_roster,
9333 exit_report,
9334 )
9335 .await
9336 {
9337 NextAction::Stop {
9338 registration_released,
9339 } => {
9340 if registration_released {
9341 process_liveness.untrack_if_current(&spec.module_id, snapshot);
9342 }
9343 }
9344 NextAction::Restart { schedule } => {
9345 let delay = schedule.map_or(runtime.restart_policy.delay_for_restart(0), |schedule| {
9346 schedule.delay
9347 });
9348 if let Some(schedule) = schedule {
9349 log_crash_respawn(&spec.module_id, schedule);
9350 }
9351 schedule_respawn(
9352 runtime,
9353 snapshot,
9354 &spec.module_id,
9355 delay,
9356 RespawnKind::Spawn,
9357 )?;
9358 }
9359 }
9360 Err(SuperviseError::ReloadFailed {
9361 module_id: spec.module_id.clone(),
9362 reason,
9363 })
9364}
9365
9366async fn handle_reload_spawn_failure(
9367 spec: &ModuleSpec,
9368 runtime: &SupervisorRuntimeConfig,
9369 process_liveness: &SupervisorProcessLiveness,
9370 snapshot: &SharedSnapshot,
9371 _child: &mut Option<SupervisedChild>,
9372 reason: String,
9373) -> Result<(), SuperviseError> {
9374 let now = Instant::now();
9375 let mut schedule = None;
9376 update_snapshot(snapshot, Some(&spec.module_id), |state| {
9377 clear_current_process_facts(state);
9378 if state.enabled {
9379 schedule = state.next_crash_restart(&runtime.restart_policy, now);
9380 state.state = if schedule.is_some() {
9381 ModuleState::Restarting
9382 } else {
9383 ModuleState::Failed
9384 };
9385 } else {
9386 state.state = ModuleState::Disabled;
9387 }
9388 })?;
9389 if let Some(schedule) = schedule {
9390 schedule_respawn(
9391 runtime,
9392 snapshot,
9393 &spec.module_id,
9394 schedule.delay,
9395 RespawnKind::Spawn,
9396 )?;
9397 } else {
9398 process_liveness.untrack_if_current(&spec.module_id, snapshot);
9399 }
9400 Err(SuperviseError::ReloadFailed {
9401 module_id: spec.module_id.clone(),
9402 reason,
9403 })
9404}
9405
9406fn control_flags() -> Flags {
9407 Flags::new(false, Priority::Passive, false)
9408}
9409
9410#[allow(clippy::too_many_arguments)]
9411async fn drain_optional_child(
9412 module_id: &str,
9413 protocol: ModuleProtocol,
9414 stop_notice: StopNotice,
9415 registry: &Registry,
9416 forwarding: Option<&ForwardingTable>,
9417 snapshot: &SharedSnapshot,
9418 terminal_ring: &Arc<Mutex<TerminalRing>>,
9419 spawn_events: &SpawnEventFeed,
9420 child: &mut Option<SupervisedChild>,
9421 drain_timeout: Duration,
9422 final_state: ModuleState,
9423 enabled: Option<bool>,
9424) -> Result<(), SuperviseError> {
9425 if let Some(child) = child.take() {
9426 drain_child_to_state(
9427 module_id,
9428 protocol,
9429 stop_notice,
9430 registry,
9431 forwarding,
9432 snapshot,
9433 terminal_ring,
9434 spawn_events,
9435 child,
9436 drain_timeout,
9437 final_state,
9438 enabled,
9439 )
9440 .await
9441 } else {
9442 update_snapshot(snapshot, Some(module_id), |state| {
9443 state.state = final_state;
9444 if let Some(enabled) = enabled {
9445 state.enabled = enabled;
9446 }
9447 clear_current_process_facts(state);
9448 })?;
9449 release_dead_registration(registry, forwarding, snapshot, module_id).await
9450 }
9451}
9452
9453#[allow(clippy::too_many_arguments)]
9454async fn drain_child_to_state(
9455 module_id: &str,
9456 _protocol: ModuleProtocol,
9457 stop_notice: StopNotice,
9458 registry: &Registry,
9459 forwarding: Option<&ForwardingTable>,
9460 snapshot: &SharedSnapshot,
9461 terminal_ring: &Arc<Mutex<TerminalRing>>,
9462 spawn_events: &SpawnEventFeed,
9463 mut child: SupervisedChild,
9464 drain_timeout: Duration,
9465 final_state: ModuleState,
9466 enabled: Option<bool>,
9467) -> Result<(), SuperviseError> {
9468 let protocol = child.protocol;
9469 update_snapshot(snapshot, Some(module_id), |state| {
9470 state.state = ModuleState::Draining;
9471 state.draining_to_replace = final_state == ModuleState::Restarting;
9472 if let Some(enabled) = enabled {
9473 state.enabled = enabled;
9474 }
9475 })?;
9476
9477 if stop_notice != StopNotice::SentOverConnection {
9488 if protocol == ModuleProtocol::Subc && stop_notice == StopNotice::NoConnection {
9489 info!(
9490 module_id,
9491 pid = child.pid,
9492 budget_ms = u64::try_from(drain_timeout.as_millis()).unwrap_or(u64::MAX),
9493 "module has no connection yet; requesting stop by signal"
9494 );
9495 }
9496 request_graceful_stop(module_id, &child);
9497 }
9498
9499 let exit_report = match timeout(drain_timeout, child.wait()).await {
9500 Ok(Ok(status)) => classify_reaped_child_exit(snapshot, &child, &status),
9501 Ok(Err(source)) => {
9502 fail_after_wait_error(
9503 module_id,
9504 snapshot,
9505 terminal_ring,
9506 spawn_events,
9507 format!("failed to wait during drain: {source}"),
9508 );
9509 return Err(SuperviseError::Wait {
9510 module_id: module_id.to_string(),
9511 source,
9512 });
9513 }
9514 Err(_) => {
9515 warn!(
9528 module_id,
9529 pid = child.pid,
9530 budget_ms = u64::try_from(drain_timeout.as_millis()).unwrap_or(u64::MAX),
9531 reason = ?final_state,
9532 ?stop_notice,
9533 "drain budget expired before the module exited; killing it"
9534 );
9535 child.start_kill().map_err(|source| {
9536 fail_after_wait_error(
9537 module_id,
9538 snapshot,
9539 terminal_ring,
9540 spawn_events,
9541 format!("failed to kill during drain: {source}"),
9542 );
9543 SuperviseError::Kill {
9544 module_id: module_id.to_string(),
9545 source,
9546 }
9547 })?;
9548 let status = child.wait().await.map_err(|source| {
9549 fail_after_wait_error(
9550 module_id,
9551 snapshot,
9552 terminal_ring,
9553 spawn_events,
9554 format!("failed to wait after drain kill: {source}"),
9555 );
9556 SuperviseError::Wait {
9557 module_id: module_id.to_string(),
9558 source,
9559 }
9560 })?;
9561 classify_reaped_child_exit(snapshot, &child, &status)
9562 }
9563 };
9564
9565 let detail = lock_snapshot(snapshot)?.drain_disposition_detail.take();
9566 publish_after_terminal_record(
9567 module_id,
9568 || {
9569 record_terminal_with_detail(
9570 module_id,
9571 terminal_ring,
9572 spawn_events,
9573 &exit_report,
9574 terminal_disposition(final_state),
9575 detail,
9576 )
9577 },
9578 || {
9579 update_snapshot(snapshot, Some(module_id), |state| {
9580 state.state = final_state;
9581 if let Some(enabled) = enabled {
9582 state.enabled = enabled;
9583 }
9584 clear_current_process_facts(state);
9585 state.last_exit = Some(exit_report.clone());
9586 if exit_report.kind == ExitKind::DeliberateSeverance {
9587 state.lifetime_restarts += 1;
9588 }
9589 })
9590 },
9591 )?;
9592 child.drain_stderr(module_id).await;
9593
9594 release_dead_registration(registry, forwarding, snapshot, module_id).await
9595}
9596
9597#[cfg(unix)]
9617fn request_graceful_stop(module_id: &str, child: &SupervisedChild) {
9618 let Some(pid) = child
9619 .id()
9620 .and_then(|pid| i32::try_from(pid).ok())
9621 .and_then(rustix::process::Pid::from_raw)
9622 else {
9623 debug!(
9624 module_id,
9625 "no pid to signal for teardown; falling through to the drain wait"
9626 );
9627 return;
9628 };
9629 match rustix::process::kill_process(pid, rustix::process::Signal::TERM) {
9630 Ok(()) => debug!(
9631 module_id,
9632 "sent SIGTERM to a module nothing else asked to stop"
9633 ),
9634 Err(err) => debug!(
9635 module_id,
9636 error = %err,
9637 "SIGTERM to module failed; the drain wait and kill still apply"
9638 ),
9639 }
9640}
9641
9642#[cfg(not(unix))]
9650fn request_graceful_stop(module_id: &str, _child: &SupervisedChild) {
9651 debug!(
9652 module_id,
9653 "no graceful stop signal exists on this platform; teardown of a module nothing asked to stop waits, then kills"
9654 );
9655}
9656
9657fn terminal_disposition(final_state: ModuleState) -> TerminalDisposition {
9658 match final_state {
9659 ModuleState::Stopped => TerminalDisposition::Stopped,
9660 ModuleState::Disabled => TerminalDisposition::Disabled,
9661 ModuleState::Restarting => TerminalDisposition::Restarting,
9662 ModuleState::Failed => TerminalDisposition::Failed,
9663 ModuleState::Starting
9664 | ModuleState::Running
9665 | ModuleState::Unresponsive
9666 | ModuleState::Draining => {
9667 unreachable!("terminal exits only finish in terminal or restarting states")
9668 }
9669 }
9670}
9671
9672async fn release_dead_registration(
9680 registry: &Registry,
9681 forwarding: Option<&ForwardingTable>,
9682 snapshot: &SharedSnapshot,
9683 module_id: &str,
9684) -> Result<(), SuperviseError> {
9685 let result = async {
9686 let registration = registry
9687 .get_module(module_id)
9688 .map_err(SuperviseError::Registry)?;
9689 match wait_for_registration_release(registry, module_id, REGISTRY_RELEASE_TIMEOUT).await {
9690 Ok(()) => return Ok(()),
9691 Err(SuperviseError::RegistrationStillActive { .. }) => {}
9692 Err(err) => return Err(err),
9693 }
9694 let pid = lock_snapshot(snapshot)?.reaped_pid;
9695 if let (Some(registration), Some(forwarding), Some(pid)) = (registration, forwarding, pid) {
9696 warn!(
9697 module_id,
9698 pid,
9699 connection_id = registration.connection_id.get(),
9700 "reaped module registration outlived release grace; closing dead connection"
9701 );
9702 forwarding.request_connection_close(
9703 registration.connection_id,
9704 CloseReason::new(
9705 "supervised_process_reaped",
9706 format!("module '{module_id}' pid {pid} exited"),
9707 ),
9708 );
9709 wait_for_slot_registration_release(
9710 registry,
9711 crate::registry::RegistrationSlot::Connection(registration.connection_id),
9712 REGISTRY_RELEASE_TIMEOUT,
9713 )
9714 .await?;
9715 }
9716 wait_for_registration_release(registry, module_id, Duration::ZERO).await
9717 }
9718 .await;
9719 if let Err(err) = &result {
9720 fail_snapshot(snapshot, Some(module_id), None);
9721 error!(module_id, error = %err, "registration release failed after child exit; module is failed and start can retry");
9722 }
9723 result
9724}
9725
9726async fn wait_for_registration_release(
9729 registry: &Registry,
9730 module_id: &str,
9731 wait: Duration,
9732) -> Result<(), SuperviseError> {
9733 wait_for_slot_registration_release(
9734 registry,
9735 crate::registry::RegistrationSlot::Active(module_id),
9736 wait,
9737 )
9738 .await
9739}
9740
9741async fn wait_for_slot_registration_release(
9749 registry: &Registry,
9750 slot: crate::registry::RegistrationSlot<'_>,
9751 wait: Duration,
9752) -> Result<(), SuperviseError> {
9753 let deadline = Instant::now() + wait;
9754 let mut release_events = registration_release_events().subscribe();
9755 let still_active = |registration: &crate::registry::ModuleRegistration| {
9756 SuperviseError::RegistrationStillActive {
9757 module_id: registration.manifest.module_id.clone(),
9758 waited: wait,
9759 }
9760 };
9761 loop {
9762 let _observed_generation = *release_events.borrow_and_update();
9763 let Some(registration) = registry
9764 .registration(slot)
9765 .map_err(SuperviseError::Registry)?
9766 else {
9767 return Ok(());
9768 };
9769
9770 let now = Instant::now();
9771 if now >= deadline {
9772 return Err(still_active(®istration));
9773 }
9774
9775 let remaining = deadline.saturating_duration_since(now);
9776 match timeout(remaining, release_events.changed()).await {
9777 Ok(Ok(())) | Ok(Err(_)) => {}
9778 Err(_) => return Err(still_active(®istration)),
9779 }
9780 }
9781}
9782
9783#[cfg(test)]
9784mod slot_registration_wait_tests {
9785 use super::*;
9786 use crate::registry::{ConnectionId, RegistrationSlot};
9787 use subc_protocol::manifest::ModuleManifest;
9788
9789 #[tokio::test]
9790 async fn enable_release_failure_is_failed_and_a_second_enable_retries() {
9791 let registry = Arc::new(Registry::default());
9792 let supervisor = Supervisor::new_for_test(Arc::clone(®istry), RestartPolicy::default());
9793 let runtime = supervisor.runtime_config();
9794 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::disabled()));
9795 let spec = ModuleSpec {
9796 module_id: "enable-stale-registration".to_string(),
9797 program: PathBuf::from("/missing/enable-retry-test"),
9798 args: Vec::new(),
9799 env: Vec::new(),
9800 reserved: false,
9801 reserved_prefixes: Vec::new(),
9802 protocol: ModuleProtocol::Subc,
9803 overlap: Default::default(),
9804 };
9805 let connection = ConnectionId::new(90);
9806 registry
9807 .register_with_control_ops(
9808 ModuleManifest::builder(&spec.module_id, "0.1.0").build(),
9809 1,
9810 connection,
9811 Vec::new(),
9812 )
9813 .unwrap();
9814 let mut child = None;
9815 let err = set_child_enabled(
9816 &spec,
9817 &runtime,
9818 ®istry,
9819 &supervisor.process_liveness,
9820 &snapshot,
9821 &mut child,
9822 true,
9823 )
9824 .await
9825 .unwrap_err();
9826 assert!(matches!(
9827 err,
9828 SuperviseError::RegistrationStillActive { .. }
9829 ));
9830 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
9831 assert!(child.is_none());
9832 registry.deregister_connection(connection).unwrap();
9833 let err = set_child_enabled(
9834 &spec,
9835 &runtime,
9836 ®istry,
9837 &supervisor.process_liveness,
9838 &snapshot,
9839 &mut child,
9840 true,
9841 )
9842 .await
9843 .unwrap_err();
9844 assert!(
9845 matches!(err, SuperviseError::Spawn { .. }),
9846 "second enable must attempt a spawn: {err}"
9847 );
9848 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
9849 }
9850
9851 const INCUMBENT: u64 = 1;
9852 const CANDIDATE: u64 = 2;
9853
9854 fn swapped_registry() -> Arc<Registry> {
9855 let registry = Arc::new(Registry::default());
9856 let manifest = ModuleManifest::builder("m", "0.1.0").build();
9857 registry
9858 .register_with_control_ops(
9859 manifest.clone(),
9860 1,
9861 ConnectionId::new(INCUMBENT),
9862 Vec::new(),
9863 )
9864 .unwrap();
9865 registry
9866 .register_candidate_with_control_ops(
9867 manifest,
9868 1,
9869 ConnectionId::new(CANDIDATE),
9870 Vec::new(),
9871 )
9872 .unwrap();
9873 registry
9874 }
9875
9876 #[tokio::test]
9880 async fn incumbent_release_is_awaited_by_connection_not_by_module_id() {
9881 let registry = swapped_registry();
9882 registry.promote_candidate("m").unwrap().unwrap();
9883
9884 assert!(matches!(
9885 wait_for_registration_release(®istry, "m", Duration::from_millis(50)).await,
9886 Err(SuperviseError::RegistrationStillActive { .. })
9887 ));
9888
9889 assert!(matches!(
9891 wait_for_slot_registration_release(
9892 ®istry,
9893 RegistrationSlot::Connection(ConnectionId::new(INCUMBENT)),
9894 Duration::from_millis(50),
9895 )
9896 .await,
9897 Err(SuperviseError::RegistrationStillActive { .. })
9898 ));
9899
9900 let releaser = Arc::clone(®istry);
9901 let release = tokio::spawn(async move {
9902 sleep(Duration::from_millis(20)).await;
9903 releaser
9904 .deregister_connection(ConnectionId::new(INCUMBENT))
9905 .unwrap();
9906 notify_registration_release();
9907 });
9908 wait_for_slot_registration_release(
9909 ®istry,
9910 RegistrationSlot::Connection(ConnectionId::new(INCUMBENT)),
9911 Duration::from_secs(5),
9912 )
9913 .await
9914 .expect("the incumbent's own registration is released");
9915 release.await.unwrap();
9916 assert!(registry.get_module("m").unwrap().is_some());
9917 }
9918
9919 #[tokio::test]
9922 async fn candidate_slot_wait_ignores_the_incumbents_registration() {
9923 let registry = swapped_registry();
9924 assert!(matches!(
9925 wait_for_slot_registration_release(
9926 ®istry,
9927 RegistrationSlot::Candidate("m"),
9928 Duration::from_millis(50),
9929 )
9930 .await,
9931 Err(SuperviseError::RegistrationStillActive { .. })
9932 ));
9933 registry
9934 .deregister_connection(ConnectionId::new(CANDIDATE))
9935 .unwrap();
9936 wait_for_slot_registration_release(
9937 ®istry,
9938 RegistrationSlot::Candidate("m"),
9939 Duration::from_millis(50),
9940 )
9941 .await
9942 .expect("a candidate slot with no candidate is released");
9943 assert!(registry
9944 .registration(RegistrationSlot::Active("m"))
9945 .unwrap()
9946 .is_some());
9947 }
9948}
9949
9950fn classify_exit(status: &ExitStatus) -> ExitReport {
9951 ExitReport {
9952 kind: if status.success() {
9953 ExitKind::Clean
9954 } else {
9955 ExitKind::Crash
9956 },
9957 code: status.code(),
9958 signal: exit_signal(status),
9959 at_ms: unix_ms_now(),
9960 }
9961}
9962
9963fn wait_error_exit_report() -> ExitReport {
9969 ExitReport {
9970 kind: ExitKind::Crash,
9971 code: None,
9972 signal: None,
9973 at_ms: unix_ms_now(),
9974 }
9975}
9976
9977#[cfg(unix)]
9978fn exit_signal(status: &ExitStatus) -> Option<i32> {
9979 use std::os::unix::process::ExitStatusExt;
9980
9981 status.signal()
9982}
9983
9984#[cfg(not(unix))]
9985fn exit_signal(_status: &ExitStatus) -> Option<i32> {
9986 None
9987}
9988
9989fn reset_restart_count(snapshot: &SharedSnapshot, module_id: &str) -> Result<(), SuperviseError> {
9995 update_snapshot(snapshot, Some(module_id), |state| {
9996 state.clear_crash_restarts();
9997 })
9998}
9999
10000fn set_running(
10001 snapshot: &SharedSnapshot,
10002 child: &SupervisedChild,
10003 module_id: &str,
10004 spawn_events: &SpawnEventFeed,
10005) -> Result<(), SuperviseError> {
10006 let mut state = snapshot.lock().map_err(|_| SuperviseError::StatePoisoned {
10007 module_id: Some(module_id.to_string()),
10008 })?;
10009 state.spawn_generation = spawn_events.emit_spawned(module_id, child.pid, child.spawned_at_ms);
10010 if std::mem::take(&mut state.coalesced_restart_pending) {
10011 let generation = state.spawn_generation;
10012 info!(module_id, "restart already satisfied by generation {generation}; coalesced pending request completed");
10013 }
10014 state.drain_disposition_detail = None;
10015 state.spawn_failure = None;
10016 state.waiting_for_previous_pid = None;
10017 state.in_alternate_slot = false;
10020 state.configuration_updated_since_spawn = false;
10021 state.spawned_protocol = Some(child.protocol);
10022 state.state = ModuleState::Running;
10023 state.enabled = true;
10024 state.process_alive = true;
10025 state.pid = child.id();
10026 #[cfg(windows)]
10027 {
10028 state.pipe_consumed = child
10029 .nonce_delivery
10030 .as_ref()
10031 .map(|delivery| delivery.consumed());
10032 }
10033 #[cfg(target_os = "macos")]
10034 {
10035 state.report_ready = Some(Arc::clone(&child.report_ready));
10036 }
10037 state.spawned_at_ms = Some(child.spawned_at_ms);
10038 state.spawned_from = Some(child.spawned_from.clone());
10039 state.spawned_file_identity = child.spawned_file_identity.clone();
10040 state.process_start_time = child.process_start_time;
10041 Ok(())
10042}
10043
10044fn clear_current_process_facts(state: &mut SupervisorSnapshot) {
10045 state.waiting_for_previous_pid = None;
10046 state.process_alive = false;
10047 state.spawned_protocol = None;
10048 state.pid = None;
10049 #[cfg(windows)]
10050 {
10051 state.pipe_consumed = None;
10052 }
10053 #[cfg(target_os = "macos")]
10054 {
10055 state.report_ready = None;
10056 }
10057 state.spawned_at_ms = None;
10058 state.spawned_from = None;
10059 state.spawned_file_identity = None;
10060 state.process_start_time = None;
10061 state.deliberate_severance = None;
10062}
10063
10064#[cfg(test)]
10065fn record_deliberate_severance(
10066 snapshot: &SharedSnapshot,
10067 identity: ProcessIdentity,
10068) -> Result<(), SuperviseError> {
10069 update_snapshot(snapshot, None, |state| {
10070 state.deliberate_severance = Some(identity);
10071 })
10072}
10073
10074fn apply_deliberate_severance_marker(
10075 snapshot: &SharedSnapshot,
10076 exited_identity: Option<ProcessIdentity>,
10077 mut exit_report: ExitReport,
10078) -> ExitReport {
10079 let marker = lock_snapshot(snapshot)
10080 .ok()
10081 .and_then(|mut state| state.deliberate_severance.take());
10082 if marker.is_some() && marker == exited_identity {
10083 exit_report.kind = ExitKind::DeliberateSeverance;
10084 }
10085 exit_report
10086}
10087
10088fn classify_reaped_child_exit(
10089 snapshot: &SharedSnapshot,
10090 child: &SupervisedChild,
10091 status: &ExitStatus,
10092) -> ExitReport {
10093 let _ = update_snapshot(snapshot, None, |state| {
10094 state.reaped_pid = Some(child.pid);
10095 state.spawn_failure = child.spawn_failure.clone();
10096 });
10097 apply_deliberate_severance_marker(snapshot, child.process_identity(), classify_exit(status))
10098}
10099
10100fn fail_snapshot(
10101 snapshot: &SharedSnapshot,
10102 module_id: Option<&str>,
10103 last_exit: Option<ExitReport>,
10104) {
10105 if let Err(err) = update_snapshot(snapshot, module_id, |state| {
10106 state.state = ModuleState::Failed;
10107 clear_current_process_facts(state);
10108 if let Some(last_exit) = last_exit {
10109 state.last_exit = Some(last_exit);
10110 }
10111 }) {
10112 error!(error = %err, "failed to mark supervisor state failed");
10113 }
10114}
10115
10116fn update_snapshot(
10117 snapshot: &SharedSnapshot,
10118 module_id: Option<&str>,
10119 update: impl FnOnce(&mut SupervisorSnapshot),
10120) -> Result<(), SuperviseError> {
10121 let mut state = snapshot.lock().map_err(|_| SuperviseError::StatePoisoned {
10122 module_id: module_id.map(ToOwned::to_owned),
10123 })?;
10124 update(&mut state);
10125 Ok(())
10126}
10127
10128const SLOW_SNAPSHOT_LOCK_THRESHOLD: Duration = Duration::from_millis(250);
10129
10130fn lock_snapshot_for_control<'a>(
10131 snapshot: &'a SharedSnapshot,
10132 module_id: &str,
10133 caller: &'static str,
10134) -> Result<std::sync::MutexGuard<'a, SupervisorSnapshot>, SuperviseError> {
10135 let started_at = Instant::now();
10136 let guard = lock_snapshot(snapshot)?;
10137 let waited = started_at.elapsed();
10138 if waited >= SLOW_SNAPSHOT_LOCK_THRESHOLD {
10139 warn!(
10140 module_id = %module_id,
10141 waited_ms = waited.as_millis() as u64,
10142 caller = %caller,
10143 "slow snapshot lock"
10144 );
10145 }
10146 Ok(guard)
10147}
10148
10149fn lock_snapshot(
10150 snapshot: &SharedSnapshot,
10151) -> Result<std::sync::MutexGuard<'_, SupervisorSnapshot>, SuperviseError> {
10152 snapshot
10153 .lock()
10154 .map_err(|_| SuperviseError::StatePoisoned { module_id: None })
10155}
10156
10157#[cfg(test)]
10158mod terminal_history_tests {
10159 use std::{
10160 path::PathBuf,
10161 sync::Arc,
10162 time::{Duration, Instant},
10163 };
10164
10165 use tokio::{sync::mpsc, time::sleep};
10166
10167 use super::{
10168 apply_deliberate_severance_marker, daemon_will_restart, drain_child_to_state,
10169 drained_after_quiescence_wait, handle_reload_spawn_failure, health_restart_child,
10170 lock_snapshot, on_child_exit, record_deliberate_severance, record_wait_error_terminal,
10171 reset_restart_count, spawn_and_mark_running, update_snapshot, wait_error_exit_report,
10172 ExitKind, ExitReport, ModuleProtocol, ModuleSpec, ModuleState, NextAction, ProcessIdentity,
10173 RestartPolicy, SpawnEventKind, StopNotice, SuperviseError, SupervisedConfiguration,
10174 SupervisedModule, SupervisedModuleInner, Supervisor, SupervisorHandle,
10175 SupervisorHealthStatus, SupervisorSnapshot,
10176 };
10177 use super::Instant as ClockInstant;
10182 use crate::{
10183 registry::Registry,
10184 terminal_ring::{TerminalRing, TerminalRingConfig},
10185 };
10186 use std::sync::Mutex;
10187 use subc_control::TerminalDisposition;
10188
10189 pub(super) fn fake_aft_stub_path() -> PathBuf {
10194 let mut path = std::env::current_exe().expect("current_exe available in tests");
10195 path.pop();
10196 path.pop();
10197 path.push(if cfg!(windows) {
10198 "fake-aft-stub.exe"
10199 } else {
10200 "fake-aft-stub"
10201 });
10202 assert!(
10203 path.exists(),
10204 "fake-aft-stub not built at {}: run `cargo test -p subc-core` (which builds \
10205 [[bin]] targets) rather than `cargo test -p subc-core --lib` (which does not)",
10206 path.display()
10207 );
10208 path
10209 }
10210
10211 #[test]
10212 fn reserved_never_spawned_refuses_every_hello() {
10213 let supervisor = SupervisorHandle::default();
10218 supervisor.apply_identity_configuration(&ModuleSpec {
10219 module_id: "never-spawned".to_string(),
10220 program: PathBuf::from("/usr/bin/false"),
10221 args: Vec::new(),
10222 env: Vec::new(),
10223 reserved: true,
10224 reserved_prefixes: Vec::new(),
10225 protocol: ModuleProtocol::Subc,
10226 overlap: Default::default(),
10227 });
10228 assert!(
10229 supervisor
10230 .reserved_hello_rejection("never-spawned", Some("any-forged-nonce"))
10231 .is_some(),
10232 "forged nonce must refuse on a reserved never-spawned id"
10233 );
10234 assert!(
10235 supervisor
10236 .reserved_hello_rejection("never-spawned", None)
10237 .is_some(),
10238 "absent nonce must refuse on a reserved never-spawned id"
10239 );
10240 supervisor.set_spawn_nonce("never-spawned", "minted".to_string());
10242 supervisor.apply_identity_configuration(&ModuleSpec {
10243 module_id: "never-spawned".to_string(),
10244 program: PathBuf::from("/usr/bin/false"),
10245 args: Vec::new(),
10246 env: Vec::new(),
10247 reserved: true,
10248 reserved_prefixes: Vec::new(),
10249 protocol: ModuleProtocol::Subc,
10250 overlap: Default::default(),
10251 });
10252 assert!(supervisor
10253 .reserved_hello_rejection("never-spawned", Some("minted"))
10254 .is_none());
10255 assert!(supervisor
10256 .reserved_hello_rejection("never-spawned", Some("forged"))
10257 .is_some());
10258 }
10259
10260 fn seed_crash_restarts(state: &mut SupervisorSnapshot, count: u32) {
10263 let now = ClockInstant::now();
10264 for _ in 0..count {
10265 state.crash_restarts.push_back(now);
10266 }
10267 }
10268
10269 fn age_oldest_crash_restart_out_of_window(state: &mut SupervisorSnapshot, window: Duration) {
10273 let aged = state
10274 .crash_restarts
10275 .front()
10276 .expect("a crash restart must be recorded before it can be aged")
10277 .checked_sub(window + Duration::from_secs(1))
10278 .expect("the test clock is far enough from its origin to age an instant");
10279 state.crash_restarts[0] = aged;
10280 }
10281
10282 fn snapshot_with_restarts(enabled: bool, count: u32) -> SupervisorSnapshot {
10283 let mut state = SupervisorSnapshot::new(ModuleState::Running, enabled);
10284 seed_crash_restarts(&mut state, count);
10285 state
10286 }
10287
10288 #[test]
10289 fn daemon_owned_recovery_predicate_uses_the_pre_increment_budget() {
10290 let policy = RestartPolicy::new(3, Duration::ZERO);
10291 let now = ClockInstant::now();
10292 assert!(daemon_will_restart(
10293 &mut snapshot_with_restarts(true, 2),
10294 &policy,
10295 now
10296 ));
10297 assert!(!daemon_will_restart(
10298 &mut snapshot_with_restarts(true, 3),
10299 &policy,
10300 now
10301 ));
10302 assert!(!daemon_will_restart(
10303 &mut snapshot_with_restarts(false, 0),
10304 &policy,
10305 now
10306 ));
10307 }
10308
10309 #[test]
10310 fn crash_restart_backoff_escalates_with_in_window_count() {
10311 let policy = RestartPolicy::new(4, Duration::from_millis(100))
10312 .with_max_backoff(Duration::from_secs(30));
10313 let now = ClockInstant::now();
10314 let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
10315 let schedules = (0..4)
10316 .map(|_| {
10317 state
10318 .next_crash_restart(&policy, now)
10319 .expect("the test policy allows four crash restarts")
10320 })
10321 .collect::<Vec<_>>();
10322
10323 assert_eq!(
10324 schedules
10325 .iter()
10326 .map(|schedule| schedule.restart_in_window)
10327 .collect::<Vec<_>>(),
10328 vec![0, 1, 2, 3]
10329 );
10330 assert_eq!(
10331 schedules
10332 .iter()
10333 .map(|schedule| schedule.delay)
10334 .collect::<Vec<_>>(),
10335 vec![
10336 Duration::from_millis(100),
10337 Duration::from_secs(1),
10338 Duration::from_secs(10),
10339 Duration::from_secs(30),
10340 ]
10341 );
10342 }
10343
10344 #[test]
10345 fn crash_restart_backoff_resets_after_ring_clear() {
10346 let policy = RestartPolicy::new(3, Duration::from_millis(100));
10347 let now = ClockInstant::now();
10348 let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
10349 assert_eq!(
10350 state.next_crash_restart(&policy, now).unwrap().delay,
10351 Duration::from_millis(100)
10352 );
10353 assert_eq!(
10354 state.next_crash_restart(&policy, now).unwrap().delay,
10355 Duration::from_secs(1)
10356 );
10357
10358 state.clear_crash_restarts();
10359 let schedule = state
10360 .next_crash_restart(&policy, now)
10361 .expect("a cleared ring must allow another restart");
10362 assert_eq!(schedule.restart_in_window, 0);
10363 assert_eq!(schedule.delay, Duration::from_millis(100));
10364 }
10365
10366 #[test]
10367 fn crash_restart_backoff_ignores_aged_restarts() {
10368 let policy = RestartPolicy::new(3, Duration::from_millis(100));
10369 let now = ClockInstant::now();
10370 let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
10371 state
10372 .next_crash_restart(&policy, now)
10373 .expect("the first restart is allowed");
10374 state
10375 .next_crash_restart(&policy, now)
10376 .expect("the second restart is allowed");
10377 state.crash_restarts[0] = now
10378 .checked_sub(policy.window + Duration::from_secs(1))
10379 .expect("the fake clock can age a restart past the window");
10380
10381 let schedule = state
10382 .next_crash_restart(&policy, now)
10383 .expect("an aged restart must release its slot");
10384 assert_eq!(schedule.restart_in_window, 1);
10385 assert_eq!(schedule.delay, Duration::from_secs(1));
10386 assert_eq!(state.crash_restarts.len(), 2);
10387 }
10388
10389 #[test]
10393 fn a_budget_spent_before_the_window_no_longer_refuses() {
10394 let policy = RestartPolicy::new(3, Duration::ZERO);
10395 let mut state = snapshot_with_restarts(true, 3);
10396 let now = ClockInstant::now();
10397 assert!(!daemon_will_restart(&mut state, &policy, now));
10398
10399 assert!(daemon_will_restart(
10400 &mut state,
10401 &policy,
10402 now + policy.window + Duration::from_secs(1)
10403 ));
10404 assert!(
10405 state.crash_restarts.is_empty(),
10406 "reading the budget must drop the instants that left the window"
10407 );
10408 }
10409
10410 fn module_with_recovery_snapshot(
10411 state: ModuleState,
10412 enabled: bool,
10413 restart_count: u32,
10414 ) -> SupervisedModule {
10415 let registry = Arc::new(Registry::default());
10416 let supervisor =
10417 Supervisor::new_for_test(Arc::clone(®istry), RestartPolicy::new(3, Duration::ZERO));
10418 let runtime = supervisor.runtime_config();
10419 let spec = ModuleSpec {
10420 module_id: "recovery-snapshot".to_string(),
10421 program: fake_aft_stub_path(),
10422 args: Vec::new(),
10423 env: Vec::new(),
10424 reserved: false,
10425 reserved_prefixes: Vec::new(),
10426 protocol: ModuleProtocol::Subc,
10427 overlap: Default::default(),
10428 };
10429 let mut snapshot = SupervisorSnapshot::new(state, enabled);
10430 seed_crash_restarts(&mut snapshot, restart_count);
10431 let (commands, _rx) = mpsc::channel(4);
10434 SupervisedModule {
10435 inner: Arc::new(SupervisedModuleInner {
10436 module_id: spec.module_id.clone(),
10437 registry,
10438 snapshot: Arc::new(Mutex::new(snapshot)),
10439 configuration: Arc::new(Mutex::new(SupervisedConfiguration {
10440 spec,
10441 health: runtime.health,
10442 })),
10443 stderr_ring: runtime.stderr_ring,
10444 terminal_ring: runtime.terminal_ring,
10445 commands,
10446 monitor: Mutex::new(None),
10447 restart_policy: runtime.restart_policy,
10448 effective_drain_timeout: runtime.effective_drain_timeout,
10449 provenance_probe: supervisor.provenance_probe.clone(),
10450 }),
10451 }
10452 }
10453
10454 #[cfg(target_os = "linux")]
10455 #[tokio::test]
10456 async fn no_cgroup_placement_does_not_block_fake_aft_stub_spawn() {
10457 let supervisor =
10458 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
10459 .with_cgroup_placement(None);
10460 let result = supervisor.spawn(ModuleSpec {
10461 module_id: "no-cgroup-placement".to_string(),
10462 program: fake_aft_stub_path(),
10463 args: Vec::new(),
10464 env: Vec::new(),
10465 reserved: false,
10466 reserved_prefixes: Vec::new(),
10467 protocol: ModuleProtocol::Subc,
10468 overlap: Default::default(),
10469 });
10470
10471 assert!(
10472 result.is_ok(),
10473 "no delegation must not turn an otherwise valid spawn into a failure: {result:?}"
10474 );
10475 }
10476
10477 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10478 async fn undecided_snapshot_uses_shared_restart_predicate() {
10479 assert!(module_with_recovery_snapshot(ModuleState::Running, true, 2)
10480 .will_recover_after_connection_loss()
10481 .unwrap());
10482 assert!(
10483 !module_with_recovery_snapshot(ModuleState::Running, true, 3)
10484 .will_recover_after_connection_loss()
10485 .unwrap()
10486 );
10487 }
10488
10489 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10490 async fn restarting_snapshot_at_exhausted_budget_is_non_terminal() {
10491 assert!(
10492 module_with_recovery_snapshot(ModuleState::Restarting, true, 3)
10493 .will_recover_after_connection_loss()
10494 .unwrap()
10495 );
10496 }
10497
10498 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10499 async fn terminal_phase_snapshots_are_terminal_before_budget_exhaustion() {
10500 assert!(!module_with_recovery_snapshot(ModuleState::Failed, true, 0)
10501 .will_recover_after_connection_loss()
10502 .unwrap());
10503 assert!(
10504 !module_with_recovery_snapshot(ModuleState::Disabled, true, 0)
10505 .will_recover_after_connection_loss()
10506 .unwrap()
10507 );
10508 }
10509
10510 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10511 async fn warming_snapshot_is_limited_to_startup_phases() {
10512 for state in [
10513 ModuleState::Starting,
10514 ModuleState::Running,
10515 ModuleState::Restarting,
10516 ] {
10517 assert!(
10518 module_with_recovery_snapshot(state, true, 0)
10519 .is_warming()
10520 .unwrap(),
10521 "{state:?} should be warming"
10522 );
10523 }
10524 for state in [
10525 ModuleState::Unresponsive,
10526 ModuleState::Draining,
10527 ModuleState::Stopped,
10528 ModuleState::Failed,
10529 ModuleState::Disabled,
10530 ] {
10531 assert!(
10532 !module_with_recovery_snapshot(state, true, 0)
10533 .is_warming()
10534 .unwrap(),
10535 "{state:?} should not be warming"
10536 );
10537 }
10538 }
10539
10540 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10541 async fn terminal_history_survives_respawn_and_keeps_both_crashes_in_order() {
10542 let registry = Arc::new(Registry::default());
10543 let supervisor =
10544 Supervisor::new_for_test(Arc::clone(®istry), RestartPolicy::new(1, Duration::ZERO));
10545 let module = supervisor
10546 .spawn(ModuleSpec {
10547 module_id: "terminal-history".to_string(),
10548 program: fake_aft_stub_path(),
10549 args: Vec::new(),
10550 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
10551 reserved: false,
10552 reserved_prefixes: Vec::new(),
10553 protocol: ModuleProtocol::Subc,
10554 overlap: Default::default(),
10555 })
10556 .unwrap();
10557
10558 let deadline = Instant::now() + Duration::from_secs(5);
10559 loop {
10560 let history = module.terminal_history();
10561 if history.entries.len() == 2 {
10562 assert_eq!(module.status().unwrap().state, ModuleState::Failed);
10563 assert_eq!(history.dropped, 0);
10564 assert_eq!(
10565 history
10566 .entries
10567 .iter()
10568 .map(|entry| entry.exit_code)
10569 .collect::<Vec<_>>(),
10570 vec![Some(23), Some(23)]
10571 );
10572 assert!(history.entries[0].at_ms <= history.entries[1].at_ms);
10573 return;
10574 }
10575 assert!(
10576 Instant::now() < deadline,
10577 "module did not retain two terminal exits: {history:?}"
10578 );
10579 sleep(Duration::from_millis(10)).await;
10580 }
10581 }
10582
10583 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10587 async fn disable_during_crash_backoff_cancels_pending_respawn() {
10588 let backoff = Duration::from_secs(2);
10589 let supervisor = Supervisor::new_for_test(
10590 Arc::new(Registry::default()),
10591 RestartPolicy::new(10, backoff),
10592 );
10593 let module = supervisor
10594 .spawn(ModuleSpec {
10595 module_id: "disable-during-backoff".to_string(),
10596 program: fake_aft_stub_path(),
10597 args: Vec::new(),
10598 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
10599 reserved: false,
10600 reserved_prefixes: Vec::new(),
10601 protocol: ModuleProtocol::Subc,
10602 overlap: Default::default(),
10603 })
10604 .unwrap();
10605
10606 let deadline = Instant::now() + Duration::from_secs(5);
10608 loop {
10609 if module.status().unwrap().state == ModuleState::Restarting {
10610 break;
10611 }
10612 assert!(
10613 Instant::now() < deadline,
10614 "module never entered the crash backoff"
10615 );
10616 sleep(Duration::from_millis(10)).await;
10617 }
10618
10619 let started = Instant::now();
10620 module.set_enabled(false).await.unwrap();
10621 let waited = started.elapsed();
10622
10623 assert!(
10624 waited < backoff / 2,
10625 "disable waited {waited:?} behind the {backoff:?} crash backoff; the operator command must preempt the pending respawn"
10626 );
10627 assert_eq!(module.status().unwrap().state, ModuleState::Disabled);
10628
10629 sleep(backoff + Duration::from_millis(500)).await;
10631 let status = module.status().unwrap();
10632 assert_eq!(status.state, ModuleState::Disabled);
10633 assert_eq!(
10634 status.spawn_generation, 1,
10635 "module respawned after the operator disabled it"
10636 );
10637 }
10638
10639 #[cfg(unix)]
10642 fn protocol_none_sigterm_exits_clean_spec(
10643 module_id: &str,
10644 dir: &std::path::Path,
10645 ) -> (ModuleSpec, PathBuf, PathBuf) {
10646 let ready = dir.join("ready");
10647 let marker = dir.join("sigterm");
10648 let spec = ModuleSpec {
10649 module_id: module_id.to_string(),
10650 program: fake_aft_stub_path(),
10651 args: Vec::new(),
10652 env: vec![
10653 ("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string()),
10654 (
10655 "FAKE_AFT_SIGTERM_MARKER_PATH".to_string(),
10656 marker.display().to_string(),
10657 ),
10658 (
10659 "FAKE_AFT_NEVER_CONNECT_READY_PATH".to_string(),
10660 ready.display().to_string(),
10661 ),
10662 ],
10663 reserved: false,
10664 reserved_prefixes: Vec::new(),
10665 protocol: ModuleProtocol::None,
10666 overlap: Default::default(),
10667 };
10668 (spec, ready, marker)
10669 }
10670
10671 #[cfg(unix)]
10675 async fn wait_for_file(path: &std::path::Path) {
10676 let deadline = Instant::now() + Duration::from_secs(10);
10677 while !path.exists() {
10678 assert!(
10679 Instant::now() < deadline,
10680 "{} never appeared",
10681 path.display()
10682 );
10683 sleep(Duration::from_millis(10)).await;
10684 }
10685 }
10686
10687 #[cfg(unix)]
10691 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10692 async fn protocol_none_unrequested_clean_exit_restarts_as_a_crash() {
10693 let dir = cortexkit_test_support::ScratchDir::new("none-unrequested-clean-exit");
10694 let (spec, ready, marker) =
10695 protocol_none_sigterm_exits_clean_spec("none-unrequested-clean-exit", dir.path());
10696 let supervisor = Supervisor::new_for_test(
10697 Arc::new(Registry::default()),
10698 RestartPolicy::new(3, Duration::ZERO),
10699 );
10700 let module = supervisor.spawn(spec).unwrap();
10701 wait_for_file(&ready).await;
10702 let deadline = Instant::now() + Duration::from_secs(10);
10706 let first_pid = loop {
10707 let status = module.status().unwrap();
10708 if status.state == ModuleState::Running {
10709 if let Some(pid) = status.pid {
10710 break pid;
10711 }
10712 }
10713 assert!(
10714 Instant::now() < deadline,
10715 "a running module must report its pid after exec confirmation: {status:?}"
10716 );
10717 sleep(Duration::from_millis(10)).await;
10718 };
10719
10720 rustix::process::kill_process(
10721 rustix::process::Pid::from_raw(i32::try_from(first_pid).unwrap()).unwrap(),
10722 rustix::process::Signal::TERM,
10723 )
10724 .unwrap();
10725
10726 let deadline = Instant::now() + Duration::from_secs(10);
10727 let respawned = loop {
10728 let status = module.status().unwrap();
10729 if status.state == ModuleState::Running
10730 && status.pid.is_some_and(|pid| pid != first_pid)
10731 {
10732 break status;
10733 }
10734 assert!(
10735 Instant::now() < deadline,
10736 "protocol-none module was not respawned after an unrequested clean exit: {status:?}"
10737 );
10738 sleep(Duration::from_millis(10)).await;
10739 };
10740 assert_eq!(respawned.spawn_generation, 2);
10741 assert!(
10742 marker.exists(),
10743 "the child must have exited through its SIGTERM handler (exit 0), or this proves nothing about clean exits"
10744 );
10745
10746 let history = module.terminal_history();
10747 assert_eq!(history.entries.len(), 1, "{history:?}");
10748 let entry = &history.entries[0];
10749 assert_eq!(entry.exit_code, Some(0));
10750 assert_eq!(entry.exit_kind, subc_control::TerminalExitKind::Clean);
10751 assert_eq!(entry.disposition, TerminalDisposition::Restarting);
10752
10753 module.stop().await.unwrap();
10754 }
10755
10756 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10760 async fn protocol_none_repeated_clean_exits_exhaust_the_restart_budget() {
10761 let supervisor = Supervisor::new_for_test(
10762 Arc::new(Registry::default()),
10763 RestartPolicy::new(1, Duration::ZERO),
10764 );
10765 let module = supervisor
10766 .spawn(ModuleSpec {
10767 module_id: "none-clean-exit-budget".to_string(),
10768 program: fake_aft_stub_path(),
10769 args: Vec::new(),
10770 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "0".to_string())],
10771 reserved: false,
10772 reserved_prefixes: Vec::new(),
10773 protocol: ModuleProtocol::None,
10774 overlap: Default::default(),
10775 })
10776 .unwrap();
10777
10778 let deadline = Instant::now() + Duration::from_secs(10);
10781 loop {
10782 let status = module.status().unwrap();
10783 if status.state == ModuleState::Failed {
10784 break;
10785 }
10786 assert!(
10787 Instant::now() < deadline,
10788 "module never exhausted its budget: {status:?} {:?}",
10789 module.terminal_history()
10790 );
10791 sleep(Duration::from_millis(10)).await;
10792 }
10793 let history = module.terminal_history();
10794 assert_eq!(
10795 history
10796 .entries
10797 .iter()
10798 .map(|entry| (entry.exit_code, entry.disposition.clone()))
10799 .collect::<Vec<_>>(),
10800 vec![
10801 (Some(0), TerminalDisposition::Restarting),
10802 (Some(0), TerminalDisposition::Failed),
10803 ]
10804 );
10805 let detail = history.entries[1]
10806 .disposition_detail
10807 .as_deref()
10808 .expect("a budget failure names the budget");
10809 assert!(detail.contains("max_restarts=1"), "{detail}");
10810 assert_eq!(module.status().unwrap().spawn_generation, 2);
10811 }
10812
10813 #[test]
10814 fn restart_budget_failure_is_published_after_its_terminal_record() {
10815 use std::io::{Read, Write};
10816 let supervisor = Supervisor::new_for_test(
10817 Arc::new(Registry::default()),
10818 RestartPolicy::new(0, Duration::ZERO),
10819 );
10820 let runtime = supervisor.runtime_config();
10821 let spec = ModuleSpec {
10822 protocol: ModuleProtocol::None,
10823 ..windowed_crash_spec("budget-publication-order")
10824 };
10825 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(
10826 ModuleState::Running,
10827 true,
10828 )));
10829 lock_snapshot(&snapshot).unwrap().process_alive = true;
10830 let reaped_snapshot = snapshot.clone();
10831 let mut barrier = terminal_record_barrier(&runtime.spawn_events);
10832 let history = runtime.terminal_ring.clone();
10833 let reap = std::thread::spawn(move || {
10834 tokio::runtime::Builder::new_current_thread()
10835 .enable_all()
10836 .build()
10837 .unwrap()
10838 .block_on(on_child_exit(
10839 &spec,
10840 runtime.restart_policy,
10841 &supervisor.registry,
10842 &reaped_snapshot,
10843 &runtime.terminal_ring,
10844 &runtime.spawn_events,
10845 &runtime.child_roster,
10846 ExitReport {
10847 kind: ExitKind::Clean,
10848 code: Some(0),
10849 signal: None,
10850 at_ms: 1,
10851 },
10852 ))
10853 });
10854 let mut reached = [0];
10855 barrier.read_exact(&mut reached).unwrap();
10856 assert_eq!(&reached, b"R");
10857 let (before_state, before_alive) = {
10858 let state = lock_snapshot(&snapshot).unwrap();
10859 (state.state, state.process_alive)
10860 };
10861 let before_history = history.lock().unwrap().snapshot();
10862 barrier.write_all(b"X").unwrap();
10863 assert!(matches!(reap.join().unwrap(), NextAction::Stop { .. }));
10864 assert!(before_history.entries.is_empty());
10865 assert_ne!(
10866 before_state,
10867 ModuleState::Failed,
10868 "Failed was visible before its terminal record could be written"
10869 );
10870 assert!(before_alive);
10871 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
10872 let history = history.lock().unwrap().snapshot();
10873 assert_eq!(history.entries.len(), 1);
10874 assert_eq!(history.entries[0].exit_code, Some(0));
10875 assert_eq!(history.entries[0].disposition, TerminalDisposition::Failed);
10876 }
10877
10878 fn terminal_record_barrier(events: &super::SpawnEventFeed) -> std::net::TcpStream {
10879 use std::net::{TcpListener, TcpStream};
10880 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
10881 let writer = TcpStream::connect(listener.local_addr().unwrap()).unwrap();
10882 let (barrier, _) = listener.accept().unwrap();
10883 for stream in [&writer, &barrier] {
10884 stream
10885 .set_read_timeout(Some(Duration::from_secs(10)))
10886 .unwrap();
10887 stream
10888 .set_write_timeout(Some(Duration::from_secs(10)))
10889 .unwrap();
10890 }
10891 *events.1.lock().unwrap() = Some(writer);
10892 barrier
10893 }
10894
10895 pub(super) fn before_terminal_record(events: &super::SpawnEventFeed) {
10896 use std::io::{Read, Write};
10897 let barrier = events.1.lock().unwrap().take();
10898 if let Some(mut barrier) = barrier {
10899 barrier.write_all(b"R").unwrap();
10900 let mut release = [0];
10901 barrier.read_exact(&mut release).unwrap();
10902 assert_eq!(&release, b"X");
10903 }
10904 }
10905
10906 #[test]
10907 fn health_restart_budget_failure_is_published_after_its_terminal_record() {
10908 use std::io::{Read, Write};
10909
10910 let supervisor = Supervisor::new_for_test(
10911 Arc::new(Registry::default()),
10912 RestartPolicy::new(0, Duration::ZERO),
10913 );
10914 let mut runtime = supervisor.runtime_config();
10915 let module = module_with_recovery_snapshot(ModuleState::Running, true, 0);
10916 let snapshot = module.inner.snapshot.clone();
10917 runtime.terminal_ring = module.inner.terminal_ring.clone();
10918 let spec = ModuleSpec {
10919 module_id: module.module_id().into(),
10920 program: fake_aft_stub_path(),
10921 protocol: ModuleProtocol::None,
10922 env: vec![("FAKE_AFT_EXIT_CODE".into(), "17".into())],
10923 ..windowed_crash_spec(module.module_id())
10924 };
10925 let mut barrier = terminal_record_barrier(&runtime.spawn_events);
10926 let reap = std::thread::spawn(move || {
10927 tokio::runtime::Builder::new_current_thread()
10928 .enable_all()
10929 .build()
10930 .unwrap()
10931 .block_on(async {
10932 let mut child =
10933 Some(spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap());
10934 health_restart_child(
10935 &spec,
10936 &runtime,
10937 &supervisor.registry,
10938 &supervisor.process_liveness,
10939 &snapshot,
10940 &mut child,
10941 SupervisorHealthStatus::Failing,
10942 Some("health probe did not answer"),
10943 1,
10944 )
10945 .await
10946 .unwrap();
10947 assert!(child.is_none());
10948 });
10949 });
10950 let mut reached = [0];
10955 barrier.read_exact(&mut reached).unwrap();
10956 assert_eq!(&reached, b"R");
10957 let before_status = module.status().unwrap();
10958 let before_history = module.terminal_history();
10959 barrier.write_all(b"X").unwrap();
10960 reap.join().unwrap();
10961 assert!(before_history.entries.is_empty());
10962 assert_ne!(
10963 before_status.state,
10964 ModuleState::Failed,
10965 "Failed was visible before its terminal record could be written"
10966 );
10967 assert!(
10968 before_status.process_alive,
10969 "process death was visible before its terminal record could be written"
10970 );
10971 let failed = module.status().unwrap();
10972 assert_eq!(failed.state, ModuleState::Failed);
10973 assert!(!failed.process_alive);
10974 let history = module.terminal_history();
10975 assert_eq!(history.entries.len(), 1);
10976 assert_eq!(history.entries[0].disposition, TerminalDisposition::Failed);
10977 assert_eq!(
10978 history.entries[0].disposition_detail.as_deref(),
10979 Some("crash budget exhausted: max_restarts=0 within window_secs=600")
10980 );
10981 }
10982
10983 #[test]
10984 fn observed_exits_publish_process_death_after_terminal_history() {
10985 use std::io::{Read, Write};
10986 for (kind, enabled, shutdown, wait_error, expected_state) in [
10987 (ExitKind::Clean, true, false, false, ModuleState::Stopped),
10988 (ExitKind::Crash, true, false, false, ModuleState::Restarting),
10989 (ExitKind::Crash, false, false, false, ModuleState::Disabled),
10990 (
10991 ExitKind::DeliberateSeverance,
10992 true,
10993 false,
10994 false,
10995 ModuleState::Restarting,
10996 ),
10997 #[cfg(unix)]
10998 (ExitKind::Crash, true, true, false, ModuleState::Stopped),
10999 (ExitKind::Crash, true, false, true, ModuleState::Failed),
11000 ] {
11001 let supervisor = Supervisor::new_for_test(
11002 Arc::new(Registry::default()),
11003 RestartPolicy::new(1, Duration::ZERO),
11004 );
11005 let runtime = supervisor.runtime_config();
11006 #[cfg(unix)]
11007 if shutdown {
11008 runtime.child_roster.close();
11009 }
11010 #[cfg(not(unix))]
11011 let _ = shutdown;
11012 let spec = windowed_crash_spec("exit-publication-order");
11013 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(
11014 ModuleState::Running,
11015 enabled,
11016 )));
11017 lock_snapshot(&snapshot).unwrap().process_alive = true;
11018 let reaped_snapshot = snapshot.clone();
11019 let history = runtime.terminal_ring.clone();
11020 let mut barrier = terminal_record_barrier(&runtime.spawn_events);
11021 let reap = std::thread::spawn(move || {
11022 if wait_error {
11023 super::fail_after_wait_error(
11024 &spec.module_id,
11025 &reaped_snapshot,
11026 &runtime.terminal_ring,
11027 &runtime.spawn_events,
11028 "wait failed".into(),
11029 );
11030 } else {
11031 tokio::runtime::Builder::new_current_thread()
11032 .enable_all()
11033 .build()
11034 .unwrap()
11035 .block_on(on_child_exit(
11036 &spec,
11037 runtime.restart_policy,
11038 &supervisor.registry,
11039 &reaped_snapshot,
11040 &runtime.terminal_ring,
11041 &runtime.spawn_events,
11042 &runtime.child_roster,
11043 ExitReport {
11044 kind,
11045 code: Some(0),
11046 signal: None,
11047 at_ms: 1,
11048 },
11049 ));
11050 }
11051 });
11052 let mut reached = [0];
11053 barrier.read_exact(&mut reached).unwrap();
11054 assert_eq!(&reached, b"R");
11055 let before_alive = lock_snapshot(&snapshot).unwrap().process_alive;
11056 let before_history = history.lock().unwrap().snapshot();
11057 barrier.write_all(b"X").unwrap();
11058 reap.join().unwrap();
11059 assert!(
11060 before_alive,
11061 "{kind:?}: death was published before recording"
11062 );
11063 assert!(before_history.entries.is_empty());
11064 assert_eq!(lock_snapshot(&snapshot).unwrap().state, expected_state);
11065 assert!(!lock_snapshot(&snapshot).unwrap().process_alive);
11066 assert_eq!(history.lock().unwrap().snapshot().entries.len(), 1);
11067 }
11068 }
11069
11070 #[tokio::test]
11071 async fn health_restart_budget_failure_remains_failed_when_journal_append_fails() {
11072 let dir = cortexkit_test_support::ScratchDir::new("health-budget-journal-failure");
11073 let path = dir.join("terminals.jsonl");
11074 std::fs::create_dir(&path).unwrap();
11075 let supervisor = Supervisor::new_for_test(
11076 Arc::new(Registry::default()),
11077 RestartPolicy::new(0, Duration::ZERO),
11078 )
11079 .with_terminal_journal(path, "health-budget-journal-failure".into());
11080 let runtime = supervisor.runtime_config();
11081 let spec = ModuleSpec {
11082 program: fake_aft_stub_path(),
11083 protocol: ModuleProtocol::None,
11084 env: vec![("FAKE_AFT_EXIT_CODE".into(), "17".into())],
11085 ..windowed_crash_spec("health-budget-journal-failure")
11086 };
11087 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11088 let mut child = Some(spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap());
11089 health_restart_child(
11090 &spec,
11091 &runtime,
11092 &supervisor.registry,
11093 &supervisor.process_liveness,
11094 &snapshot,
11095 &mut child,
11096 SupervisorHealthStatus::Failing,
11097 None,
11098 1,
11099 )
11100 .await
11101 .unwrap();
11102 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
11103 assert!(!lock_snapshot(&snapshot).unwrap().process_alive);
11104 let history = runtime
11105 .terminal_ring
11106 .lock()
11107 .unwrap()
11108 .durable_history(&spec.module_id);
11109 assert!(history.journal_write_failures > 0);
11110 assert_eq!(history.entries.len(), 1);
11111 assert_eq!(history.entries[0].disposition, TerminalDisposition::Failed);
11112 }
11113
11114 #[test]
11115 fn health_restart_budget_failure_remains_failed_when_exit_recording_panics() {
11116 let supervisor = Supervisor::new_for_test(
11117 Arc::new(Registry::default()),
11118 RestartPolicy::new(0, Duration::ZERO),
11119 );
11120 let runtime = supervisor.runtime_config();
11121 let spec = ModuleSpec {
11122 program: fake_aft_stub_path(),
11123 protocol: ModuleProtocol::None,
11124 env: vec![("FAKE_AFT_EXIT_CODE".into(), "17".into())],
11125 ..windowed_crash_spec("health-budget-recording-panic")
11126 };
11127 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11128 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
11129 tokio::runtime::Builder::new_current_thread()
11130 .enable_all()
11131 .build()
11132 .unwrap()
11133 .block_on(async {
11134 let mut child =
11135 Some(spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap());
11136 runtime.spawn_events.0.lock().unwrap().seq = u64::MAX;
11137 health_restart_child(
11138 &spec,
11139 &runtime,
11140 &supervisor.registry,
11141 &supervisor.process_liveness,
11142 &snapshot,
11143 &mut child,
11144 SupervisorHealthStatus::Failing,
11145 None,
11146 1,
11147 )
11148 .await
11149 .unwrap();
11150 });
11151 }));
11152 let panic = result.expect_err("recording must still unwind");
11153 assert_eq!(
11154 panic.downcast_ref::<String>().map(String::as_str),
11155 Some("spawn event sequence exhausted")
11156 );
11157 let state = lock_snapshot(&snapshot).unwrap();
11158 assert_eq!(state.state, ModuleState::Failed);
11159 assert!(!state.process_alive);
11160 assert!(state.last_exit.is_some());
11161 }
11162
11163 #[tokio::test]
11164 async fn restart_budget_failure_remains_failed_when_journal_append_fails() {
11165 let dir = cortexkit_test_support::ScratchDir::new("budget-journal-failure");
11166 let path = dir.join("terminals.jsonl");
11167 std::fs::create_dir(&path).unwrap();
11168 let supervisor = Supervisor::new_for_test(
11169 Arc::new(Registry::default()),
11170 RestartPolicy::new(0, Duration::ZERO),
11171 )
11172 .with_terminal_journal(path, "budget-journal-failure".into());
11173 let runtime = supervisor.runtime_config();
11174 let spec = windowed_crash_spec("budget-journal-failure");
11175 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11176 assert!(matches!(
11177 on_child_exit(
11178 &spec,
11179 runtime.restart_policy,
11180 &supervisor.registry,
11181 &snapshot,
11182 &runtime.terminal_ring,
11183 &runtime.spawn_events,
11184 &runtime.child_roster,
11185 crash_exit_report(1),
11186 )
11187 .await,
11188 NextAction::Stop { .. }
11189 ));
11190 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
11191 let history = runtime
11192 .terminal_ring
11193 .lock()
11194 .unwrap()
11195 .durable_history(&spec.module_id);
11196 assert!(history.journal_write_failures > 0);
11197 assert_eq!(history.entries.len(), 1);
11198 assert_eq!(history.entries[0].disposition, TerminalDisposition::Failed);
11199 }
11200
11201 #[test]
11202 fn restart_budget_failure_remains_failed_when_exit_recording_panics() {
11203 let supervisor = Supervisor::new_for_test(
11204 Arc::new(Registry::default()),
11205 RestartPolicy::new(0, Duration::ZERO),
11206 );
11207 let runtime = supervisor.runtime_config();
11208 let spec = windowed_crash_spec("budget-recording-panic");
11209 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11210 runtime.spawn_events.emit_spawned(&spec.module_id, 1, 1);
11211 runtime.spawn_events.0.lock().unwrap().seq = u64::MAX;
11214 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
11215 tokio::runtime::Builder::new_current_thread()
11216 .enable_all()
11217 .build()
11218 .unwrap()
11219 .block_on(on_child_exit(
11220 &spec,
11221 runtime.restart_policy,
11222 &supervisor.registry,
11223 &snapshot,
11224 &runtime.terminal_ring,
11225 &runtime.spawn_events,
11226 &runtime.child_roster,
11227 crash_exit_report(1),
11228 ))
11229 }));
11230 let panic = result.err().expect("recording must still unwind");
11231 assert_eq!(
11232 panic.downcast_ref::<String>().map(String::as_str),
11233 Some("spawn event sequence exhausted")
11234 );
11235 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
11236 }
11237
11238 #[cfg(unix)]
11241 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11242 async fn protocol_none_requested_stop_and_disable_do_not_respawn() {
11243 for disable in [false, true] {
11244 let label = if disable {
11245 "none-requested-disable"
11246 } else {
11247 "none-requested-stop"
11248 };
11249 let dir = cortexkit_test_support::ScratchDir::new(label);
11250 let (spec, ready, marker) = protocol_none_sigterm_exits_clean_spec(label, dir.path());
11251 let supervisor = Supervisor::new_for_test(
11252 Arc::new(Registry::default()),
11253 RestartPolicy::new(3, Duration::ZERO),
11254 );
11255 let module = supervisor.spawn(spec).unwrap();
11256 wait_for_file(&ready).await;
11257
11258 if disable {
11259 module.set_enabled(false).await.unwrap();
11260 } else {
11261 module.stop().await.unwrap();
11262 }
11263 assert!(
11264 marker.exists(),
11265 "{label}: the child must have left through its SIGTERM handler with exit 0"
11266 );
11267
11268 sleep(Duration::from_millis(500)).await;
11271 let status = module.status().unwrap();
11272 let expected = if disable {
11273 ModuleState::Disabled
11274 } else {
11275 ModuleState::Stopped
11276 };
11277 assert_eq!(status.state, expected, "{label}");
11278 assert_eq!(
11279 status.spawn_generation, 1,
11280 "{label}: respawned after a requested stop"
11281 );
11282 let history = module.terminal_history();
11283 assert_eq!(history.entries.len(), 1, "{label}: {history:?}");
11284 assert_eq!(history.entries[0].exit_code, Some(0), "{label}");
11285 assert_ne!(
11286 history.entries[0].disposition,
11287 TerminalDisposition::Restarting,
11288 "{label}"
11289 );
11290 }
11291 }
11292
11293 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11296 async fn subc_wire_clean_exit_is_still_a_stop() {
11297 let supervisor = Supervisor::new_for_test(
11298 Arc::new(Registry::default()),
11299 RestartPolicy::new(3, Duration::ZERO),
11300 );
11301 let module = supervisor
11302 .spawn(ModuleSpec {
11303 module_id: "wire-clean-exit".to_string(),
11304 program: fake_aft_stub_path(),
11305 args: Vec::new(),
11306 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "0".to_string())],
11307 reserved: false,
11308 reserved_prefixes: Vec::new(),
11309 protocol: ModuleProtocol::Subc,
11310 overlap: Default::default(),
11311 })
11312 .unwrap();
11313
11314 let deadline = Instant::now() + Duration::from_secs(10);
11315 while module.terminal_history().entries.is_empty() {
11316 assert!(Instant::now() < deadline, "module never exited");
11317 sleep(Duration::from_millis(10)).await;
11318 }
11319 sleep(Duration::from_millis(500)).await;
11321 let status = module.status().unwrap();
11322 assert_eq!(status.state, ModuleState::Stopped);
11323 assert_eq!(status.spawn_generation, 1);
11324 let history = module.terminal_history();
11325 assert_eq!(history.entries.len(), 1, "{history:?}");
11326 assert_eq!(history.entries[0].exit_code, Some(0));
11327 assert_eq!(history.entries[0].disposition, TerminalDisposition::Stopped);
11328 }
11329
11330 #[cfg(unix)]
11331 #[tokio::test]
11332 async fn reload_exit_keeps_roster_until_terminal_is_recorded_during_shutdown() {
11333 let dir = cortexkit_test_support::ScratchDir::new("reload-roster-terminal-order");
11334 let record = dir.join("live-children.json");
11335 let supervisor = Supervisor::new_for_test(
11336 Arc::new(Registry::default()),
11337 RestartPolicy::new(0, Duration::ZERO),
11338 );
11339 let mut runtime = supervisor.runtime_config();
11340 runtime.child_roster.record_to(record.clone());
11341 let gate = Arc::new(super::ReloadExitRecordGate::default());
11342 runtime.test_reload_exit_record_gate = Some(Arc::clone(&gate));
11343 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11344 let spec = ModuleSpec {
11345 module_id: "reload-exit-roster".into(),
11346 program: fake_aft_stub_path(),
11347 args: Vec::new(),
11348 env: vec![("FAKE_AFT_EXIT_CODE".into(), "23".into())],
11349 reserved: false,
11350 reserved_prefixes: Vec::new(),
11351 protocol: ModuleProtocol::Subc,
11352 overlap: Default::default(),
11353 };
11354 let mut child = None;
11355 let reload = super::finish_reload_child(
11356 &spec,
11357 &runtime,
11358 &supervisor.registry,
11359 &supervisor.process_liveness,
11360 &snapshot,
11361 &mut child,
11362 );
11363 tokio::pin!(reload);
11364 tokio::select! {
11365 result = &mut reload => panic!("reload missed the before-record gate: {result:?}"),
11366 _ = gate.reached.notified() => {}
11367 }
11368 assert!(runtime
11369 .terminal_ring
11370 .lock()
11371 .unwrap()
11372 .snapshot()
11373 .entries
11374 .is_empty());
11375 assert_eq!(
11376 crate::live_children::read_record(&record).unwrap().len(),
11377 1,
11378 "shutdown must still wait for the reaped child until its terminal record exists"
11379 );
11380 runtime.child_roster.close();
11381 gate.resume.notify_one();
11382 assert!(reload.await.is_err());
11383 assert!(crate::live_children::read_record(&record)
11384 .unwrap()
11385 .is_empty());
11386 let history = runtime.terminal_ring.lock().unwrap().snapshot();
11387 assert_eq!(history.entries.len(), 1);
11388 assert_eq!(
11389 history.entries[0].disposition,
11390 TerminalDisposition::DaemonShutdown
11391 );
11392 }
11393
11394 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11398 async fn every_restart_increment_path_advances_lifetime_count() {
11399 let supervisor = Supervisor::new_for_test(
11400 Arc::new(Registry::default()),
11401 RestartPolicy::new(1, Duration::ZERO),
11402 );
11403 let runtime = supervisor.runtime_config();
11404 let spec = ModuleSpec {
11405 module_id: "lifetime-increment-path".to_string(),
11406 program: PathBuf::from("/unused/lifetime-increment-path"),
11407 args: Vec::new(),
11408 env: Vec::new(),
11409 reserved: false,
11410 reserved_prefixes: Vec::new(),
11411 protocol: ModuleProtocol::Subc,
11412 overlap: Default::default(),
11413 };
11414
11415 let crash_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11416 assert!(matches!(
11417 on_child_exit(
11418 &spec,
11419 runtime.restart_policy,
11420 &supervisor.registry,
11421 &crash_snapshot,
11422 &runtime.terminal_ring,
11423 &runtime.spawn_events,
11424 &runtime.child_roster,
11425 ExitReport {
11426 kind: ExitKind::Crash,
11427 code: Some(1),
11428 signal: None,
11429 at_ms: 1,
11430 },
11431 )
11432 .await,
11433 NextAction::Restart { schedule: _ }
11434 ));
11435 let (crash_restarts, crash_lifetime) = {
11436 let state = lock_snapshot(&crash_snapshot).unwrap();
11437 (state.crash_restarts.len(), state.lifetime_restarts)
11438 };
11439 assert_eq!(crash_restarts, 1);
11440 assert_eq!(crash_lifetime, 1);
11441
11442 let health_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11443 let mut health_child = None;
11444 assert!(matches!(
11445 health_restart_child(
11446 &spec,
11447 &runtime,
11448 &supervisor.registry,
11449 &supervisor.process_liveness,
11450 &health_snapshot,
11451 &mut health_child,
11452 SupervisorHealthStatus::Failing,
11453 None,
11454 2,
11455 )
11456 .await,
11457 Ok(())
11458 ));
11459 assert!(health_child.is_none());
11460 assert!(lock_snapshot(&health_snapshot).unwrap().respawn_pending);
11461 assert!(runtime.scheduled_respawn.lock().unwrap().take().is_some());
11462 let (health_restarts, health_lifetime) = {
11463 let state = lock_snapshot(&health_snapshot).unwrap();
11464 (state.crash_restarts.len(), state.lifetime_restarts)
11465 };
11466 assert_eq!(health_restarts, 1);
11467 assert_eq!(health_lifetime, 1);
11468
11469 let reload_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11470 let mut reload_child = None;
11471 assert!(matches!(
11472 handle_reload_spawn_failure(
11473 &spec,
11474 &runtime,
11475 &supervisor.process_liveness,
11476 &reload_snapshot,
11477 &mut reload_child,
11478 "forced reload spawn failure".to_string(),
11479 )
11480 .await,
11481 Err(SuperviseError::ReloadFailed { .. })
11482 ));
11483 let (reload_restarts, reload_lifetime) = {
11484 let state = lock_snapshot(&reload_snapshot).unwrap();
11485 (state.crash_restarts.len(), state.lifetime_restarts)
11486 };
11487 assert_eq!(reload_restarts, 1);
11488 assert_eq!(reload_lifetime, 1);
11489 }
11490
11491 #[tokio::test]
11492 async fn deliberately_severed_live_child_records_lifetime_without_spending_restart_budget() {
11493 let supervisor = Supervisor::new_for_test(
11494 Arc::new(Registry::default()),
11495 RestartPolicy::new(3, Duration::ZERO),
11496 );
11497 let runtime = supervisor.runtime_config();
11498 let spec = ModuleSpec {
11499 module_id: "deliberately-severed".to_string(),
11500 program: PathBuf::from("/unused/deliberately-severed"),
11501 args: Vec::new(),
11502 env: Vec::new(),
11503 reserved: false,
11504 reserved_prefixes: Vec::new(),
11505 protocol: ModuleProtocol::Subc,
11506 overlap: Default::default(),
11507 };
11508 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11509 let process = ProcessIdentity {
11510 pid: 41,
11511 start_time: 101,
11512 };
11513 record_deliberate_severance(&snapshot, process).unwrap();
11514 let exit_report = apply_deliberate_severance_marker(
11515 &snapshot,
11516 Some(process),
11517 ExitReport {
11518 kind: ExitKind::Crash,
11519 code: Some(1),
11520 signal: None,
11521 at_ms: 1,
11522 },
11523 );
11524 assert_eq!(exit_report.kind, ExitKind::DeliberateSeverance);
11525
11526 assert!(matches!(
11527 on_child_exit(
11528 &spec,
11529 runtime.restart_policy,
11530 &supervisor.registry,
11531 &snapshot,
11532 &runtime.terminal_ring,
11533 &runtime.spawn_events,
11534 &runtime.child_roster,
11535 exit_report,
11536 )
11537 .await,
11538 NextAction::Restart { schedule: _ }
11539 ));
11540 let state = lock_snapshot(&snapshot).unwrap();
11541 assert_eq!(state.lifetime_restarts, 1);
11542 assert_eq!(state.crash_restarts.len(), 0);
11543 }
11544
11545 #[tokio::test]
11546 async fn genuine_crash_spends_restart_budget_and_records_lifetime() {
11547 let supervisor = Supervisor::new_for_test(
11548 Arc::new(Registry::default()),
11549 RestartPolicy::new(3, Duration::ZERO),
11550 );
11551 let runtime = supervisor.runtime_config();
11552 let spec = ModuleSpec {
11553 module_id: "genuine-crash".to_string(),
11554 program: PathBuf::from("/unused/genuine-crash"),
11555 args: Vec::new(),
11556 env: Vec::new(),
11557 reserved: false,
11558 reserved_prefixes: Vec::new(),
11559 protocol: ModuleProtocol::Subc,
11560 overlap: Default::default(),
11561 };
11562 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11563
11564 assert!(matches!(
11565 on_child_exit(
11566 &spec,
11567 runtime.restart_policy,
11568 &supervisor.registry,
11569 &snapshot,
11570 &runtime.terminal_ring,
11571 &runtime.spawn_events,
11572 &runtime.child_roster,
11573 ExitReport {
11574 kind: ExitKind::Crash,
11575 code: Some(1),
11576 signal: None,
11577 at_ms: 1,
11578 },
11579 )
11580 .await,
11581 NextAction::Restart { schedule: _ }
11582 ));
11583 let state = lock_snapshot(&snapshot).unwrap();
11584 assert_eq!(state.lifetime_restarts, 1);
11585 assert_eq!(state.crash_restarts.len(), 1);
11586 }
11587
11588 fn crash_exit_report(at_ms: u64) -> ExitReport {
11589 ExitReport {
11590 kind: ExitKind::Crash,
11591 code: Some(1),
11592 signal: None,
11593 at_ms,
11594 }
11595 }
11596
11597 fn windowed_crash_spec(module_id: &str) -> ModuleSpec {
11598 ModuleSpec {
11599 module_id: module_id.to_string(),
11600 program: PathBuf::from("/unused").join(module_id),
11601 args: Vec::new(),
11602 env: Vec::new(),
11603 reserved: false,
11604 reserved_prefixes: Vec::new(),
11605 protocol: ModuleProtocol::Subc,
11606 overlap: Default::default(),
11607 }
11608 }
11609
11610 #[tokio::test]
11616 async fn three_crashes_inside_the_window_stop_the_module_and_name_the_window() {
11617 let (logs, _guard) = crate::router::test_log::log_capture(tracing::Level::ERROR);
11618 let supervisor = Supervisor::new_for_test(
11619 Arc::new(Registry::default()),
11620 RestartPolicy::new(2, Duration::ZERO),
11621 );
11622 let runtime = supervisor.runtime_config();
11623 let spec = windowed_crash_spec("crash-loop-in-window");
11624 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11625
11626 for attempt in 1..=2 {
11627 assert!(
11628 matches!(
11629 on_child_exit(
11630 &spec,
11631 runtime.restart_policy,
11632 &supervisor.registry,
11633 &snapshot,
11634 &runtime.terminal_ring,
11635 &runtime.spawn_events,
11636 &runtime.child_roster,
11637 crash_exit_report(attempt),
11638 )
11639 .await,
11640 NextAction::Restart { schedule: _ }
11641 ),
11642 "crash {attempt} is inside the budget and must respawn"
11643 );
11644 }
11645
11646 assert!(matches!(
11647 on_child_exit(
11648 &spec,
11649 runtime.restart_policy,
11650 &supervisor.registry,
11651 &snapshot,
11652 &runtime.terminal_ring,
11653 &runtime.spawn_events,
11654 &runtime.child_roster,
11655 crash_exit_report(3),
11656 )
11657 .await,
11658 NextAction::Stop { .. }
11659 ));
11660
11661 {
11662 let state = lock_snapshot(&snapshot).unwrap();
11663 assert_eq!(state.state, ModuleState::Failed);
11664 assert_eq!(state.crash_restarts.len(), 2);
11665 assert_eq!(state.lifetime_restarts, 2);
11666 }
11667
11668 let history = runtime
11669 .terminal_ring
11670 .lock()
11671 .expect("terminal ring is not poisoned")
11672 .snapshot();
11673 let last = history
11674 .entries
11675 .last()
11676 .expect("the refused crash is retained");
11677 assert_eq!(last.disposition, TerminalDisposition::Failed);
11678 assert_eq!(
11679 last.disposition_detail.as_deref(),
11680 Some("crash budget exhausted: max_restarts=2 within window_secs=600")
11681 );
11682
11683 let captured = crate::router::test_log::captured_logs(&logs);
11684 assert!(
11685 captured.contains("crash budget exhausted: max_restarts=2 within window_secs=600"),
11686 "the stop must be logged with its window: {captured}"
11687 );
11688 }
11689
11690 #[tokio::test]
11698 async fn a_crash_older_than_the_window_frees_its_slot_for_a_later_crash() {
11699 let supervisor = Supervisor::new_for_test(
11700 Arc::new(Registry::default()),
11701 RestartPolicy::new(2, Duration::ZERO),
11702 );
11703 let runtime = supervisor.runtime_config();
11704 let spec = windowed_crash_spec("crash-across-windows");
11705 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11706
11707 for attempt in 1..=2 {
11708 assert!(matches!(
11709 on_child_exit(
11710 &spec,
11711 runtime.restart_policy,
11712 &supervisor.registry,
11713 &snapshot,
11714 &runtime.terminal_ring,
11715 &runtime.spawn_events,
11716 &runtime.child_roster,
11717 crash_exit_report(attempt),
11718 )
11719 .await,
11720 NextAction::Restart { schedule: _ }
11721 ));
11722 }
11723
11724 update_snapshot(&snapshot, Some(&spec.module_id), |state| {
11727 age_oldest_crash_restart_out_of_window(state, runtime.restart_policy.window);
11728 })
11729 .unwrap();
11730
11731 assert!(
11732 matches!(
11733 on_child_exit(
11734 &spec,
11735 runtime.restart_policy,
11736 &supervisor.registry,
11737 &snapshot,
11738 &runtime.terminal_ring,
11739 &runtime.spawn_events,
11740 &runtime.child_roster,
11741 crash_exit_report(3),
11742 )
11743 .await,
11744 NextAction::Restart { schedule: _ }
11745 ),
11746 "a crash older than the window must not hold a budget slot"
11747 );
11748
11749 let state = lock_snapshot(&snapshot).unwrap();
11750 assert_eq!(state.state, ModuleState::Restarting);
11751 assert_eq!(
11752 state.crash_restarts.len(),
11753 2,
11754 "the aged instant is dropped and the new one takes its place"
11755 );
11756 assert_eq!(
11757 state.lifetime_restarts, 3,
11758 "the ledger counts every restart, including the ones the window forgot"
11759 );
11760 }
11761
11762 #[tokio::test]
11767 async fn an_operator_restart_clears_the_ring_and_leaves_the_ledger_alone() {
11768 let supervisor = Supervisor::new_for_test(
11769 Arc::new(Registry::default()),
11770 RestartPolicy::new(2, Duration::ZERO),
11771 );
11772 let runtime = supervisor.runtime_config();
11773 let spec = windowed_crash_spec("operator-cleared-budget");
11774 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11775
11776 for attempt in 1..=2 {
11777 assert!(matches!(
11778 on_child_exit(
11779 &spec,
11780 runtime.restart_policy,
11781 &supervisor.registry,
11782 &snapshot,
11783 &runtime.terminal_ring,
11784 &runtime.spawn_events,
11785 &runtime.child_roster,
11786 crash_exit_report(attempt),
11787 )
11788 .await,
11789 NextAction::Restart { schedule: _ }
11790 ));
11791 }
11792
11793 reset_restart_count(&snapshot, &spec.module_id).unwrap();
11794 {
11795 let state = lock_snapshot(&snapshot).unwrap();
11796 assert!(
11797 state.crash_restarts.is_empty(),
11798 "an operator restart returns the full budget"
11799 );
11800 assert_eq!(
11801 state.lifetime_restarts, 2,
11802 "clearing the budget must not unmake the crashes"
11803 );
11804 }
11805
11806 assert!(
11807 matches!(
11808 on_child_exit(
11809 &spec,
11810 runtime.restart_policy,
11811 &supervisor.registry,
11812 &snapshot,
11813 &runtime.terminal_ring,
11814 &runtime.spawn_events,
11815 &runtime.child_roster,
11816 crash_exit_report(3),
11817 )
11818 .await,
11819 NextAction::Restart { schedule: _ }
11820 ),
11821 "the cleared budget must be spendable again"
11822 );
11823 let state = lock_snapshot(&snapshot).unwrap();
11824 assert_eq!(state.crash_restarts.len(), 1);
11825 assert_eq!(state.lifetime_restarts, 3);
11826 }
11827
11828 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11829 async fn severance_marker_for_a_dead_child_does_not_label_its_successor() {
11830 let severed = ProcessIdentity {
11831 pid: 41,
11832 start_time: 101,
11833 };
11834 let successor = ProcessIdentity {
11835 pid: 41,
11836 start_time: 202,
11837 };
11838 let module = module_with_recovery_snapshot(ModuleState::Running, true, 0);
11839 update_snapshot(&module.inner.snapshot, Some("recovery-snapshot"), |state| {
11840 state.pid = Some(successor.pid);
11841 state.process_start_time = Some(successor.start_time);
11842 })
11843 .unwrap();
11844 assert!(!module.record_deliberate_severance(severed).unwrap());
11845
11846 let exit_report = apply_deliberate_severance_marker(
11847 &module.inner.snapshot,
11848 Some(successor),
11849 ExitReport {
11850 kind: ExitKind::Crash,
11851 code: Some(1),
11852 signal: None,
11853 at_ms: 1,
11854 },
11855 );
11856
11857 assert_eq!(exit_report.kind, ExitKind::Crash);
11858 }
11859
11860 #[tokio::test]
11861 async fn drain_reap_marks_deliberate_severance_and_records_lifetime_without_budget() {
11862 let registry = Registry::default();
11863 let supervisor = Supervisor::new_for_test(
11864 Arc::new(Registry::default()),
11865 RestartPolicy::new(3, Duration::ZERO),
11866 );
11867 let runtime = supervisor.runtime_config();
11868 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11869 let spec = ModuleSpec {
11870 module_id: "drain-deliberate-severance".to_string(),
11871 program: fake_aft_stub_path(),
11872 args: Vec::new(),
11873 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
11874 reserved: false,
11875 reserved_prefixes: Vec::new(),
11876 protocol: ModuleProtocol::Subc,
11877 overlap: Default::default(),
11878 };
11879 let mut child = spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap();
11880 let process = ProcessIdentity {
11881 pid: 41,
11882 start_time: 101,
11883 };
11884 child.process_identity = Some(process);
11885 update_snapshot(&snapshot, Some(&spec.module_id), |state| {
11886 state.pid = Some(process.pid);
11887 state.process_start_time = Some(process.start_time);
11888 })
11889 .unwrap();
11890 record_deliberate_severance(&snapshot, process).unwrap();
11891
11892 drain_child_to_state(
11893 &spec.module_id,
11894 spec.protocol,
11895 StopNotice::SentOverConnection,
11898 ®istry,
11899 None,
11900 &snapshot,
11901 &runtime.terminal_ring,
11902 &runtime.spawn_events,
11903 child,
11904 Duration::from_secs(1),
11905 ModuleState::Stopped,
11906 Some(false),
11907 )
11908 .await
11909 .unwrap();
11910
11911 let state = lock_snapshot(&snapshot).unwrap();
11912 assert_eq!(
11913 state.last_exit.as_ref().map(|exit| exit.kind),
11914 Some(ExitKind::DeliberateSeverance)
11915 );
11916 assert_eq!(state.lifetime_restarts, 1);
11917 assert_eq!(state.crash_restarts.len(), 0);
11918 drop(state);
11919 let history = runtime.terminal_ring.lock().unwrap().snapshot();
11920 assert_eq!(
11921 history.entries[0].exit_kind,
11922 subc_control::TerminalExitKind::DeliberateSeverance
11923 );
11924 }
11925
11926 #[tokio::test]
11927 async fn ordinary_drain_reap_does_not_record_a_lifetime_restart() {
11928 let registry = Registry::default();
11929 let supervisor = Supervisor::new_for_test(
11930 Arc::new(Registry::default()),
11931 RestartPolicy::new(3, Duration::ZERO),
11932 );
11933 let runtime = supervisor.runtime_config();
11934 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11935 let spec = ModuleSpec {
11936 module_id: "ordinary-drain".to_string(),
11937 program: fake_aft_stub_path(),
11938 args: Vec::new(),
11939 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
11940 reserved: false,
11941 reserved_prefixes: Vec::new(),
11942 protocol: ModuleProtocol::Subc,
11943 overlap: Default::default(),
11944 };
11945 let child = spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap();
11946
11947 drain_child_to_state(
11948 &spec.module_id,
11949 spec.protocol,
11950 StopNotice::SentOverConnection,
11953 ®istry,
11954 None,
11955 &snapshot,
11956 &runtime.terminal_ring,
11957 &runtime.spawn_events,
11958 child,
11959 Duration::from_secs(1),
11960 ModuleState::Stopped,
11961 Some(false),
11962 )
11963 .await
11964 .unwrap();
11965
11966 let state = lock_snapshot(&snapshot).unwrap();
11967 assert_eq!(
11968 state.last_exit.as_ref().map(|exit| exit.kind),
11969 Some(ExitKind::Crash)
11970 );
11971 assert_eq!(state.lifetime_restarts, 0);
11972 assert_eq!(state.crash_restarts.len(), 0);
11973 }
11974
11975 #[test]
11976 fn fatal_connection_teardown_cannot_arm_a_marker_for_a_surviving_process() {
11977 assert!(!include_str!("server.rs")
11983 .contains("router.record_deliberate_connection_severance(ctx.connection_id)"));
11984 }
11985
11986 #[test]
11993 fn drained_after_quiescence_wait_passes_ok_through_and_forces_false_on_err() {
11994 assert!(drained_after_quiescence_wait(&Ok(true)));
11995 assert!(!drained_after_quiescence_wait(&Ok(false)));
11996 assert!(!drained_after_quiescence_wait(&Err(
11997 SuperviseError::StatePoisoned { module_id: None }
11998 )));
11999 }
12000
12001 #[test]
12010 fn wait_error_exit_report_records_a_failed_terminal_with_no_code_or_signal() {
12011 let ring = Arc::new(Mutex::new(TerminalRing::new(
12012 TerminalRingConfig::default(),
12013 0,
12014 )));
12015 record_wait_error_terminal("wait-error", &ring, &super::SpawnEventFeed::default());
12016
12017 let snapshot = ring.lock().unwrap().snapshot();
12018 assert_eq!(snapshot.entries.len(), 1);
12019 let entry = &snapshot.entries[0];
12020 assert_eq!(entry.exit_code, None);
12021 assert_eq!(entry.exit_signal, None);
12022 assert_eq!(entry.disposition, TerminalDisposition::Failed);
12023 }
12024
12025 #[test]
12026 fn wait_error_exit_path_preserves_spawn_event_density() {
12027 let feed = super::SpawnEventFeed::default();
12028 feed.configure_incarnation("wait-error-density".to_string());
12029 feed.emit_spawned("wait-error", 41, 1);
12030 let ring = Arc::new(Mutex::new(TerminalRing::new(
12031 TerminalRingConfig::default(),
12032 0,
12033 )));
12034
12035 record_wait_error_terminal("wait-error", &ring, &feed);
12036 feed.emit_spawned("after-wait-error", 42, 2);
12037
12038 let state = feed.0.lock().unwrap();
12039 let sequences = state
12040 .events
12041 .iter()
12042 .map(|event| event.cursor.seq)
12043 .collect::<Vec<_>>();
12044 assert_eq!(sequences, vec![1, 2, 3]);
12045 assert_eq!(state.events[1].kind, SpawnEventKind::Exited);
12046 assert_eq!(state.events[1].exit_code, None);
12047 assert_eq!(state.events[1].exit_signal, None);
12048 }
12049
12050 #[test]
12054 fn wait_error_exit_report_is_classified_as_a_crash() {
12055 assert_eq!(wait_error_exit_report().kind, ExitKind::Crash);
12056 }
12057}
12058
12059#[cfg(all(test, unix))]
12060mod health_event_tests {
12061 use super::*;
12062 use cortexkit_test_support::ScratchDir;
12063 use subc_protocol::{manifest::Concurrency, session::ModuleControlResponse};
12064
12065 struct Actor {
12066 module: SupervisedModule,
12067 registry: Arc<Registry>,
12068 forwarding: Arc<ForwardingTable>,
12069 spec: ModuleSpec,
12070 health: HealthConfig,
12071 rx: mpsc::Receiver<crate::router::OutboundFrame>,
12072 _home: ScratchDir,
12073 }
12074
12075 async fn wait_for<T>(reason: &str, mut observe: impl FnMut() -> Option<T>) -> T {
12076 let deadline = std::time::Instant::now() + Duration::from_secs(10);
12080 let tick = Instant::now();
12081 loop {
12082 if let Some(value) = observe() {
12083 assert_eq!(Instant::now(), tick, "{reason} must not wait for a timer");
12084 return value;
12085 }
12086 assert!(
12087 std::time::Instant::now() < deadline,
12088 "timed out waiting for {reason}"
12089 );
12090 tokio::task::yield_now().await;
12091 }
12092 }
12093
12094 impl Actor {
12095 async fn start(protocol: ModuleProtocol, cadence: Duration) -> Self {
12096 let home = ScratchDir::new("health-events");
12097 let registry = Arc::new(Registry::default());
12098 let forwarding = Arc::new(ForwardingTable::default());
12099 let health = HealthConfig {
12100 cadence,
12101 deadline: Duration::from_secs(3600),
12102 ..HealthConfig::default()
12103 };
12104 let supervisor =
12105 Supervisor::new_for_test(registry.clone(), RestartPolicy::new(3, Duration::ZERO))
12106 .with_forwarding(forwarding.clone())
12107 .with_health_config(health.clone());
12108 let spec = ModuleSpec {
12109 module_id: "event-health-child".into(),
12110 program: PathBuf::from("/bin/sh"),
12111 args: vec!["-c".into(), "exec sleep 600".into()],
12114 env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
12115 .into_iter()
12116 .map(|key| (key.into(), home.path().display().to_string()))
12117 .collect(),
12118 reserved: false,
12119 reserved_prefixes: vec![],
12120 protocol,
12121 overlap: Default::default(),
12122 };
12123 let module = supervisor.spawn(spec.clone()).unwrap();
12124 let (_, rx) = mpsc::channel(8);
12125 let actor = Self {
12126 module,
12127 registry,
12128 forwarding,
12129 spec,
12130 health,
12131 rx,
12132 _home: home,
12133 };
12134 actor
12135 .wait_for_select(|checkpoint| checkpoint.generation > 0)
12136 .await;
12137 actor
12138 }
12139
12140 fn turns(&self) -> u64 {
12141 lock_snapshot(&self.module.inner.snapshot)
12142 .unwrap()
12143 .actor_turns
12144 }
12145
12146 async fn wait_for_select(
12147 &self,
12148 expected: impl Fn(&ActorSelectCheckpoint) -> bool,
12149 ) -> ActorSelectCheckpoint {
12150 wait_for("actor select checkpoint", || {
12151 let state = lock_snapshot(&self.module.inner.snapshot).unwrap();
12152 state.actor_select.filter(|checkpoint| {
12153 checkpoint.generation == state.spawn_generation
12154 && state.state == ModuleState::Running
12155 && state.process_alive
12156 && state.reported_pid().is_some()
12157 && expected(checkpoint)
12158 })
12159 })
12160 .await
12161 }
12162
12163 fn hello(&mut self, connection: u64, health: bool) {
12164 let manifest =
12165 subc_protocol::manifest::ModuleManifest::builder(&self.spec.module_id, "1.0.0")
12166 .protocol_ver(subc_protocol::PROTOCOL_VERSION)
12167 .build();
12168 let connection = ConnectionId::new(connection);
12169 let (tx, rx) = mpsc::channel(8);
12170 self.rx = rx;
12171 self.forwarding
12172 .register_module_connection(
12173 connection,
12174 self.spec.module_id.clone(),
12175 subc_protocol::PROTOCOL_VERSION,
12176 Concurrency::ModuleManaged,
12177 FrameSink::new(tx),
12178 )
12179 .unwrap();
12180 self.registry
12181 .register_with_control_ops(
12182 manifest,
12183 subc_protocol::PROTOCOL_VERSION,
12184 connection,
12185 if health {
12186 vec![MODULE_CONTROL_OP_HEALTH_CHECK.into()]
12187 } else {
12188 vec![]
12189 },
12190 )
12191 .unwrap();
12192 }
12193
12194 async fn probe(&mut self) -> crate::router::OutboundFrame {
12195 let frame = wait_for("outbound health probe", || match self.rx.try_recv() {
12196 Ok(frame) => Some(frame),
12197 Err(mpsc::error::TryRecvError::Empty) => None,
12198 Err(mpsc::error::TryRecvError::Disconnected) => panic!("health peer disconnected"),
12199 })
12200 .await;
12201 let body: Value = serde_json::from_slice(&frame.body).unwrap();
12202 assert_eq!(body["op"], MODULE_CONTROL_OP_HEALTH_CHECK);
12203 frame
12204 }
12205
12206 fn no_probe(&mut self) {
12207 assert!(matches!(
12208 self.rx.try_recv(),
12209 Err(mpsc::error::TryRecvError::Empty)
12210 ));
12211 }
12212
12213 fn answer(&self, connection: u64, frame: crate::router::OutboundFrame) {
12214 self.forwarding
12215 .complete_module_control_rpc(
12216 ConnectionId::new(connection),
12217 frame.header.corr,
12218 Some(MODULE_CONTROL_OP_HEALTH_CHECK),
12219 ModuleControlRpcOutcome::Response(ModuleControlResponse::HealthCheck {
12220 status: HealthStatus::Ok,
12221 detail: None,
12222 metrics: None,
12223 }),
12224 )
12225 .unwrap();
12226 }
12227 }
12228
12229 #[tokio::test(start_paused = true)]
12230 async fn no_health_children_park_for_sixty_seconds() {
12231 let none = Actor::start(ModuleProtocol::None, Duration::from_secs(30)).await;
12232 let unregistered = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12233 let mut unadvertised = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12234 unadvertised.hello(1, false);
12235 for _ in 0..6000 {
12240 tokio::time::advance(Duration::from_millis(10)).await;
12241 tokio::task::yield_now().await;
12242 }
12243 for actor in [&none, &unregistered, &unadvertised] {
12244 assert!(
12245 actor.turns() <= 5,
12246 "no-health actor woke {} times",
12247 actor.turns()
12248 );
12249 assert_eq!(actor.module.state().unwrap(), ModuleState::Running);
12250 }
12251 }
12252
12253 #[tokio::test(start_paused = true)]
12254 async fn late_hello_starts_a_due_probe_in_the_notification_tick() {
12255 let mut actor = Actor::start(ModuleProtocol::Subc, Duration::ZERO).await;
12259 let tick = Instant::now();
12260 actor.hello(1, true);
12261 actor.probe().await;
12262 assert_eq!(Instant::now(), tick, "HELLO must not wait for a poll");
12263 }
12264
12265 #[tokio::test(start_paused = true)]
12266 async fn hello_keeps_the_jittered_cadence_and_catalog_updates_do_not_reset_it() {
12267 let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12268 let hello_tick = Instant::now();
12269 actor.hello(1, true);
12270 let armed = actor
12271 .wait_for_select(|checkpoint| {
12272 checkpoint.registered_connection == Some(ConnectionId::new(1))
12273 && checkpoint.next_probe_at.is_some()
12274 })
12275 .await;
12276 let delay = jittered_health_delay(&actor.spec.module_id, 0, actor.health.cadence);
12277 assert!((Duration::from_secs(30)..Duration::from_secs(33)).contains(&delay));
12278 let first_deadline = hello_tick + delay;
12279 assert_eq!(armed.next_probe_at, Some(first_deadline));
12280 tokio::time::advance(Duration::from_secs(29)).await;
12281 let turns = actor.turns();
12282 actor
12283 .registry
12284 .replace_catalog_for_connection(ConnectionId::new(1), vec![], None, Some(true))
12285 .unwrap();
12286 let updated = actor
12287 .wait_for_select(|checkpoint| checkpoint.turn > turns)
12288 .await;
12289 assert_eq!(
12290 updated.next_probe_at,
12291 Some(first_deadline),
12292 "catalog update must not reset cadence"
12293 );
12294 actor.no_probe();
12295 tokio::time::advance(first_deadline - Instant::now()).await;
12296 let frame = actor.probe().await;
12297 actor.answer(1, frame);
12298 let next = jittered_health_delay(&actor.spec.module_id, 1, actor.health.cadence);
12299 let next_deadline = Instant::now() + next;
12300 let rearmed = actor
12301 .wait_for_select(|checkpoint| {
12302 checkpoint
12303 .next_probe_at
12304 .is_some_and(|deadline| deadline > first_deadline)
12305 })
12306 .await;
12307 assert_eq!(rearmed.next_probe_at, Some(next_deadline));
12308 tokio::time::advance(next - Duration::from_nanos(1)).await;
12309 actor.no_probe();
12310 tokio::time::advance(Duration::from_nanos(1)).await;
12311 actor.probe().await;
12312 }
12313
12314 #[tokio::test(start_paused = true)]
12315 async fn disconnect_disarms_and_reconnect_rearms_health() {
12316 let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12317 actor.hello(1, true);
12318 actor
12319 .wait_for_select(|checkpoint| {
12320 checkpoint.registered_connection == Some(ConnectionId::new(1))
12321 && checkpoint.next_probe_at.is_some()
12322 })
12323 .await;
12324 let turns = actor.turns();
12325 let tick = Instant::now();
12326 actor
12327 .registry
12328 .deregister_connection(ConnectionId::new(1))
12329 .unwrap();
12330 let disarmed = actor
12331 .wait_for_select(|checkpoint| {
12332 checkpoint.turn > turns && checkpoint.registered_connection.is_none()
12333 })
12334 .await;
12335 assert_eq!(disarmed.next_probe_at, None);
12336 assert_eq!(disarmed.wake_after, None);
12337 assert_eq!(
12338 Instant::now(),
12339 tick,
12340 "disconnect must disarm in the notification tick"
12341 );
12342 tokio::time::advance(Duration::from_secs(60)).await;
12343 actor.no_probe();
12344 let reconnect_tick = Instant::now();
12345 actor.hello(2, true);
12346 let rearmed = actor
12347 .wait_for_select(|checkpoint| {
12348 checkpoint.registered_connection == Some(ConnectionId::new(2))
12349 && checkpoint.next_probe_at.is_some()
12350 })
12351 .await;
12352 let delay = jittered_health_delay(&actor.spec.module_id, 0, actor.health.cadence);
12353 assert_eq!(rearmed.next_probe_at, Some(reconnect_tick + delay));
12354 tokio::time::advance(delay).await;
12355 actor.probe().await;
12356 }
12357
12358 #[tokio::test(start_paused = true)]
12359 async fn reregistration_without_health_disarms_the_old_schedule() {
12360 let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12361 actor.hello(1, true);
12362 actor
12363 .wait_for_select(|checkpoint| {
12364 checkpoint.registered_connection == Some(ConnectionId::new(1))
12365 && checkpoint.next_probe_at.is_some()
12366 })
12367 .await;
12368 actor
12369 .registry
12370 .deregister_connection(ConnectionId::new(1))
12371 .unwrap();
12372 actor.hello(2, false);
12373 let disarmed = actor
12374 .wait_for_select(|checkpoint| {
12375 checkpoint.registered_connection == Some(ConnectionId::new(2))
12376 })
12377 .await;
12378 assert_eq!(disarmed.next_probe_at, None);
12379 assert_eq!(disarmed.wake_after, None);
12380 tokio::time::advance(Duration::from_secs(60)).await;
12381 actor.no_probe();
12382 }
12383
12384 #[tokio::test(start_paused = true)]
12385 async fn restart_waits_for_its_own_hello_before_rearming_health() {
12386 let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12387 actor.hello(1, true);
12388 let armed = actor
12389 .wait_for_select(|checkpoint| {
12390 checkpoint.registered_connection == Some(ConnectionId::new(1))
12391 && checkpoint.next_probe_at.is_some()
12392 })
12393 .await;
12394 actor
12398 .registry
12399 .deregister_connection(ConnectionId::new(1))
12400 .unwrap();
12401 actor.module.restart(Some(0)).await.unwrap();
12402 let parked = actor
12403 .wait_for_select(|checkpoint| {
12404 checkpoint.generation > armed.generation
12405 && checkpoint.registered_connection.is_none()
12406 && checkpoint.next_probe_at.is_none()
12407 })
12408 .await;
12409 assert_eq!(parked.wake_after, None);
12410 actor.rx.close();
12411 while actor.rx.try_recv().is_ok() {}
12414 let turns = actor.turns();
12415 tokio::time::advance(Duration::from_secs(60)).await;
12416 let after = actor.turns();
12417 eprintln!(
12418 "restart park turns: before={turns} after={after} delta={}",
12419 after - turns
12420 );
12421 assert_eq!(after, turns, "replacement must park before HELLO");
12422 let hello_tick = Instant::now();
12423 actor.hello(2, true);
12424 let rearmed = actor
12425 .wait_for_select(|checkpoint| {
12426 checkpoint.registered_connection == Some(ConnectionId::new(2))
12427 && checkpoint.next_probe_at.is_some()
12428 })
12429 .await;
12430 let delay = jittered_health_delay(&actor.spec.module_id, 0, actor.health.cadence);
12431 assert_eq!(rearmed.next_probe_at, Some(hello_tick + delay));
12432 tokio::time::advance(delay).await;
12433 actor.probe().await;
12434 }
12435
12436 #[tokio::test(start_paused = true)]
12437 async fn rescan_adds_and_removes_http_health_without_polling() {
12438 use tokio::{
12439 io::{AsyncReadExt, AsyncWriteExt},
12440 net::TcpListener,
12441 };
12442 let mut actor = Actor::start(ModuleProtocol::None, Duration::from_secs(30)).await;
12443 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
12444 actor.health.http = Some(format!("http://{}/health", listener.local_addr().unwrap()));
12445 actor.health.cadence = Duration::from_millis(1);
12447 let (started_tx, mut started_rx) = mpsc::channel(8);
12448 let peer = tokio::spawn(async move {
12449 loop {
12450 let (mut socket, _) = listener.accept().await.unwrap();
12451 let mut request = [0; 1024];
12452 assert!(socket.read(&mut request).await.unwrap() > 0);
12453 started_tx.send(()).await.unwrap();
12454 socket
12455 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")
12456 .await
12457 .unwrap();
12458 }
12459 });
12460 let tick = Instant::now();
12461 let turns = actor.turns();
12462 actor
12463 .module
12464 .update_configuration(actor.spec.clone(), actor.health.clone(), None)
12465 .await
12466 .unwrap();
12467 let armed = actor
12468 .wait_for_select(|checkpoint| {
12469 checkpoint.turn > turns && checkpoint.next_probe_at.is_some()
12470 })
12471 .await;
12472 assert_eq!(armed.next_probe_at, Some(tick + Duration::from_millis(1)));
12473 assert_eq!(Instant::now(), tick);
12474 tokio::time::advance(Duration::from_millis(1)).await;
12475 let mut started = false;
12476 wait_for("successful HTTP probe", || {
12477 started |= started_rx.try_recv().is_ok();
12478 (started && actor.module.status().unwrap().health.status == SupervisorHealthStatus::Ok)
12479 .then_some(())
12480 })
12481 .await;
12482 assert!(started, "added HTTP check must start at its first deadline");
12483 assert_eq!(
12484 actor.module.status().unwrap().health.status,
12485 SupervisorHealthStatus::Ok
12486 );
12487 assert_eq!(Instant::now(), tick + Duration::from_millis(1));
12488 actor.health.http = None;
12489 let turns = actor.turns();
12490 actor
12491 .module
12492 .update_configuration(actor.spec.clone(), actor.health.clone(), None)
12493 .await
12494 .unwrap();
12495 let parked = actor
12496 .wait_for_select(|checkpoint| {
12497 checkpoint.turn > turns && checkpoint.next_probe_at.is_none()
12498 })
12499 .await;
12500 assert_eq!(parked.wake_after, None);
12501 let turns = actor.turns();
12502 tokio::time::advance(Duration::from_secs(60)).await;
12503 assert_eq!(
12504 actor.turns(),
12505 turns,
12506 "removed HTTP check must park the actor"
12507 );
12508 assert!(
12509 started_rx.try_recv().is_err(),
12510 "removed HTTP check must stay disarmed"
12511 );
12512 assert_eq!(
12513 actor.module.status().unwrap().health,
12514 ModuleHealthStatus::default()
12515 );
12516 peer.abort();
12517 }
12518}
12519
12520#[cfg(test)]
12521mod health_evidence_tests {
12522 use super::{HealthProbeError, HealthProbeEvidence};
12523 use std::collections::HashSet;
12524
12525 #[test]
12533 fn only_a_dead_lane_is_proof_of_death() {
12534 assert!(HealthProbeError::lane_dead("gone").is_proof_of_death());
12535 assert!(!HealthProbeError::no_answer("timed out").is_proof_of_death());
12539 assert!(!HealthProbeError::bad_answer("garbage").is_proof_of_death());
12540 assert!(!HealthProbeError::misconfigured("no table").is_proof_of_death());
12541 }
12542
12543 #[test]
12549 fn every_evidence_class_has_a_distinct_label() {
12550 let labels = [
12551 HealthProbeError::lane_dead("").label(),
12552 HealthProbeError::no_answer("").label(),
12553 HealthProbeError::bad_answer("").label(),
12554 HealthProbeError::misconfigured("").label(),
12555 ];
12556 let unique: HashSet<_> = labels.iter().collect();
12557 assert_eq!(unique.len(), labels.len(), "labels collided: {labels:?}");
12558 }
12559
12560 #[test]
12566 fn classification_preserves_the_original_message() {
12567 let err = HealthProbeError::no_answer("module did not answer within 5s");
12568 assert_eq!(err.to_string(), "module did not answer within 5s");
12569 assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
12570 }
12571}
12572
12573#[cfg(test)]
12574mod health_tombstone_tests {
12575 use std::{path::PathBuf, sync::Arc, time::Duration};
12576
12577 use subc_protocol::{
12578 manifest::Concurrency,
12579 session::{HealthStatus, ModuleControlResponse},
12580 };
12581 use tokio::sync::mpsc;
12582
12583 use super::{
12584 probe_module_health, HealthAction, HealthConfig, HealthProbeEvidence, ModuleProtocol,
12585 ModuleSpec, RestartPolicy, Supervisor, SupervisorRuntimeConfig,
12586 };
12587 use crate::{
12588 control::ControlHandler,
12589 forwarding::{ForwardingTable, ModuleControlRpcCompletion, ModuleControlRpcOutcome},
12590 registry::{ConnectionId, Registry},
12591 router::FrameSink,
12592 };
12593
12594 struct ProbeHarness {
12595 spec: ModuleSpec,
12596 runtime: SupervisorRuntimeConfig,
12597 forwarding: Arc<ForwardingTable>,
12598 module_connection: ConnectionId,
12599 module_rx: mpsc::Receiver<crate::router::OutboundFrame>,
12600 handler: ControlHandler,
12601 module: super::SupervisedModule,
12602 }
12603
12604 fn probe_harness() -> ProbeHarness {
12605 let registry = Arc::new(Registry::default());
12606 let forwarding = Arc::new(ForwardingTable::default());
12607 let supervisor_handle = super::SupervisorHandle::new();
12608 let health = HealthConfig {
12609 http: None,
12610 cadence: Duration::from_secs(30),
12611 deadline: Duration::from_secs(5),
12612 failure_threshold: 3,
12613 on_degraded: HealthAction::Report,
12614 on_failing: HealthAction::Report,
12615 critical: false,
12616 };
12617 let supervisor = Supervisor::new_for_test(Arc::clone(®istry), RestartPolicy::default())
12618 .with_forwarding(Arc::clone(&forwarding))
12619 .with_handle(supervisor_handle.clone())
12620 .with_health_config(health);
12621 let spec = ModuleSpec {
12622 module_id: "late-health-module".to_string(),
12623 program: PathBuf::from("disabled-module"),
12624 args: Vec::new(),
12625 env: Vec::new(),
12626 reserved: false,
12627 reserved_prefixes: Vec::new(),
12628 protocol: ModuleProtocol::Subc,
12629 overlap: Default::default(),
12630 };
12631 let module = supervisor
12632 .supervise_configured(spec.clone(), false)
12633 .unwrap();
12634 let runtime = supervisor.runtime_config();
12635 let handler = ControlHandler::with_forwarding(registry, Arc::clone(&forwarding))
12636 .with_supervisor(supervisor_handle);
12637 let module_connection = ConnectionId::new(700);
12638 let (module_tx, module_rx) = mpsc::channel(8);
12639 forwarding
12640 .register_module_connection(
12641 module_connection,
12642 spec.module_id.clone(),
12643 subc_protocol::PROTOCOL_VERSION,
12644 Concurrency::ModuleManaged,
12645 FrameSink::new(module_tx),
12646 )
12647 .unwrap();
12648
12649 ProbeHarness {
12650 spec,
12651 runtime,
12652 forwarding,
12653 module_connection,
12654 module_rx,
12655 handler,
12656 module,
12657 }
12658 }
12659
12660 async fn finish_after(
12661 harness: &mut ProbeHarness,
12662 stall: Duration,
12663 ) -> ModuleControlRpcCompletion {
12664 assert!(stall > harness.runtime.health.deadline);
12665 let deadline = harness.runtime.health.deadline;
12666 let probe = probe_module_health(&harness.spec.module_id, &harness.runtime, None);
12667 let answer = async {
12668 let frame = harness.module_rx.recv().await.expect("health.check frame");
12669 tokio::time::advance(deadline).await;
12670 tokio::task::yield_now().await;
12671 tokio::time::advance(stall - deadline).await;
12672 harness
12673 .forwarding
12674 .complete_module_control_rpc(
12675 harness.module_connection,
12676 frame.header.corr,
12677 Some("health.check"),
12678 ModuleControlRpcOutcome::Response(ModuleControlResponse::HealthCheck {
12679 status: HealthStatus::Ok,
12680 detail: None,
12681 metrics: None,
12682 }),
12683 )
12684 .unwrap()
12685 };
12686 let (probe_result, completion) = tokio::join!(probe, answer);
12687 let err = probe_result.expect_err("probe must miss its deadline");
12688 assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
12689 completion
12690 }
12691
12692 async fn time_out_without_answer(harness: &mut ProbeHarness) {
12693 let deadline = harness.runtime.health.deadline;
12694 let probe = probe_module_health(&harness.spec.module_id, &harness.runtime, None);
12695 let exhaust_deadline = async {
12696 let _frame = harness.module_rx.recv().await.expect("health.check frame");
12697 tokio::time::advance(deadline).await;
12698 tokio::task::yield_now().await;
12699 };
12700 let (probe_result, ()) = tokio::join!(probe, exhaust_deadline);
12701 let err = probe_result.expect_err("probe must miss its deadline");
12702 assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
12703 }
12704
12705 async fn run_probe_cycle(harness: &mut ProbeHarness, answer: bool) {
12706 let registry = Arc::clone(&harness.module.inner.registry);
12707 let snapshot = Arc::clone(&harness.module.inner.snapshot);
12708 let process_liveness = super::SupervisorProcessLiveness::default();
12709 let mut child = None;
12710 let cycle = super::run_health_probe_cycle(
12711 &harness.spec,
12712 &harness.runtime,
12713 ®istry,
12714 &process_liveness,
12715 &snapshot,
12716 &mut child,
12717 );
12718 let peer = async {
12719 let frame = harness.module_rx.recv().await.expect("health.check frame");
12720 if answer {
12721 harness
12722 .forwarding
12723 .complete_module_control_rpc(
12724 harness.module_connection,
12725 frame.header.corr,
12726 Some("health.check"),
12727 ModuleControlRpcOutcome::Response(ModuleControlResponse::HealthCheck {
12728 status: HealthStatus::Ok,
12729 detail: None,
12730 metrics: Some(serde_json::json!({"ready": true})),
12731 }),
12732 )
12733 .unwrap();
12734 } else {
12735 tokio::time::advance(harness.runtime.health.deadline).await;
12736 tokio::task::yield_now().await;
12737 }
12738 };
12739 tokio::join!(cycle, peer);
12740 }
12741
12742 #[tokio::test(start_paused = true)]
12743 async fn unanswered_probe_is_unknown_until_threshold_and_ok_report_recovers() {
12744 let mut harness = probe_harness();
12745 let monitor = harness.module.inner.monitor.lock().unwrap().take().unwrap();
12749 monitor.abort();
12750 let _ = monitor.await;
12751 super::update_snapshot(&harness.module.inner.snapshot, None, |state| {
12752 state.enabled = true;
12753 state.state = super::ModuleState::Running;
12754 state.process_alive = true;
12755 })
12756 .unwrap();
12757
12758 run_probe_cycle(&mut harness, true).await;
12759 assert_eq!(
12760 harness.module.status().unwrap().health.status,
12761 super::SupervisorHealthStatus::Ok
12762 );
12763
12764 for failures in 1..harness.runtime.health.failure_threshold {
12765 run_probe_cycle(&mut harness, false).await;
12766 let status = harness.module.status().unwrap();
12767 assert_eq!(status.health.status, super::SupervisorHealthStatus::Unknown);
12768 assert_eq!(status.health.consecutive_failures, failures);
12769 assert!(status.health.last_probe_ms.is_some());
12770 assert!(status.health.detail.unwrap().starts_with("[no-answer]"));
12771 assert!(status.health.metrics.is_none());
12772 assert_eq!(status.state, super::ModuleState::Running);
12773 assert!(status.process_alive);
12774 assert_eq!(status.restart_count, 0);
12775 assert_eq!(status.lifetime_restarts, 0);
12776 assert!(status.health.last_action.is_none());
12777 assert!(harness.runtime.scheduled_respawn.lock().unwrap().is_none());
12778 }
12779
12780 run_probe_cycle(&mut harness, true).await;
12781 let recovered = harness.module.status().unwrap();
12782 assert_eq!(recovered.health.status, super::SupervisorHealthStatus::Ok);
12783 assert_eq!(recovered.health.consecutive_failures, 0);
12784 assert!(recovered.health.detail.is_none());
12785 assert_eq!(
12786 recovered.health.metrics,
12787 Some(serde_json::json!({"ready": true}))
12788 );
12789 assert_eq!(recovered.lifetime_restarts, 0);
12790
12791 for failures in 1..=harness.runtime.health.failure_threshold {
12792 run_probe_cycle(&mut harness, false).await;
12793 let status = harness.module.status().unwrap();
12794 assert_eq!(status.health.consecutive_failures, failures);
12795 if failures < harness.runtime.health.failure_threshold {
12796 assert_eq!(status.health.status, super::SupervisorHealthStatus::Unknown);
12797 assert_eq!(status.state, super::ModuleState::Running);
12798 assert_eq!(status.lifetime_restarts, 0);
12799 assert!(harness.runtime.scheduled_respawn.lock().unwrap().is_none());
12800 } else {
12801 assert_eq!(
12802 status.health.status,
12803 super::SupervisorHealthStatus::Unresponsive
12804 );
12805 assert_eq!(status.state, super::ModuleState::Restarting);
12806 assert_eq!(status.restart_count, 1);
12807 assert_eq!(status.lifetime_restarts, 1);
12808 assert_eq!(status.health.last_action.as_deref(), Some("restart"));
12809 assert!(harness.runtime.scheduled_respawn.lock().unwrap().is_some());
12810 }
12811 }
12812 }
12813
12814 #[tokio::test(start_paused = true)]
12815 async fn late_health_answers_record_start_anchored_latency_for_two_stalls() {
12816 let mut harness = probe_harness();
12817
12818 let first = finish_after(&mut harness, Duration::from_secs(8)).await;
12819 let first_latency = match &first {
12820 ModuleControlRpcCompletion::LateHealthAnswer { latency, .. } => *latency,
12821 other => panic!("late answer was not retained: {other:?}"),
12822 };
12823 assert!(harness.handler.observe_module_control_completion(first));
12824
12825 let second = finish_after(&mut harness, Duration::from_secs(11)).await;
12826 let second_latency = match &second {
12827 ModuleControlRpcCompletion::LateHealthAnswer { latency, .. } => *latency,
12828 other => panic!("late answer was not retained: {other:?}"),
12829 };
12830 assert!(harness.handler.observe_module_control_completion(second));
12831
12832 assert_eq!(first_latency, Duration::from_secs(8));
12833 assert_eq!(
12834 second_latency - first_latency,
12835 Duration::from_secs(3),
12836 "latency must grow linearly with the additional stall"
12837 );
12838 let health = harness.module.status().unwrap().health;
12839 assert_eq!(health.late_answer_count, 2);
12840 assert_eq!(health.last_late_answer_latency_ms, Some(11_000));
12841 }
12842
12843 #[tokio::test(start_paused = true)]
12851 async fn late_answer_clears_the_consecutive_failure_streak() {
12852 let mut harness = probe_harness();
12853
12854 time_out_without_answer(&mut harness).await;
12856 harness
12857 .module
12858 .record_health_probe_failure_for_test("[no-answer] test miss")
12859 .unwrap();
12860 assert_eq!(
12861 harness.module.status().unwrap().health.consecutive_failures,
12862 1,
12863 "precondition: the miss must be on the streak before the late answer"
12864 );
12865
12866 let late = finish_after(&mut harness, Duration::from_secs(9)).await;
12868 assert!(matches!(
12869 late,
12870 ModuleControlRpcCompletion::LateHealthAnswer { .. }
12871 ));
12872 assert!(harness.handler.observe_module_control_completion(late));
12873
12874 let health = harness.module.status().unwrap().health;
12875 assert_eq!(
12876 health.consecutive_failures, 0,
12877 "a late answer is an answer: the streak must reset"
12878 );
12879 assert_eq!(health.late_answer_count, 1);
12880 }
12881
12882 #[tokio::test(start_paused = true)]
12883 async fn repeated_serial_probe_cycles_keep_one_tombstone_per_endpoint() {
12884 let mut harness = probe_harness();
12885
12886 for _ in 0..20 {
12887 time_out_without_answer(&mut harness).await;
12888 assert_eq!(
12889 harness.forwarding.health_probe_tombstone_count().unwrap(),
12890 1
12891 );
12892 }
12893 }
12894}
12895
12896#[cfg(test)]
12897mod child_env_tests {
12898 #[cfg(windows)]
12899 use super::{
12900 clear_current_process_facts, observed_launch_nonce_source, ModuleState, SupervisorSnapshot,
12901 };
12902 #[cfg(windows)]
12903 use std::sync::{Arc, OnceLock};
12904 #[cfg(windows)]
12905 #[test]
12906 fn windows_wire_spawn_prepares_pipe_and_keeps_phase_one_env() {
12907 let spec = spec(Vec::new());
12908 let mut command = Command::new("unused");
12909 let handoff = apply_wire_spawn_args(&mut command, &spec, None, None)
12910 .unwrap()
12911 .unwrap();
12912 let name = command
12913 .as_std()
12914 .get_envs()
12915 .find(|(key, _)| *key == OsStr::new(subc_os::launch_nonce::LAUNCH_NONCE_PIPE_ENV))
12916 .and_then(|(_, value)| value)
12917 .unwrap();
12918 assert_eq!(name, OsStr::new(handoff.name()));
12919 assert!(handoff.name().starts_with(r"\\.\pipe\subc-launch-"));
12920 assert!(command.as_std().get_envs().any(|(key, value)| key
12921 == OsStr::new(subc_os::LAUNCH_NONCE_PIPE_FALLBACK_ENV)
12922 && value == Some(OsStr::new("env"))));
12923 assert!(command
12924 .as_std()
12925 .get_envs()
12926 .any(|(key, value)| key == OsStr::new(SUBC_LAUNCH_NONCE_ENV) && value.is_some()));
12927 }
12928
12929 #[cfg(windows)]
12930 #[test]
12931 fn windows_source_reporting_tracks_consumption_and_resets_per_spawn() {
12932 let mut snapshot = SupervisorSnapshot::new(ModuleState::Running, true);
12933 snapshot.process_alive = true;
12934 snapshot.spawned_protocol = Some(ModuleProtocol::Subc);
12935 assert_eq!(
12936 observed_launch_nonce_source(&snapshot).as_deref(),
12937 Some("env")
12938 );
12939 let consumed = Arc::new(OnceLock::new());
12940 snapshot.pipe_consumed = Some(consumed.clone());
12941 assert_eq!(
12942 observed_launch_nonce_source(&snapshot).as_deref(),
12943 Some("env")
12944 );
12945 consumed.set(()).unwrap();
12946 assert_eq!(
12947 observed_launch_nonce_source(&snapshot).as_deref(),
12948 Some("pipe")
12949 );
12950 snapshot.pipe_consumed = Some(Arc::new(OnceLock::new()));
12951 assert_eq!(
12952 observed_launch_nonce_source(&snapshot).as_deref(),
12953 Some("env")
12954 );
12955 clear_current_process_facts(&mut snapshot);
12956 assert_eq!(observed_launch_nonce_source(&snapshot), None);
12957 }
12958 use super::{
12959 apply_child_env, apply_spawn_role, apply_wire_spawn_args, ModuleProtocol, ModuleSpec,
12960 SpawnRole, SupervisorHandle, SPAWN_ROLE_SWAP_CANDIDATE, SUBC_ARG, SUBC_LAUNCH_NONCE_ENV,
12961 SUBC_MODULE_ID_ENV, SUBC_SPAWN_ROLE_ENV,
12962 };
12963 use std::{ffi::OsStr, path::PathBuf};
12964 use tokio::process::Command;
12965
12966 fn spec(env: Vec<(String, String)>) -> ModuleSpec {
12967 ModuleSpec {
12968 module_id: "env-plan".to_string(),
12969 program: PathBuf::from("/nonexistent"),
12970 args: Vec::new(),
12971 env,
12972 reserved: false,
12973 reserved_prefixes: Vec::new(),
12974 protocol: ModuleProtocol::Subc,
12975 overlap: Default::default(),
12976 }
12977 }
12978
12979 #[test]
12993 fn ambient_ck_log_is_removed_and_a_configured_one_survives() {
12994 let mut command = Command::new("/nonexistent");
12995 apply_child_env(&mut command, &spec(Vec::new()));
12996 let removed = command
12997 .as_std()
12998 .get_envs()
12999 .any(|(key, value)| key == OsStr::new("CK_LOG") && value.is_none());
13000 assert!(
13001 removed,
13002 "ambient CK_LOG must be explicitly removed for an unconfigured module"
13003 );
13004
13005 let mut configured = Command::new("/nonexistent");
13006 apply_child_env(
13007 &mut configured,
13008 &spec(vec![("CK_LOG".to_string(), "debug".to_string())]),
13009 );
13010 let effective = configured
13011 .as_std()
13012 .get_envs()
13013 .filter(|(key, _)| *key == OsStr::new("CK_LOG"))
13014 .last()
13015 .map(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()));
13016 assert_eq!(
13017 effective,
13018 Some(Some("debug".to_string())),
13019 "a module's configured CK_LOG must survive the ambient removal"
13020 );
13021 }
13022
13023 #[test]
13032 fn protocol_none_spawn_carries_no_subc_argument_and_no_nonce() {
13033 let connection_file = std::path::Path::new("/run/subc-connection.json");
13034 let handle = SupervisorHandle::new();
13035
13036 let mut none_spec = spec(Vec::new());
13037 none_spec.protocol = ModuleProtocol::None;
13038 let mut none = Command::new("/nonexistent");
13039 let none_handoff =
13040 apply_wire_spawn_args(&mut none, &none_spec, Some(connection_file), Some(&handle))
13041 .expect("protocol-none spawn args apply");
13042 assert!(
13043 none_handoff.is_none(),
13044 "protocol:none spawn must not receive a nonce descriptor"
13045 );
13046 #[cfg(windows)]
13047 assert!(!none.as_std().get_envs().any(|(key, value)| key
13048 == OsStr::new(subc_os::LAUNCH_NONCE_PIPE_FALLBACK_ENV)
13049 && value.is_some()));
13050 assert!(
13051 !none.as_std().get_envs().any(|(key, value)| key
13052 == OsStr::new(subc_os::LAUNCH_NONCE_FD_ENV)
13053 && value.is_some()),
13054 "protocol:none spawn must not name a nonce descriptor"
13055 );
13056 let none_args: Vec<String> = none
13057 .as_std()
13058 .get_args()
13059 .map(|a| a.to_string_lossy().into_owned())
13060 .collect();
13061 assert!(
13062 !none_args.iter().any(|a| a == SUBC_ARG),
13063 "protocol:none argv must not carry --subc; got {none_args:?}"
13064 );
13065 let none_has_nonce = none
13066 .as_std()
13067 .get_envs()
13068 .any(|(key, value)| key == OsStr::new(SUBC_LAUNCH_NONCE_ENV) && value.is_some());
13069 assert!(
13070 !none_has_nonce,
13071 "protocol:none spawn must not receive a launch nonce"
13072 );
13073 let none_has_module_id = none
13074 .as_std()
13075 .get_envs()
13076 .any(|(key, value)| key == OsStr::new(SUBC_MODULE_ID_ENV) && value.is_some());
13077 assert!(
13078 none_has_module_id,
13079 "SUBC_MODULE_ID is inert and stays on every path"
13080 );
13081 assert!(
13082 handle.spawn_nonce(&none_spec.module_id).is_none(),
13083 "no nonce record for a process that will never present one"
13084 );
13085
13086 let wire_spec = spec(Vec::new());
13088 let mut wire = Command::new("/nonexistent");
13089 let wire_handoff =
13090 apply_wire_spawn_args(&mut wire, &wire_spec, Some(connection_file), Some(&handle))
13091 .expect("subc-wire spawn args apply");
13092 let wire_fd_env = wire
13093 .as_std()
13094 .get_envs()
13095 .find(|(key, _)| *key == OsStr::new(subc_os::LAUNCH_NONCE_FD_ENV))
13096 .and_then(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()));
13097 #[cfg(unix)]
13098 assert_eq!(
13099 wire_fd_env,
13100 Some(wire_handoff.expect("a descriptor handoff").fd_env_value()),
13101 "a subc-wire spawn names the pipe it will receive at descriptor 3"
13102 );
13103 #[cfg(windows)]
13104 assert!(wire_handoff.is_some() && wire_fd_env.is_none());
13105 #[cfg(not(any(unix, windows)))]
13106 assert!(wire_handoff.is_none() && wire_fd_env.is_none());
13107 let wire_args: Vec<String> = wire
13108 .as_std()
13109 .get_args()
13110 .map(|a| a.to_string_lossy().into_owned())
13111 .collect();
13112 assert_eq!(
13113 wire_args,
13114 vec![
13115 SUBC_ARG.to_string(),
13116 connection_file.to_string_lossy().into_owned()
13117 ],
13118 "a subc-wire spawn still carries --subc <path>"
13119 );
13120 assert_eq!(
13121 wire.as_std()
13122 .get_envs()
13123 .any(|(key, value)| key == OsStr::new(SUBC_LAUNCH_NONCE_ENV) && value.is_some()),
13124 !cfg!(unix),
13125 "only Windows supplies the environment nonce"
13126 );
13127 assert!(handle.spawn_nonce(&wire_spec.module_id).is_some());
13128 }
13129
13130 #[test]
13140 fn plain_spawn_removes_the_spawn_role_even_when_the_spec_sets_it() {
13141 let role = |command: &Command| {
13142 command
13143 .as_std()
13144 .get_envs()
13145 .filter(|(key, _)| *key == OsStr::new(SUBC_SPAWN_ROLE_ENV))
13146 .last()
13147 .map(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()))
13148 };
13149 let forged = spec(vec![(
13150 SUBC_SPAWN_ROLE_ENV.to_string(),
13151 SPAWN_ROLE_SWAP_CANDIDATE.to_string(),
13152 )]);
13153
13154 let mut plain = Command::new("/nonexistent");
13155 apply_child_env(&mut plain, &forged);
13156 apply_spawn_role(&mut plain, SpawnRole::Plain);
13157 assert_eq!(
13158 role(&plain),
13159 Some(None),
13160 "a plain spawn must remove SUBC_SPAWN_ROLE, whatever the spec says"
13161 );
13162
13163 let mut candidate = Command::new("/nonexistent");
13164 apply_child_env(&mut candidate, &spec(Vec::new()));
13165 apply_spawn_role(&mut candidate, SpawnRole::SwapCandidate);
13166 assert_eq!(
13167 role(&candidate),
13168 Some(Some(SPAWN_ROLE_SWAP_CANDIDATE.to_string()))
13169 );
13170 }
13171
13172 #[test]
13178 fn daemon_private_capture_keys_are_not_passed_to_the_child() {
13179 let mut command = Command::new("/nonexistent");
13180 apply_child_env(
13181 &mut command,
13182 &spec(vec![
13183 (super::CAPTURE_KEEP_ENV.to_string(), "5".to_string()),
13184 ("KEPT".to_string(), "yes".to_string()),
13185 ]),
13186 );
13187 let keys: Vec<String> = command
13188 .as_std()
13189 .get_envs()
13190 .filter(|(_, value)| value.is_some())
13191 .map(|(key, _)| key.to_string_lossy().into_owned())
13192 .collect();
13193 assert!(keys.contains(&"KEPT".to_string()), "got {keys:?}");
13194 assert!(
13195 !keys.contains(&super::CAPTURE_KEEP_ENV.to_string()),
13196 "daemon-private capture key leaked to the child: {keys:?}"
13197 );
13198 }
13199}
13200
13201#[cfg(test)]
13202mod jitter_tests {
13203 use super::jittered_health_delay;
13204 use std::{collections::HashSet, time::Duration};
13205
13206 const FLEET: [&str; 14] = [
13215 "aft",
13216 "alfonso-core",
13217 "magic-context",
13218 "broca",
13219 "thalamus",
13220 "quota",
13221 "engram",
13222 "plexus",
13223 "cerebellum",
13224 "astrocyte",
13225 "synapse",
13226 "subc-mcp",
13227 "cortexkit-credentials",
13228 "subc-federation",
13229 ];
13230
13231 #[test]
13239 fn probe_delays_disperse_across_the_fleet() {
13240 let cadence = Duration::from_secs(30);
13241 let delays: HashSet<Duration> = FLEET
13242 .iter()
13243 .map(|id| jittered_health_delay(id, 0, cadence))
13244 .collect();
13245 assert_eq!(
13246 delays.len(),
13247 FLEET.len(),
13248 "every supervised module must land on its own probe offset"
13249 );
13250 }
13251
13252 #[test]
13258 fn jitter_only_delays_and_stays_within_one_tenth_of_cadence() {
13259 let cadence = Duration::from_secs(30);
13260 let span = cadence / 10;
13261 for id in FLEET {
13262 for probe_index in 0..8 {
13263 let delay = jittered_health_delay(id, probe_index, cadence);
13264 assert!(
13265 delay >= cadence,
13266 "{id}#{probe_index}: jitter must not shorten the cadence"
13267 );
13268 assert!(
13269 delay < cadence + span,
13270 "{id}#{probe_index}: jitter must stay inside one tenth of the cadence"
13271 );
13272 }
13273 }
13274 }
13275
13276 #[test]
13282 fn a_module_offset_is_stable_across_restarts() {
13283 let cadence = Duration::from_secs(30);
13284 for id in FLEET {
13285 assert_eq!(
13286 jittered_health_delay(id, 0, cadence),
13287 jittered_health_delay(id, 0, cadence),
13288 "{id}: the same module and probe index must produce the same offset"
13289 );
13290 }
13291 }
13292
13293 #[test]
13295 fn zero_cadence_yields_zero_delay() {
13296 assert_eq!(
13297 jittered_health_delay("aft", 0, Duration::ZERO),
13298 Duration::ZERO
13299 );
13300 }
13301}
13302
13303#[cfg(all(test, target_os = "linux"))]
13304mod cgroup_placement_tests {
13305 use super::{
13306 apply_cgroup_placement, remove_module_cgroup, ModuleProtocol, ModuleSpec, SuperviseError,
13307 SupervisedChild,
13308 };
13309 use crate::stderr_tail::{StderrRing, StderrTailConfig};
13310 use cortexkit_test_support::ScratchDir;
13311 use std::{
13312 fs, io,
13313 path::{Path, PathBuf},
13314 sync::{Arc, Mutex},
13315 };
13316 use tokio::process::Command;
13317
13318 #[tokio::test]
13319 async fn unique_spawn_cgroups_clean_up_across_restarts_and_shutdown() {
13320 use super::*;
13321 let dir = ScratchDir::new("unique-spawn-cgroups");
13322 let root = PathBuf::from(format!(
13323 "/sys/fs/cgroup/subc-unique-test-{}-{}",
13324 std::process::id(),
13325 unix_ms_now()
13326 ));
13327 if let Err(error) = fs::create_dir(&root) {
13328 assert!(
13329 std::env::var_os("SUBC_REQUIRE_CGROUP_TEST").is_none(),
13330 "required cgroup test cannot execute: {error}"
13331 );
13332 eprintln!("SKIP unique_spawn_cgroups_clean_up_across_restarts_and_shutdown: {error}");
13333 return;
13334 }
13335 let placement = subc_cgroup::prepare_at(&root).unwrap().unwrap();
13336 let group_count = || {
13337 fs::read_dir(root.join("subc-modules"))
13338 .unwrap()
13339 .map(|entry| entry.unwrap().file_type().unwrap())
13340 .filter(|kind| kind.is_dir())
13341 .count()
13342 };
13343 let supervisor =
13344 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
13345 .with_cgroup_placement(Some(placement.clone()));
13346 let runtime = supervisor.runtime_config();
13347 let mut spec = ModuleSpec {
13348 module_id: "unique-spawn".into(),
13349 program: PathBuf::from("/bin/sleep"),
13350 args: vec!["60".into()],
13351 env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
13352 .into_iter()
13353 .map(|key| (key.into(), dir.to_string_lossy().into_owned()))
13354 .collect(),
13355 reserved: false,
13356 reserved_prefixes: vec![],
13357 protocol: ModuleProtocol::None,
13358 overlap: Default::default(),
13359 };
13360 let spawn = |spec: &ModuleSpec| {
13361 spawn_child(
13362 spec,
13363 None,
13364 None,
13365 &runtime.stderr_ring,
13366 None,
13367 &runtime.child_roster,
13368 Some(&placement),
13369 )
13370 .unwrap()
13371 };
13372 let mut live = spawn(&spec);
13373 for _ in 0..3 {
13374 let next = spawn(&spec);
13376 assert_ne!(live.module_id, next.module_id);
13377 live.start_kill().unwrap();
13378 live.wait().await.unwrap();
13379 live = next;
13380 assert!(
13381 live.child.try_wait().unwrap().is_none(),
13382 "retiring the old slot must not kill the replacement"
13383 );
13384 assert_eq!(
13385 group_count(),
13386 1,
13387 "only the live spawn's cgroup should remain"
13388 );
13389 }
13390 supervisor.begin_daemon_shutdown();
13391 let reap = tokio::spawn(async move {
13392 live.wait().await.unwrap();
13393 });
13394 supervisor
13395 .end_children_for_daemon_shutdown(false, std::future::pending())
13396 .await;
13397 reap.await.unwrap();
13398 assert_eq!(group_count(), 0);
13399 spec.program = PathBuf::from("/bin/true");
13401 spec.args.clear();
13402 let fresh_roster = ChildRoster::default();
13403 let mut short = spawn_child(
13404 &spec,
13405 None,
13406 None,
13407 &runtime.stderr_ring,
13408 None,
13409 &fresh_roster,
13410 Some(&placement),
13411 )
13412 .unwrap();
13413 short.wait().await.unwrap();
13414 assert_eq!(group_count(), 0);
13415 spec.module_id = "_".repeat(255);
13416 let mut long_id = spawn_child(
13417 &spec,
13418 None,
13419 None,
13420 &runtime.stderr_ring,
13421 None,
13422 &fresh_roster,
13423 Some(&placement),
13424 )
13425 .unwrap();
13426 long_id.wait().await.unwrap();
13427 assert_eq!(
13428 group_count(),
13429 0,
13430 "valid long module IDs must not exceed cgroup NAME_MAX"
13431 );
13432 fs::remove_dir(root.join("subc-modules")).unwrap();
13433 fs::remove_dir(root).unwrap();
13434 eprintln!("EXECUTED unique_spawn_cgroups_clean_up_across_restarts_and_shutdown");
13435 }
13436
13437 #[test]
13438 fn failed_parent_cgroup_open_is_a_cgroup_supervision_error() {
13439 let path = Path::new("/definitely-missing-subc-cgroup");
13440 let mut command = Command::new("true");
13441 let error = apply_cgroup_placement(
13442 &mut command,
13443 &ModuleSpec {
13444 module_id: "broken-cgroup".to_string(),
13445 program: PathBuf::from("true"),
13446 args: Vec::new(),
13447 env: Vec::new(),
13448 reserved: false,
13449 reserved_prefixes: Vec::new(),
13450 protocol: ModuleProtocol::Subc,
13451 overlap: Default::default(),
13452 },
13453 path,
13454 )
13455 .expect_err("a parent cgroup open failure must reject the supervised spawn");
13456 let reason = error.to_string();
13457
13458 assert!(
13459 matches!(error, SuperviseError::Cgroup { .. }),
13460 "parent cgroup open must be reported as a cgroup supervision error: {reason}"
13461 );
13462 assert!(
13463 reason.contains("/definitely-missing-subc-cgroup/cgroup.procs"),
13464 "parent cgroup open failure must name cgroup.procs: {reason}"
13465 );
13466 }
13467
13468 #[tokio::test]
13469 async fn reaping_a_child_removes_its_empty_module_cgroup() {
13470 let root = ScratchDir::new("supervisor-reap-cgroup");
13471 fs::write(root.join("cgroup.procs"), b"").expect("write scratch cgroup marker");
13472 let placement = subc_cgroup::prepare_at(&root)
13473 .expect("prepare scratch cgroup root")
13474 .expect("scratch root has a cgroup.procs marker");
13475 let module_id = "reaped-module";
13476 let module = placement
13477 .module_path(module_id)
13478 .expect("create scratch module cgroup");
13479 let child = Command::new("true")
13480 .env("XDG_DATA_HOME", root.path())
13481 .env("XDG_RUNTIME_DIR", root.path())
13482 .env("XDG_CONFIG_HOME", root.path())
13483 .spawn()
13484 .expect("spawn short-lived child");
13485 let pid = child.id().expect("spawned child has pid");
13486 let mut child = SupervisedChild {
13487 child,
13488 protocol: ModuleProtocol::Subc,
13489 module_id: module_id.to_string(),
13490 cgroup_placement: Some(placement),
13491 stdout_pump: None,
13492 stderr_pump: None,
13493 stderr_ring: Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default()))),
13494 spawned_at_ms: 0,
13495 spawned_from: PathBuf::from("true"),
13496 spawned_file_identity: None,
13497 process_start_time: None,
13498 process_identity: None,
13499 pid,
13500 roster_guard: None,
13501 #[cfg(target_os = "macos")]
13502 privacy_exec: None,
13503 spawn_failure: None,
13504 };
13505
13506 child.wait().await.expect("reap short-lived child");
13507
13508 assert!(
13509 !module.exists(),
13510 "reaping the supervised child must remove its empty cgroup"
13511 );
13512 }
13513
13514 #[test]
13515 fn non_empty_cgroup_removal_is_reported_without_blocking_teardown() {
13516 let root = ScratchDir::new("supervisor-non-empty-cgroup");
13517 fs::write(root.join("cgroup.procs"), b"").expect("write scratch cgroup marker");
13518 let placement = subc_cgroup::prepare_at(&root)
13519 .expect("prepare scratch cgroup root")
13520 .expect("scratch root has a cgroup.procs marker");
13521 let module = placement
13522 .module_path("surviving-module")
13523 .expect("create scratch module cgroup");
13524 fs::write(module.join("surviving-process"), b"still present")
13525 .expect("make scratch cgroup non-empty");
13526 let (logs, _guard) = crate::router::test_log::log_capture(tracing::Level::WARN);
13527
13528 remove_module_cgroup(&placement, "surviving-module");
13529
13530 let logs = crate::router::test_log::captured_logs(&logs);
13531 assert!(
13532 module.exists(),
13533 "failed removal must leave the cgroup intact"
13534 );
13535 assert!(
13536 logs.contains("could not remove module cgroup after process exit; continuing teardown")
13537 && logs.contains("surviving-module"),
13538 "best-effort removal must report the failure without returning it: {logs}"
13539 );
13540 }
13541
13542 #[test]
13543 fn cgroup_pre_exec_spawn_failure_names_the_cgroup_path() {
13544 let cgroup_path = PathBuf::from("/sys/fs/cgroup/subc-modules/broken-module");
13545 let reason = SuperviseError::Spawn {
13546 program: PathBuf::from("/bin/true"),
13547 source: io::Error::from_raw_os_error(13),
13548 cgroup_path: Some(cgroup_path.clone()),
13549 }
13550 .to_string();
13551
13552 assert!(
13553 reason.contains(&cgroup_path.display().to_string()),
13554 "a pre_exec spawn failure must name the cgroup path: {reason}"
13555 );
13556 }
13557}
13558
13559#[cfg(test)]
13560mod spawn_subscriber_lag_tests {
13561 use super::*;
13562
13563 #[tokio::test]
13568 async fn lagged_spawn_subscriber_receives_a_terminal_lagged_error_after_its_queued_frames() {
13569 let feed = SpawnEventFeed::default();
13570 feed.configure_incarnation("lag-incarnation".to_string());
13571 let (tx, mut rx) = mpsc::channel(1);
13574 feed.subscribe(ConnectionId::new(1), 7, 1, None, FrameSink::new(tx))
13575 .expect("subscribe");
13576 let emitted = SPAWN_SUBSCRIBER_BUFFER + 16;
13577 for index in 0..emitted {
13578 feed.emit_spawned(&format!("lag-module-{index}"), 1000, 0);
13579 tokio::task::yield_now().await;
13582 }
13583 assert_eq!(
13584 feed.subscriber_count(),
13585 0,
13586 "the lagged subscriber must be removed"
13587 );
13588
13589 let mut data = Vec::new();
13590 let mut last = None;
13591 loop {
13592 let next = tokio::time::timeout(Duration::from_secs(5), rx.recv())
13593 .await
13594 .expect("the forwarder must finish once the subscriber is dropped");
13595 let Some(outbound) = next else { break };
13596 let frame = outbound.frame;
13597 if frame.header.ty == FrameType::StreamData {
13598 assert!(last.is_none(), "no data may follow the terminal frame");
13599 let event: SpawnEvent = serde_json::from_slice(&frame.body).unwrap();
13600 data.push(event.cursor.seq);
13601 } else {
13602 assert!(last.is_none(), "exactly one terminal frame");
13603 last = Some(frame);
13604 }
13605 }
13606 assert!(!data.is_empty(), "queued frames drain before the terminal");
13607 for pair in data.windows(2) {
13608 assert_eq!(
13609 pair[1],
13610 pair[0] + 1,
13611 "queued frames arrive dense and in order"
13612 );
13613 }
13614 let terminal = last.expect("a lagged subscriber must receive a terminal frame");
13615 assert_eq!(terminal.header.ty, FrameType::Error);
13616 assert_eq!(terminal.header.corr, 7);
13617 let body: subc_protocol::ErrorBody = serde_json::from_slice(&terminal.body).unwrap();
13618 assert_eq!(body.code, SPAWN_SUBSCRIBER_LAGGED_CODE);
13619 let detail = body.detail.expect("lagged error carries detail");
13620 assert_eq!(
13621 detail["first_undelivered_cursor"]["seq"],
13622 data.last().unwrap() + 1,
13623 "the named cursor is the first event the subscriber did not receive"
13624 );
13625 assert_eq!(
13626 detail["first_undelivered_cursor"]["daemon_incarnation"],
13627 "lag-incarnation"
13628 );
13629 }
13630}
13631
13632#[cfg(test)]
13633mod terminal_history_read_concurrency_tests {
13634 use super::*;
13635 use crate::terminal_journal::read_pause;
13636 use cortexkit_test_support::ScratchDir;
13637 use std::sync::mpsc as std_mpsc;
13638
13639 fn journaled_ring(
13640 journal: &Arc<crate::terminal_journal::TerminalJournal>,
13641 ) -> Arc<Mutex<TerminalRing>> {
13642 Arc::new(Mutex::new(
13643 TerminalRing::new(TerminalRingConfig::default(), 1)
13644 .with_journal(Some(Arc::clone(journal))),
13645 ))
13646 }
13647
13648 fn crash(at_ms: u64) -> ExitReport {
13649 ExitReport {
13650 kind: ExitKind::Crash,
13651 code: Some(1),
13652 signal: None,
13653 at_ms,
13654 }
13655 }
13656
13657 fn record_within(
13660 module_id: &'static str,
13661 ring: &Arc<Mutex<TerminalRing>>,
13662 at_ms: u64,
13663 bound: Duration,
13664 ) -> bool {
13665 let ring = Arc::clone(ring);
13666 let (done, done_rx) = std_mpsc::channel();
13667 std::thread::spawn(move || {
13668 record_terminal(
13669 module_id,
13670 &ring,
13671 &SpawnEventFeed::default(),
13672 &crash(at_ms),
13673 TerminalDisposition::Restarting,
13674 );
13675 let _ = done.send(());
13676 });
13677 done_rx.recv_timeout(bound).is_ok()
13678 }
13679
13680 #[test]
13685 fn exits_recorded_during_a_paused_history_read_are_not_blocked_or_half_merged() {
13686 let dir = ScratchDir::new("terminal-history-concurrent-read");
13687 let path = dir.join("terminals.jsonl");
13688 let journal = Arc::new(crate::terminal_journal::TerminalJournal::open(
13689 path.clone(),
13690 "daemon".into(),
13691 ));
13692 let reader_ring = journaled_ring(&journal);
13693 let other_ring = journaled_ring(&journal);
13694 assert!(record_within(
13695 "reader-module",
13696 &reader_ring,
13697 10,
13698 Duration::from_secs(5)
13699 ));
13700
13701 let (started, release) = read_pause::install(&path);
13702 let reading = {
13703 let ring = Arc::clone(&reader_ring);
13704 std::thread::spawn(move || durable_terminal_history_of(&ring, "reader-module"))
13705 };
13706 started
13707 .recv_timeout(Duration::from_secs(5))
13708 .expect("the history read reached its pause");
13709
13710 let bound = Duration::from_secs(1);
13711 assert!(
13712 record_within("other-module", &other_ring, 20, bound),
13713 "another module's exit waited on a history read (journal writer held)"
13714 );
13715 assert!(
13716 record_within("reader-module", &reader_ring, 30, bound),
13717 "the read module's own exit waited on its history read (ring held)"
13718 );
13719
13720 drop(release);
13721 let paused = reading.join().unwrap();
13722 assert_eq!(
13723 paused.entries.iter().map(|e| e.at_ms).collect::<Vec<_>>(),
13724 vec![10],
13725 "an exit recorded after the read began lands in neither half of it"
13726 );
13727 assert_eq!(paused.journal_skipped_lines, 0);
13728 assert_eq!(paused.journal_read_errors, 0);
13729
13730 let after = durable_terminal_history_of(&reader_ring, "reader-module");
13731 assert_eq!(
13732 after.entries.iter().map(|e| e.at_ms).collect::<Vec<_>>(),
13733 vec![10, 30],
13734 "the next read merges ring and journal with no duplicate"
13735 );
13736 assert_eq!(after.journal_skipped_lines, 0);
13737 }
13738}
13739
13740#[cfg(test)]
13745mod stderr_settle_tests {
13746 use std::{
13747 future::Future,
13748 io,
13749 pin::Pin,
13750 sync::{Arc, Mutex},
13751 task::{Context, Poll},
13752 time::Duration,
13753 };
13754
13755 use tokio::{
13756 io::{AsyncRead, ReadBuf},
13757 sync::oneshot,
13758 time::Instant,
13759 };
13760
13761 use super::{settle_stderr_pump, settle_stdout_pump, StderrPump};
13762 use crate::stderr_tail::{
13763 pump_stderr_to, pump_stdout_to, split_capture_stamp, untimed, CaptureState,
13764 ChildOutputSink, OutputSink, StderrRing, StderrTailConfig, TailEntry,
13765 };
13766
13767 const BOUND: Duration = Duration::from_millis(250);
13768
13769 struct HeldReader {
13773 before: Option<Vec<u8>>,
13774 gate: Option<oneshot::Receiver<()>>,
13775 after: io::Cursor<Vec<u8>>,
13776 }
13777
13778 impl AsyncRead for HeldReader {
13779 fn poll_read(
13780 mut self: Pin<&mut Self>,
13781 cx: &mut Context<'_>,
13782 buf: &mut ReadBuf<'_>,
13783 ) -> Poll<io::Result<()>> {
13784 if let Some(bytes) = self.before.take() {
13785 buf.put_slice(&bytes);
13786 return Poll::Ready(Ok(()));
13787 }
13788 if let Some(gate) = self.gate.as_mut() {
13789 match Pin::new(gate).poll(cx) {
13790 Poll::Pending => return Poll::Pending,
13791 Poll::Ready(_) => self.gate = None,
13792 }
13793 }
13794 Pin::new(&mut self.after).poll_read(cx, buf)
13795 }
13796 }
13797
13798 struct DiscardSink;
13799
13800 impl OutputSink for DiscardSink {
13801 fn write_line(&mut self, _line: &[u8]) {}
13802 }
13803
13804 fn line(text: &str) -> TailEntry {
13805 TailEntry::Line {
13806 text: text.to_string(),
13807 truncated: false,
13808 at_ms: None,
13809 }
13810 }
13811
13812 fn lock(ring: &Arc<Mutex<StderrRing>>) -> std::sync::MutexGuard<'_, StderrRing> {
13813 ring.lock().unwrap()
13814 }
13815
13816 fn held_pump(
13820 ring: &Arc<Mutex<StderrRing>>,
13821 before: &str,
13822 after: &str,
13823 ) -> (StderrPump, oneshot::Sender<()>) {
13824 let generation = lock(ring).begin_process();
13825 let (release, gate) = oneshot::channel();
13826 let reader = HeldReader {
13827 before: Some(before.as_bytes().to_vec()),
13828 gate: Some(gate),
13829 after: io::Cursor::new(after.as_bytes().to_vec()),
13830 };
13831 let task = tokio::spawn(pump_stderr_to(
13832 reader,
13833 Arc::clone(ring),
13834 generation,
13835 DiscardSink,
13836 ));
13837 (StderrPump { task, generation }, release)
13838 }
13839
13840 async fn wait_until(ring: &Arc<Mutex<StderrRing>>, done: impl Fn(&StderrRing) -> bool) {
13841 for _ in 0..1000 {
13842 if done(&lock(ring)) {
13843 return;
13844 }
13845 tokio::time::sleep(Duration::from_millis(1)).await;
13846 }
13847 panic!(
13848 "ring never reached the expected state: {:?}",
13849 lock(ring).snapshot(None, None)
13850 );
13851 }
13852
13853 #[tokio::test(start_paused = true)]
13854 async fn a_crash_line_the_reader_had_not_reached_by_the_bound_is_kept_before_the_restart() {
13855 let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
13856 let (pump, release) = held_pump(&ring, "booting\n", "config error: missing storage\n");
13857
13858 settle_stderr_pump("crasher", &ring, pump, BOUND).await;
13859 let before_release = lock(&ring).snapshot(None, None);
13860 assert!(
13861 matches!(before_release.capture, CaptureState::Incomplete { .. }),
13862 "a reader that has not reached EOF cannot claim a whole tail: {before_release:?}"
13863 );
13864
13865 let next = lock(&ring).begin_process();
13868 lock(&ring).push_line_from(next, "next process booting");
13869 release.send(()).unwrap();
13870 wait_until(&ring, |ring| {
13871 ring.snapshot(None, None).capture == CaptureState::Captured
13872 })
13873 .await;
13874
13875 assert_eq!(
13876 untimed(lock(&ring).snapshot(None, None).entries),
13877 vec![
13878 line("booting"),
13879 line("config error: missing storage"),
13880 TailEntry::ProcessStart,
13881 line("next process booting"),
13882 ],
13883 "the crash's last line must survive a slow reader and stay in the crashed process's section"
13884 );
13885 }
13886
13887 #[tokio::test(start_paused = true)]
13888 async fn a_pipe_held_open_by_a_descendant_reads_incomplete_without_delaying_the_restart_past_the_bound(
13889 ) {
13890 let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
13891 let (pump, _held) = held_pump(&ring, "parent exiting\n", "");
13894
13895 let started = Instant::now();
13896 settle_stderr_pump("orphaning", &ring, pump, BOUND).await;
13897 assert_eq!(
13898 started.elapsed(),
13899 BOUND,
13900 "the restart must wait exactly the bound for a pipe that stays open, no longer"
13901 );
13902
13903 let next = lock(&ring).begin_process();
13904 lock(&ring).push_line_from(next, "next process booting");
13905 tokio::time::sleep(Duration::from_secs(60)).await;
13906
13907 let snapshot = lock(&ring).snapshot(None, None);
13908 match &snapshot.capture {
13909 CaptureState::Incomplete { reason } => assert!(
13910 reason.contains("had not reached EOF") && reason.contains("250ms"),
13911 "the reason must say what is missing and after how long: {reason}"
13912 ),
13913 other => panic!("expected Incomplete while the pipe is held open, got {other:?}"),
13914 }
13915 assert_eq!(
13916 untimed(snapshot.entries),
13917 vec![
13918 line("parent exiting"),
13919 TailEntry::ProcessStart,
13920 line("next process booting"),
13921 ]
13922 );
13923 }
13924
13925 #[tokio::test(start_paused = true)]
13926 async fn a_reader_that_reaches_eof_within_the_bound_leaves_the_tail_captured() {
13927 let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
13928 let (pump, release) = held_pump(&ring, "one\n", "two\n");
13929 release.send(()).unwrap();
13930
13931 settle_stderr_pump("clean", &ring, pump, BOUND).await;
13932
13933 let snapshot = lock(&ring).snapshot(None, None);
13934 assert_eq!(snapshot.capture, CaptureState::Captured);
13935 assert_eq!(untimed(snapshot.entries), vec![line("one"), line("two")]);
13936 }
13937
13938 #[tokio::test(start_paused = true)]
13939 async fn stdout_buffered_past_the_exit_drain_bound_reaches_the_capture_file() {
13940 const BURST: usize = 150;
13941 let capture = cortexkit_test_support::ScratchDir::new("stdout-exit-drain");
13942 let path = capture.join("two-pipe.stderr.log");
13943 let sink = ChildOutputSink::open(&path, cortexkit_log::Retention::default()).unwrap();
13944 let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
13945 let generation = lock(&ring).begin_process();
13946 let stdout = (0..BURST)
13947 .map(|i| format!("out-{i:04}-{}\n", "o".repeat(64)))
13948 .collect::<String>();
13949 let stderr = (0..BURST)
13950 .map(|i| format!("err-{i:04}-{}\n", "e".repeat(64)))
13951 .collect::<String>();
13952 let first_chunk = stdout.find('\n').unwrap() + 1 + "out-0001-".len();
13957 let (release, gate) = oneshot::channel();
13958 let reader = HeldReader {
13959 before: Some(stdout.as_bytes()[..first_chunk].to_vec()),
13960 gate: Some(gate),
13961 after: io::Cursor::new(stdout.as_bytes()[first_chunk..stdout.len() - 1].to_vec()),
13962 };
13963 let (finished, completion) = oneshot::channel();
13964 let stdout_sink = sink.clone();
13965 let pump = tokio::spawn(async move {
13966 pump_stdout_to(reader, stdout_sink).await;
13967 finished.send(()).unwrap();
13968 });
13969 pump_stderr_to(io::Cursor::new(stderr.into_bytes()), ring, generation, sink).await;
13970
13971 let started = Instant::now();
13972 settle_stdout_pump("two-pipe", pump, BOUND).await;
13973 assert_eq!(
13974 started.elapsed(),
13975 BOUND,
13976 "a late reader must not hold up restart"
13977 );
13978 assert_eq!(
13979 std::fs::read_to_string(&path).unwrap().lines().count(),
13980 BURST + 1
13981 );
13982
13983 release
13984 .send(())
13985 .expect("stdout reader must survive the exit drain bound");
13986 tokio::time::timeout(Duration::from_secs(1), completion)
13987 .await
13988 .expect("released stdout reader must reach EOF")
13989 .expect("stdout capture must finish at EOF, not be cancelled");
13990 let contents = std::fs::read_to_string(&path).unwrap();
13991 let mut lines = contents
13992 .lines()
13993 .map(|line| split_capture_stamp(line).expect("capture stamp").1)
13994 .collect::<Vec<_>>();
13995 let mut expected = (0..BURST)
13996 .flat_map(|i| {
13997 [
13998 format!("out-{i:04}-{}", "o".repeat(64)),
13999 format!("err-{i:04}-{}", "e".repeat(64)),
14000 ]
14001 })
14002 .collect::<Vec<_>>();
14003 lines.sort_unstable();
14004 expected.sort_unstable();
14005 assert_eq!(
14006 lines, expected,
14007 "both pipes must retain every complete line exactly once"
14008 );
14009 }
14010}
14011
14012#[cfg(all(test, windows))]
14026mod job_containment_tests {
14027 use super::*;
14028 use cortexkit_test_support::ScratchDir;
14029 use std::{
14030 path::{Path, PathBuf},
14031 sync::{Arc, Mutex},
14032 time::{Duration, Instant},
14033 };
14034
14035 fn stub_path() -> PathBuf {
14041 let mut path = std::env::current_exe().expect("current_exe available in tests");
14042 path.pop();
14043 path.pop();
14044 path.push("fake-aft-stub.exe");
14045 assert!(
14046 path.exists(),
14047 "fake-aft-stub not built at {}: run `cargo test -p subc-core` (which builds \
14048 [[bin]] targets) rather than `cargo test -p subc-core --lib` (which does not)",
14049 path.display()
14050 );
14051 path
14052 }
14053
14054 fn read_grandchild_pid(path: &Path) -> u32 {
14056 let deadline = Instant::now() + Duration::from_secs(10);
14057 loop {
14058 if let Ok(contents) = std::fs::read_to_string(path) {
14059 if let Ok(pid) = contents.trim().parse() {
14060 return pid;
14061 }
14062 }
14063 assert!(
14064 Instant::now() < deadline,
14065 "the stub never recorded a grandchild pid at {}",
14066 path.display()
14067 );
14068 std::thread::sleep(Duration::from_millis(10));
14069 }
14070 }
14071
14072 struct Fixture {
14075 _dir: ScratchDir,
14076 module_id: String,
14077 grandchild: u32,
14078 child: Option<SupervisedChild>,
14079 registry: Arc<Registry>,
14080 snapshot: Arc<Mutex<SupervisorSnapshot>>,
14081 terminal_ring: Arc<Mutex<TerminalRing>>,
14082 spawn_events: SpawnEventFeed,
14083 }
14084
14085 fn fixture(label: &str, module_id: &str) -> Fixture {
14086 let dir = ScratchDir::new(label);
14087 let pid_file = dir.join("grandchild.pid");
14088 let supervisor = Supervisor::new_for_test(
14089 Arc::new(Registry::default()),
14090 RestartPolicy::new(3, Duration::ZERO),
14091 );
14092 let runtime = supervisor.runtime_config();
14093 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
14094 let spec = ModuleSpec {
14095 module_id: module_id.to_string(),
14096 program: stub_path(),
14097 args: Vec::new(),
14101 env: vec![
14102 ("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string()),
14103 (
14104 "FAKE_AFT_GRANDCHILD_PID_FILE".to_string(),
14105 pid_file.display().to_string(),
14106 ),
14107 ],
14108 reserved: false,
14109 reserved_prefixes: Vec::new(),
14110 protocol: ModuleProtocol::Subc,
14111 overlap: Default::default(),
14112 };
14113 let child = spawn_and_mark_running(&spec, &runtime, &snapshot)
14114 .expect("spawn the supervised fixture");
14115 let grandchild = read_grandchild_pid(&pid_file);
14116 Fixture {
14117 _dir: dir,
14118 module_id: module_id.to_string(),
14119 grandchild,
14120 child: Some(child),
14121 registry: Arc::new(Registry::default()),
14122 snapshot,
14123 terminal_ring: Arc::clone(&runtime.terminal_ring),
14124 spawn_events: SpawnEventFeed::default(),
14125 }
14126 }
14127
14128 impl Fixture {
14129 async fn drain(&mut self) {
14131 let child = self
14132 .child
14133 .take()
14134 .expect("the fixture child is still present");
14135 drain_child_to_state(
14136 &self.module_id,
14137 ModuleProtocol::Subc,
14138 StopNotice::NotSent,
14141 &self.registry,
14142 None,
14143 &self.snapshot,
14144 &self.terminal_ring,
14145 &self.spawn_events,
14146 child,
14147 Duration::from_millis(500),
14148 ModuleState::Stopped,
14149 Some(false),
14150 )
14151 .await
14152 .expect("drain the supervised fixture");
14153 }
14154 }
14155
14156 #[tokio::test]
14162 async fn teardown_reaps_the_grandchild() {
14163 let mut fixture = fixture("teardown-grandchild", "tree-teardown");
14164 let grandchild = fixture.grandchild;
14165
14166 assert!(
14167 subc_jobobject::process_exists(grandchild),
14168 "grandchild {grandchild} must be alive before teardown, or this proves nothing"
14169 );
14170
14171 fixture.drain().await;
14172
14173 assert!(
14174 subc_jobobject::wait_for_process_exit(grandchild, Duration::from_secs(10)),
14175 "grandchild {grandchild} outlived module teardown: the tree was not contained"
14176 );
14177 }
14178
14179 #[test]
14192 fn an_uncontained_grandchild_survives_a_direct_child_kill() {
14193 let dir = ScratchDir::new("teardown-uncontained");
14194 let pid_file = dir.join("grandchild.pid");
14195 let mut child = std::process::Command::new(stub_path())
14196 .env("FAKE_AFT_NEVER_CONNECT", "1")
14197 .env(
14198 "FAKE_AFT_GRANDCHILD_PID_FILE",
14199 pid_file.display().to_string(),
14200 )
14201 .stdin(std::process::Stdio::null())
14202 .stdout(std::process::Stdio::null())
14203 .stderr(std::process::Stdio::null())
14204 .spawn()
14205 .expect("spawn the uncontained fixture");
14206 let grandchild = read_grandchild_pid(&pid_file);
14207
14208 child.kill().expect("kill the direct child");
14210 let _ = child.wait();
14211
14212 assert!(
14213 subc_jobobject::process_exists(grandchild),
14214 "grandchild {grandchild} died with the direct child, so this control no longer \
14215 distinguishes contained from uncontained teardown and the regression test is \
14216 passing vacuously"
14217 );
14218
14219 kill_tree(grandchild);
14222 }
14223
14224 #[tokio::test]
14233 async fn dropping_containment_reaps_the_grandchild() {
14234 let mut fixture = fixture("drop-containment", "tree-drop");
14235 let grandchild = fixture.grandchild;
14236
14237 assert!(subc_jobobject::process_exists(grandchild));
14238
14239 fixture.child.as_mut().expect("child present").job = None;
14241
14242 assert!(
14243 subc_jobobject::wait_for_process_exit(grandchild, Duration::from_secs(10)),
14244 "grandchild {grandchild} survived the containment handle closing, so a daemon \
14245 crash would leave the tree behind"
14246 );
14247 }
14248
14249 fn kill_tree(pid: u32) {
14251 let _ = std::process::Command::new("taskkill.exe")
14252 .args(["/PID", &pid.to_string(), "/T", "/F"])
14253 .stdin(std::process::Stdio::null())
14254 .stdout(std::process::Stdio::null())
14255 .stderr(std::process::Stdio::null())
14256 .status();
14257 assert!(
14258 subc_jobobject::wait_for_process_exit(pid, Duration::from_secs(10)),
14259 "could not clean up grandchild {pid}"
14260 );
14261 }
14262}
14263
14264#[cfg(test)]
14265mod privacy_trampoline_configuration_tests {
14266 #[tokio::test]
14267 #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
14268 async fn macos_spawn_without_a_configured_trampoline_refuses_by_name() {
14269 #[cfg(target_os = "macos")]
14270 {
14271 let supervisor = super::Supervisor::new(
14272 std::sync::Arc::new(crate::Registry::default()),
14273 super::RestartPolicy::default(),
14274 );
14275 let error = supervisor.spawn(spec()).unwrap_err();
14276 assert!(
14277 error
14278 .to_string()
14279 .contains("no privacy trampoline configured"),
14280 "{error}"
14281 );
14282 }
14283 }
14284
14285 #[tokio::test]
14286 #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
14287 async fn macos_wrong_trampoline_is_rejected_by_the_startup_probe() {
14288 #[cfg(target_os = "macos")]
14289 {
14290 let supervisor = super::Supervisor::new(
14291 std::sync::Arc::new(crate::Registry::default()),
14292 super::RestartPolicy::default(),
14293 )
14294 .with_privacy_trampoline(std::env::current_exe().unwrap());
14295 let error = supervisor.spawn(spec()).unwrap_err();
14296 assert!(
14297 error
14298 .to_string()
14299 .contains("binary does not implement the privacy trampoline protocol"),
14300 "{error}"
14301 );
14302 }
14303 }
14304
14305 #[cfg(target_os = "macos")]
14306 fn spec() -> super::ModuleSpec {
14307 super::ModuleSpec {
14308 module_id: "privacy-configuration".into(),
14309 program: "/bin/sleep".into(),
14310 args: vec!["30".into()],
14311 env: vec![],
14312 reserved: false,
14313 reserved_prefixes: vec![],
14314 protocol: subc_control::ModuleProtocol::None,
14315 overlap: super::ModuleOverlap::Exclusive,
14316 }
14317 }
14318}
14319
14320#[cfg(test)]
14321mod privacy_exec_boundary_tests {
14322 #[cfg(target_os = "macos")]
14323 use super::*;
14324 #[cfg(target_os = "macos")]
14325 use std::{
14326 io::{Read, Write},
14327 net::{TcpListener, TcpStream},
14328 };
14329
14330 #[cfg(target_os = "macos")]
14333 pub(super) fn before_image_sample(spec: &ModuleSpec, pid: u32) {
14334 if let Some((_, path)) = spec
14335 .env
14336 .iter()
14337 .find(|(key, _)| key == "SUBC_TEST_PRIVACY_SAMPLE_BARRIER")
14338 {
14339 let mut barrier = TcpStream::connect(path).unwrap();
14340 barrier
14341 .set_read_timeout(Some(Duration::from_secs(30)))
14342 .unwrap();
14343 barrier.write_all(&pid.to_ne_bytes()).unwrap();
14344 let mut release = [0];
14345 barrier.read_exact(&mut release).unwrap();
14346 assert_eq!(&release, b"X");
14347 }
14348 }
14349
14350 #[cfg(target_os = "macos")]
14351 fn spec(program: &str, args: &[&str]) -> ModuleSpec {
14352 ModuleSpec {
14353 module_id: "privacy-boundary".into(),
14354 program: program.into(),
14355 args: args.iter().map(|arg| (*arg).into()).collect(),
14356 env: vec![],
14357 reserved: false,
14358 reserved_prefixes: vec![],
14359 protocol: ModuleProtocol::None,
14360 overlap: ModuleOverlap::Exclusive,
14361 }
14362 }
14363
14364 #[cfg(target_os = "macos")]
14365 async fn accept(listener: TcpListener) -> TcpStream {
14366 let listener = tokio::net::TcpListener::from_std({
14368 listener.set_nonblocking(true).unwrap();
14369 listener
14370 })
14371 .unwrap();
14372 let (stream, _) = tokio::time::timeout(Duration::from_secs(30), listener.accept())
14373 .await
14374 .unwrap()
14375 .unwrap();
14376 let stream = stream.into_std().unwrap();
14377 stream.set_nonblocking(false).unwrap();
14378 stream
14379 .set_read_timeout(Some(Duration::from_secs(30)))
14380 .unwrap();
14381 stream
14382 }
14383
14384 #[tokio::test]
14385 #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
14386 async fn macos_roster_withholds_a_nonnull_trampoline_image_until_exec_confirmation() {
14387 #[cfg(target_os = "macos")]
14388 {
14389 let root = cortexkit_test_support::ScratchDir::new("privacy-roster-barrier");
14390 let exec_listener = TcpListener::bind("127.0.0.1:0").unwrap();
14393 let sample_listener = TcpListener::bind("127.0.0.1:0").unwrap();
14394 let record = root.join("live-children.json");
14395 let supervisor =
14396 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
14397 .with_live_children_record(&record);
14398 let runtime = supervisor.runtime_config();
14399 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
14400 let mut spec = spec("/bin/sleep", &["30"]);
14401 spec.env = vec![
14402 (
14403 "SUBC_TEST_PRIVACY_EXEC_BARRIER".into(),
14404 exec_listener.local_addr().unwrap().to_string(),
14405 ),
14406 (
14407 "SUBC_TEST_PRIVACY_SAMPLE_BARRIER".into(),
14408 sample_listener.local_addr().unwrap().to_string(),
14409 ),
14410 ];
14411 let spawn = tokio::task::spawn_blocking(move || {
14412 spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap()
14413 });
14414 let mut sample = accept(sample_listener).await;
14415 let mut pid = [0; 4];
14416 sample.read_exact(&mut pid).unwrap();
14417 let pid = u32::from_ne_bytes(pid);
14418 let mut exec = accept(exec_listener).await;
14419 let mut ready = [0];
14420 exec.read_exact(&mut ready).unwrap();
14421 assert_eq!(&ready, b"R");
14422 let trampoline = subc_os::file_identity(&test_privacy_trampoline()).unwrap();
14425 assert_eq!(
14426 observe_spawned_image(pid).unwrap().executable,
14427 Some(trampoline)
14428 );
14429 sample.write_all(b"X").unwrap();
14430 let mut child = spawn.await.unwrap();
14431 let early = crate::live_children::read_record(&record).unwrap();
14432 assert_eq!(early.len(), 1);
14433 assert_eq!(early[0].pid, pid);
14434 assert_eq!(
14435 early[0].executable, None,
14436 "unconfirmed trampoline image entered the roster"
14437 );
14438 assert!(child.report_ready.get().is_none());
14439 child.privacy_exec.as_mut().unwrap().deadline =
14442 tokio::time::Instant::now() + Duration::from_secs(30);
14443 exec.write_all(b"X").unwrap();
14444 child.confirm_privacy_exec().await;
14445 assert_eq!(child.spawn_failure, None);
14446 assert!(child.report_ready.get().is_some());
14447 let confirmed = crate::live_children::read_record(&record).unwrap();
14448 let module = subc_os::file_identity(std::path::Path::new("/bin/sleep")).unwrap();
14449 assert_ne!(module, trampoline);
14450 assert_eq!(confirmed[0].executable, Some(module.into()));
14451 child.start_kill().unwrap();
14452 child.wait().await.unwrap();
14453 child.release_roster();
14454 }
14455 }
14456
14457 #[tokio::test]
14458 #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
14459 async fn macos_already_exited_121_in_try_wait_is_a_module_exit_not_a_trampoline_refusal() {
14460 #[cfg(target_os = "macos")]
14461 {
14462 let registry = Arc::new(Registry::default());
14463 let policy = RestartPolicy::new(0, Duration::ZERO);
14464 let supervisor = Supervisor::new_for_test(Arc::clone(®istry), policy);
14465 let runtime = supervisor.runtime_config();
14466 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
14467 let spec = spec("/bin/sh", &["-c", "exit 121"]);
14468 let mut child = spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap();
14472 let pid = child.pid;
14473 tokio::task::spawn_blocking(move || {
14474 subc_os::fork_exec_test::wait_for_child_exit_without_reaping(pid)
14475 })
14476 .await
14477 .unwrap()
14478 .unwrap();
14479 child.privacy_exec.as_mut().unwrap().deadline =
14480 tokio::time::Instant::now() + Duration::from_secs(30);
14481 let status = child.wait().await.unwrap();
14482 assert_eq!(status.code(), Some(121));
14483 assert!(child.privacy_exec.is_none());
14484 assert!(
14485 child.report_ready.get().is_none(),
14486 "an exited module must not publish a live pid"
14487 );
14488 let report = classify_reaped_child_exit(&snapshot, &child, &status);
14489 on_child_exit(
14490 &spec,
14491 policy,
14492 ®istry,
14493 &snapshot,
14494 &runtime.terminal_ring,
14495 &runtime.spawn_events,
14496 &runtime.child_roster,
14497 report,
14498 )
14499 .await;
14500 let state = lock_snapshot(&snapshot).unwrap();
14501 assert_eq!(state.state, ModuleState::Failed);
14502 assert_eq!(state.last_exit.as_ref().unwrap().code, Some(121));
14503 assert_eq!(state.reported_pid(), None);
14504 drop(state);
14505 let history = runtime.terminal_ring.lock().unwrap().snapshot();
14506 assert_eq!(history.entries.len(), 1);
14507 let terminal = &history.entries[0];
14508 assert_eq!(terminal.exit_code, Some(121));
14509 assert_eq!(terminal.exit_kind, subc_control::TerminalExitKind::Crash);
14510 assert_eq!(terminal.disposition, TerminalDisposition::Failed);
14511 assert_eq!(
14512 terminal.disposition_detail.as_deref(),
14513 Some(policy.budget_exhausted_detail().as_str()),
14514 "module exit 121 was classified as a trampoline refusal: {terminal:?}"
14515 );
14516 assert_eq!(child.spawn_failure, None);
14517 child.release_roster();
14518 }
14519 }
14520}
14521
14522#[cfg(all(test, unix))]
14526mod launch_nonce_descriptor_tests {
14527 use super::{ChildRoster, ModuleProtocol, ModuleSpec, SupervisorHandle};
14528 use crate::stderr_tail::{StderrRing, StderrTailConfig};
14529 use cortexkit_test_support::ScratchDir;
14530 use std::{
14531 path::PathBuf,
14532 sync::{Arc, Mutex},
14533 time::{Duration, Instant},
14534 };
14535
14536 async fn probe(role: super::SpawnRole) {
14537 let scratch = ScratchDir::new("launch-nonce-descriptor");
14538 let fd_copy = scratch.join("from-descriptor");
14539 let env_copy = scratch.join("environment");
14540 let script = format!(
14541 "env > '{env}.tmp' && mv '{env}.tmp' '{env}'; cat <&3 > '{fd}.tmp' && mv '{fd}.tmp' '{fd}'; sleep 30",
14542 fd = fd_copy.display(), env = env_copy.display(),
14543 );
14544 let xdg = |name: &str| (name.to_string(), scratch.join(name).display().to_string());
14545 let spec = ModuleSpec {
14546 module_id: "nonce-descriptor-probe".to_string(),
14547 program: PathBuf::from("/bin/sh"),
14548 args: vec!["-c".to_string(), script],
14549 env: vec![
14550 xdg("XDG_DATA_HOME"),
14551 xdg("XDG_RUNTIME_DIR"),
14552 xdg("XDG_CONFIG_HOME"),
14553 ("SUBC_LAUNCH_NONCE".to_string(), "must-not-leak".to_string()),
14554 ],
14555 reserved: true,
14556 reserved_prefixes: Vec::new(),
14557 protocol: ModuleProtocol::Subc,
14558 overlap: Default::default(),
14559 };
14560 let handle = SupervisorHandle::new();
14561 let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
14562 let roster = ChildRoster::default();
14563 #[cfg(target_os = "macos")]
14564 {
14565 let path = super::test_privacy_trampoline();
14566 roster.set_privacy_trampoline(super::probe_privacy_trampoline(&path).map(|()| path));
14567 }
14568 let child = super::spawn_child_in_slot(
14569 &spec,
14570 None,
14571 Some(&handle),
14572 &ring,
14573 None,
14574 &roster,
14575 #[cfg(target_os = "linux")]
14576 None,
14577 role,
14578 matches!(role, super::SpawnRole::SwapCandidate),
14579 )
14580 .expect("spawn probe");
14581 let deadline = Instant::now() + Duration::from_secs(10);
14582 while !(fd_copy.exists() && env_copy.exists()) {
14583 assert!(Instant::now() < deadline, "probe never wrote its copies");
14584 tokio::time::sleep(Duration::from_millis(20)).await;
14585 }
14586 let nonce = std::fs::read_to_string(fd_copy).unwrap();
14587 assert!(!nonce.is_empty());
14588 let environment = std::fs::read_to_string(env_copy).unwrap();
14589 assert!(environment
14590 .lines()
14591 .any(|line| line.starts_with("SUBC_LAUNCH_NONCE_FD=3:")));
14592 let copy = environment
14593 .lines()
14594 .find_map(|line| line.strip_prefix("SUBC_LAUNCH_NONCE="));
14595 assert_eq!(
14596 copy, None,
14597 "Unix children must never receive the environment nonce"
14598 );
14599 if matches!(role, super::SpawnRole::Plain) {
14600 assert_eq!(
14601 handle.spawn_nonce(&spec.module_id).as_deref(),
14602 Some(nonce.as_str())
14603 );
14604 }
14605 drop(child);
14606 }
14607
14608 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
14609 async fn a_spawned_module_receives_its_nonce_only_on_descriptor_3() {
14610 probe(super::SpawnRole::Plain).await;
14611 }
14612
14613 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
14614 async fn swap_candidate_receives_its_nonce_only_on_descriptor_3() {
14615 probe(super::SpawnRole::SwapCandidate).await;
14616 }
14617}
14618
14619#[cfg(all(test, target_os = "linux"))]
14620mod cgroup_containment_tests {
14621 use super::*;
14622 use cortexkit_test_support::ScratchDir;
14623
14624 fn running(pid: u32) -> bool {
14625 std::fs::read_to_string(format!("/proc/{pid}/stat"))
14627 .ok()
14628 .and_then(|stat| {
14629 stat.rsplit_once(") ")
14630 .map(|(_, rest)| rest.starts_with('Z'))
14631 })
14632 .is_some_and(|zombie| !zombie)
14633 }
14634
14635 #[tokio::test]
14636 async fn linux_teardown_reaps_the_grandchild() {
14637 teardown_tree("linux_teardown_reaps_the_grandchild", false).await;
14638 }
14639
14640 #[tokio::test]
14641 async fn linux_shutdown_straggler_reaps_the_grandchild() {
14642 teardown_tree("linux_shutdown_straggler_reaps_the_grandchild", true).await;
14643 }
14644
14645 async fn teardown_tree(test_name: &str, shutdown: bool) {
14646 let dir = ScratchDir::new(test_name);
14647 let root = PathBuf::from(format!(
14648 "/sys/fs/cgroup/subc-tree-test-{test_name}-{}-{}",
14649 std::process::id(),
14650 unix_ms_now()
14651 ));
14652 if let Err(error) = std::fs::create_dir(&root) {
14653 assert!(
14654 std::env::var_os("SUBC_REQUIRE_CGROUP_TEST").is_none(),
14655 "required cgroup test cannot execute: {error}"
14656 );
14657 eprintln!(
14658 "SKIP {test_name}: no writable delegated cgroup at {}: {error}",
14659 root.display()
14660 );
14661 return;
14662 }
14663 let placement = subc_cgroup::prepare_at(&root)
14664 .expect("prepare isolated kernel cgroup")
14665 .expect("isolated cgroup is delegated");
14666 let module_id = "tree-teardown";
14667 let module = placement
14668 .module_path(module_id)
14669 .expect("create isolated module cgroup");
14670 if !module.join("cgroup.kill").exists() {
14671 std::fs::remove_dir(&module).unwrap();
14672 std::fs::remove_dir(root.join("subc-modules")).unwrap();
14673 std::fs::remove_dir(&root).unwrap();
14674 assert!(
14675 std::env::var_os("SUBC_REQUIRE_CGROUP_TEST").is_none(),
14676 "required cgroup.kill interface unavailable"
14677 );
14678 eprintln!("SKIP {test_name}: cgroup.kill unavailable (kernel < 5.14)");
14679 return;
14680 }
14681 let supervisor = Supervisor::new_for_test(
14682 Arc::new(Registry::default()),
14683 RestartPolicy::new(3, Duration::ZERO),
14684 )
14685 .with_cgroup_placement(Some(placement));
14686 let mut runtime = supervisor.runtime_config();
14687 runtime.child_roster = runtime
14688 .child_roster
14689 .for_module(Arc::new(Mutex::new(Duration::from_millis(100))));
14690 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
14691 let pid_file = dir.join("grandchild.pid");
14692 let spec = ModuleSpec {
14693 module_id: module_id.to_string(),
14694 program: PathBuf::from("/bin/sh"),
14695 args: vec![
14696 "-c".into(),
14697 "trap '' TERM; sleep 600 & echo $! > \"$1\"; wait".into(),
14698 "fixture".into(),
14699 pid_file.display().to_string(),
14700 ],
14701 env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
14702 .into_iter()
14703 .map(|key| (key.to_string(), dir.display().to_string()))
14704 .collect(),
14705 reserved: false,
14706 reserved_prefixes: Vec::new(),
14707 protocol: ModuleProtocol::None,
14708 overlap: Default::default(),
14709 };
14710 let child =
14711 spawn_and_mark_running(&spec, &runtime, &snapshot).expect("spawn supervised tree");
14712 let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
14713 let grandchild: u32 = loop {
14714 if let Ok(contents) = std::fs::read_to_string(&pid_file) {
14715 if let Ok(pid) = contents.trim().parse() {
14716 break pid;
14717 }
14718 }
14719 assert!(
14720 tokio::time::Instant::now() < deadline,
14721 "grandchild pid was not recorded"
14722 );
14723 tokio::time::sleep(Duration::from_millis(10)).await;
14724 };
14725 assert!(
14726 running(grandchild),
14727 "grandchild must be alive before teardown"
14728 );
14729 if shutdown {
14730 let mut child = child;
14731 crate::child_roster::end_children_for_daemon_shutdown(
14732 &runtime.child_roster,
14733 false,
14734 std::future::pending(),
14735 )
14736 .await;
14737 child.wait().await.expect("reap shutdown straggler");
14738 } else {
14739 drain_child_to_state(
14740 module_id,
14741 ModuleProtocol::None,
14742 StopNotice::NotSent,
14743 &Registry::default(),
14744 None,
14745 &snapshot,
14746 &runtime.terminal_ring,
14747 &SpawnEventFeed::default(),
14748 child,
14749 Duration::from_millis(100),
14750 ModuleState::Stopped,
14751 Some(false),
14752 )
14753 .await
14754 .expect("real supervisor teardown");
14755 }
14756 let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
14757 while running(grandchild) && tokio::time::Instant::now() < deadline {
14758 tokio::time::sleep(Duration::from_millis(10)).await;
14759 }
14760 let survived = running(grandchild);
14761 if survived {
14763 let pid = rustix::process::Pid::from_raw(grandchild as i32).unwrap();
14764 let _ = rustix::process::kill_process(pid, rustix::process::Signal::KILL);
14765 tokio::time::sleep(Duration::from_millis(100)).await;
14766 }
14767 if module.exists() {
14768 std::fs::remove_dir(&module).expect("remove empty module cgroup");
14769 }
14770 std::fs::remove_dir(root.join("subc-modules")).unwrap();
14771 std::fs::remove_dir(&root).unwrap();
14772 assert!(
14773 !survived,
14774 "grandchild {grandchild} outlived module teardown"
14775 );
14776 eprintln!("EXECUTED {test_name}: grandchild {grandchild} killed in isolated cgroup");
14777 }
14778}