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 modules = 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, endpoint) in modules {
2621 let Some(target) = forwarding
2622 .begin_endpoint_drain(endpoint, 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 let wait_result = wait_for_forwarding_quiescence(
2700 &forwarding,
2701 &module_id,
2702 &runtime,
2703 endpoint,
2704 deadline,
2705 &gauges,
2706 DrainScope::Endpoint(endpoint),
2707 )
2708 .await;
2709 forwarding
2713 .record_daemon_drain_result(
2714 endpoint,
2715 drained_after_quiescence_wait(&wait_result),
2716 )
2717 .map_err(SuperviseError::Forwarding)?;
2718 wait_result
2719 });
2720 }
2721 while let Ok(Some(result)) = timeout_at(deadline, waits.join_next()).await {
2722 if !matches!(result, Ok(Ok(true))) {
2723 warn!(?result, "daemon shutdown drain did not reach quiescence");
2724 }
2725 }
2726 Ok(())
2727 }
2728
2729 #[cfg(unix)]
2741 pub(crate) async fn end_children_for_daemon_shutdown(
2742 &self,
2743 already_escalated: bool,
2744 escalate: impl std::future::Future<Output = ()>,
2745 ) {
2746 tokio::pin!(escalate);
2747 let mut escalated = already_escalated;
2748 if let Some(forwarding) = &self.forwarding {
2749 let reason = CloseReason::new(
2750 "daemon_shutdown",
2751 "the daemon is exiting after its shutdown notice and drain",
2752 );
2753 if escalated {
2754 send_module_goodbyes_for_daemon_shutdown(forwarding, &reason, false).await;
2757 } else {
2758 tokio::select! {
2759 biased;
2760 _ = escalate.as_mut() => {
2761 info!("second SIGTERM: abandoning module GOODBYE delivery");
2762 escalated = true;
2763 }
2764 _ = send_module_goodbyes_for_daemon_shutdown(forwarding, &reason, true) => {}
2765 }
2766 }
2767 let closed = forwarding.close_all_connections(&reason);
2768 debug!(closed, "closed established connections for daemon shutdown");
2769 }
2770 let escalated_here = escalated && !already_escalated;
2774 let remaining_escalate = async move {
2775 if escalated_here {
2776 std::future::pending::<()>().await;
2777 } else {
2778 escalate.await;
2779 }
2780 };
2781 crate::child_roster::end_children_for_daemon_shutdown(
2782 &self.child_roster,
2783 escalated,
2784 remaining_escalate,
2785 )
2786 .await;
2787 }
2788
2789 pub fn new(registry: Arc<Registry>, restart_policy: RestartPolicy) -> Self {
2790 Self {
2791 registry,
2792 restart_policy,
2793 drain_timeout: DEFAULT_DRAIN_TIMEOUT,
2794 connection_file_path: None,
2795 capture_logs_dir: None,
2796 forwarding: None,
2797 process_liveness: Arc::new(SupervisorProcessLiveness::default()),
2798 supervisor_handle: None,
2799 health: HealthConfig::default(),
2800 daemon_start_clock: crate::clock::StartClock::capture(),
2801 terminal_journal: None,
2802 spawn_events: SpawnEventFeed::default(),
2803 provenance_probe: ExecutableIdentityProbe::default(),
2804 child_roster: ChildRoster::default(),
2805 #[cfg(target_os = "linux")]
2806 cgroup_placement: None,
2807 #[cfg(test)]
2808 test_after_first_spawn: AfterFirstSpawnHook::default(),
2809 }
2810 }
2811
2812 pub fn with_drain_timeout(mut self, drain_timeout: Duration) -> Self {
2813 self.drain_timeout = drain_timeout;
2814 self
2815 }
2816
2817 pub fn with_process_liveness(
2818 mut self,
2819 process_liveness: Arc<SupervisorProcessLiveness>,
2820 ) -> Self {
2821 self.process_liveness = process_liveness;
2822 self
2823 }
2824
2825 pub fn with_connection_file_path(mut self, connection_file_path: impl Into<PathBuf>) -> Self {
2826 self.connection_file_path = Some(connection_file_path.into());
2827 self
2828 }
2829
2830 pub fn with_capture_logs_dir(mut self, logs_dir: impl Into<PathBuf>) -> Self {
2832 self.capture_logs_dir = Some(logs_dir.into());
2833 self
2834 }
2835
2836 pub fn with_daemon_incarnation(self, daemon_incarnation: String) -> Self {
2839 self.spawn_events.configure_incarnation(daemon_incarnation);
2843 self
2844 }
2845
2846 pub fn with_terminal_journal(self, path: PathBuf, daemon_incarnation: String) -> Self {
2849 let mut this = self.with_daemon_incarnation(daemon_incarnation.clone());
2850 this.terminal_journal = Some(Arc::new(crate::terminal_journal::TerminalJournal::open(
2851 path,
2852 daemon_incarnation,
2853 )));
2854 this
2855 }
2856
2857 pub fn with_forwarding(mut self, forwarding: Arc<ForwardingTable>) -> Self {
2858 self.forwarding = Some(forwarding);
2859 self
2860 }
2861
2862 pub fn with_handle(mut self, supervisor_handle: SupervisorHandle) -> Self {
2863 self.spawn_events = supervisor_handle.spawn_events.clone();
2864 self.supervisor_handle = Some(supervisor_handle);
2865 self
2866 }
2867
2868 pub fn with_health_config(mut self, health: HealthConfig) -> Self {
2869 self.health = health;
2870 self
2871 }
2872
2873 pub fn with_live_children_record(self, path: impl Into<PathBuf>) -> Self {
2877 self.child_roster.record_to(path.into());
2878 self
2879 }
2880
2881 #[cfg(target_os = "linux")]
2882 pub fn with_cgroup_placement(
2883 mut self,
2884 cgroup_placement: Option<subc_cgroup::Placement>,
2885 ) -> Self {
2886 self.cgroup_placement = cgroup_placement;
2887 self
2888 }
2889
2890 pub fn spawn(&self, spec: ModuleSpec) -> Result<SupervisedModule, SuperviseError> {
2896 validate_spec(&spec)?;
2897 self.establish_identity(&spec);
2898
2899 let runtime = self.runtime_config();
2900 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
2901 let spawned = spawn_child(
2902 &spec,
2903 runtime.connection_file_path.as_deref(),
2904 self.supervisor_handle.as_ref(),
2905 &runtime.stderr_ring,
2906 runtime.capture_logs_dir.as_deref(),
2907 &runtime.child_roster,
2908 #[cfg(target_os = "linux")]
2909 runtime.cgroup_placement.as_ref(),
2910 );
2911 #[cfg(test)]
2912 self.test_after_first_spawn.run(&spec.module_id);
2913 let child = match spawned {
2914 Ok(child) => child,
2915 Err(err) => {
2916 self.abandon_unrostered(&spec.module_id);
2919 return Err(err);
2920 }
2921 };
2922 self.mark_first_process_running(&spec, &runtime, &snapshot, &child)?;
2923
2924 Ok(self.supervised_module(spec, runtime, snapshot, Some(child)))
2925 }
2926
2927 fn establish_identity(&self, spec: &ModuleSpec) {
2940 if let Some(supervisor_handle) = &self.supervisor_handle {
2941 supervisor_handle.apply_identity_configuration(spec);
2942 supervisor_handle.mark_configured(&spec.module_id);
2943 }
2944 }
2945
2946 fn abandon_unrostered(&self, module_id: &str) {
2949 if let Some(supervisor_handle) = &self.supervisor_handle {
2950 supervisor_handle.unmark_configured_unless_rostered(module_id);
2951 }
2952 }
2953
2954 fn mark_first_process_running(
2957 &self,
2958 spec: &ModuleSpec,
2959 runtime: &SupervisorRuntimeConfig,
2960 snapshot: &SharedSnapshot,
2961 child: &SupervisedChild,
2962 ) -> Result<(), SuperviseError> {
2963 if let Err(err) = set_running(snapshot, child, &spec.module_id, &runtime.spawn_events) {
2964 self.abandon_unrostered(&spec.module_id);
2965 return Err(err);
2966 }
2967 self.process_liveness
2968 .track(spec.module_id.clone(), Arc::clone(snapshot));
2969 Ok(())
2970 }
2971
2972 pub fn supervise_configured(
2978 &self,
2979 spec: ModuleSpec,
2980 enabled: bool,
2981 ) -> Result<SupervisedModule, SuperviseError> {
2982 validate_spec(&spec)?;
2983 self.establish_identity(&spec);
2984
2985 let runtime = self.runtime_config();
2986 if !enabled {
2987 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::disabled()));
2988 return Ok(self.supervised_module(spec, runtime, snapshot, None));
2989 }
2990
2991 #[cfg(target_os = "linux")]
2992 if let Some(pid) = runtime.child_roster.previous_instance_pid(&spec.module_id) {
2993 let mut snapshot = SupervisorSnapshot::starting();
2994 snapshot.waiting_for_previous_pid = Some(pid);
2995 return Ok(self.supervised_module(spec, runtime, Arc::new(Mutex::new(snapshot)), None));
2996 }
2997
2998 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
2999 let spawned = spawn_child(
3000 &spec,
3001 runtime.connection_file_path.as_deref(),
3002 self.supervisor_handle.as_ref(),
3003 &runtime.stderr_ring,
3004 runtime.capture_logs_dir.as_deref(),
3005 &runtime.child_roster,
3006 #[cfg(target_os = "linux")]
3007 runtime.cgroup_placement.as_ref(),
3008 );
3009 #[cfg(test)]
3010 self.test_after_first_spawn.run(&spec.module_id);
3011 match spawned {
3012 Ok(child) => {
3013 self.mark_first_process_running(&spec, &runtime, &snapshot, &child)?;
3014 Ok(self.supervised_module(spec, runtime, snapshot, Some(child)))
3015 }
3016 Err(err) => {
3017 error!(
3018 module_id = %spec.module_id,
3019 program = %spec.program.display(),
3020 error = %err,
3021 "configured module failed to spawn; marking failed and continuing"
3022 );
3023 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::failed()));
3024 Ok(self.supervised_module(spec, runtime, snapshot, None))
3025 }
3026 }
3027 }
3028
3029 pub fn supervise_configured_with_health(
3035 &self,
3036 spec: ModuleSpec,
3037 enabled: bool,
3038 health: HealthConfig,
3039 drain_timeout_ms: Option<u64>,
3040 restart_policy: RestartPolicy,
3041 ) -> Result<SupervisedModule, SuperviseError> {
3042 validate_spec(&spec)?;
3043 self.establish_identity(&spec);
3044
3045 let mut runtime = self.runtime_config();
3046 runtime.health = health.clone();
3047 runtime.restart_policy = restart_policy;
3048 if let Some(ms) = drain_timeout_ms {
3049 runtime.drain_timeout = Duration::from_millis(ms);
3050 *runtime
3051 .effective_drain_timeout
3052 .lock()
3053 .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.drain_timeout;
3054 }
3055 if !enabled {
3056 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::disabled()));
3057 return Ok(self.supervised_module(spec, runtime, snapshot, None));
3058 }
3059
3060 #[cfg(target_os = "linux")]
3061 if let Some(pid) = runtime.child_roster.previous_instance_pid(&spec.module_id) {
3062 let mut snapshot = SupervisorSnapshot::starting();
3063 snapshot.waiting_for_previous_pid = Some(pid);
3064 return Ok(self.supervised_module(spec, runtime, Arc::new(Mutex::new(snapshot)), None));
3065 }
3066
3067 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
3068 let spawned = spawn_child(
3069 &spec,
3070 runtime.connection_file_path.as_deref(),
3071 self.supervisor_handle.as_ref(),
3072 &runtime.stderr_ring,
3073 runtime.capture_logs_dir.as_deref(),
3074 &runtime.child_roster,
3075 #[cfg(target_os = "linux")]
3076 runtime.cgroup_placement.as_ref(),
3077 );
3078 #[cfg(test)]
3079 self.test_after_first_spawn.run(&spec.module_id);
3080 match spawned {
3081 Ok(child) => {
3082 self.mark_first_process_running(&spec, &runtime, &snapshot, &child)?;
3083 Ok(self.supervised_module(spec, runtime, snapshot, Some(child)))
3084 }
3085 Err(err) => {
3086 if health.critical {
3087 error!(
3088 module_id = %spec.module_id,
3089 program = %spec.program.display(),
3090 error = %err,
3091 "critical configured module failed to spawn; marking failed and alerting"
3092 );
3093 } else {
3094 error!(
3095 module_id = %spec.module_id,
3096 program = %spec.program.display(),
3097 error = %err,
3098 "configured module failed to spawn; marking failed and continuing"
3099 );
3100 }
3101 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::failed()));
3102 Ok(self.supervised_module(spec, runtime, snapshot, None))
3103 }
3104 }
3105 }
3106
3107 fn runtime_config(&self) -> SupervisorRuntimeConfig {
3108 let effective_drain_timeout = Arc::new(Mutex::new(self.drain_timeout));
3109 SupervisorRuntimeConfig {
3110 scheduled_respawn: Arc::default(),
3111 deferred_reload_reply: Arc::default(),
3112 restart_policy: self.restart_policy,
3113 drain_timeout: self.drain_timeout,
3114 child_roster: self
3117 .child_roster
3118 .for_module(Arc::clone(&effective_drain_timeout)),
3119 effective_drain_timeout,
3120 default_drain_timeout: self.drain_timeout,
3121 health: self.health.clone(),
3122 connection_file_path: self.connection_file_path.clone(),
3123 capture_logs_dir: self.capture_logs_dir.clone(),
3124 forwarding: self.forwarding.clone(),
3125 supervisor_handle: self.supervisor_handle.clone(),
3126 stderr_ring: Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default()))),
3127 terminal_ring: Arc::new(Mutex::new(
3128 TerminalRing::new(
3129 TerminalRingConfig::default(),
3130 self.daemon_start_clock.started_at_ms(),
3131 )
3132 .with_start_clock(self.daemon_start_clock)
3133 .with_journal(self.terminal_journal.clone())
3134 .with_daemon_shutdown(self.child_roster.shutdown_flag()),
3135 )),
3136 spawn_events: self.spawn_events.clone(),
3137 #[cfg(target_os = "linux")]
3138 cgroup_placement: self.cgroup_placement.clone(),
3139 #[cfg(test)]
3140 test_seed_stale_facts_before_enable_spawn: false,
3141 #[cfg(test)]
3142 test_reload_exit_record_gate: None,
3143 }
3144 }
3145
3146 fn supervised_module(
3147 &self,
3148 spec: ModuleSpec,
3149 runtime: SupervisorRuntimeConfig,
3150 snapshot: SharedSnapshot,
3151 child: Option<SupervisedChild>,
3152 ) -> SupervisedModule {
3153 let configuration = Arc::new(Mutex::new(SupervisedConfiguration {
3154 spec: spec.clone(),
3155 health: runtime.health.clone(),
3156 }));
3157 let stderr_ring = Arc::clone(&runtime.stderr_ring);
3158 let terminal_ring = Arc::clone(&runtime.terminal_ring);
3159 let restart_policy = runtime.restart_policy;
3163 let effective_drain_timeout = Arc::clone(&runtime.effective_drain_timeout);
3164 let (tx, rx) = mpsc::channel(4);
3165 let monitor = tokio::spawn(supervise_loop(
3166 spec.clone(),
3167 runtime,
3168 Arc::clone(&self.registry),
3169 Arc::clone(&self.process_liveness),
3170 Arc::clone(&snapshot),
3171 child,
3172 rx,
3173 ));
3174
3175 let module_id = spec.module_id.clone();
3176 let module = SupervisedModule {
3177 inner: Arc::new(SupervisedModuleInner {
3178 module_id: module_id.clone(),
3179 registry: Arc::clone(&self.registry),
3180 snapshot,
3181 configuration,
3182 stderr_ring,
3183 terminal_ring,
3184 commands: tx,
3185 monitor: Mutex::new(Some(monitor)),
3186 restart_policy,
3187 effective_drain_timeout,
3188 provenance_probe: self.provenance_probe.clone(),
3189 }),
3190 };
3191 if let Some(supervisor_handle) = &self.supervisor_handle {
3195 supervisor_handle.insert(module.clone());
3196 }
3197 module
3198 }
3199}
3200
3201impl Default for Supervisor {
3202 fn default() -> Self {
3203 Self::new(Arc::new(Registry::default()), RestartPolicy::default())
3204 }
3205}
3206
3207#[derive(Clone)]
3209pub struct SupervisedModule {
3210 inner: Arc<SupervisedModuleInner>,
3211}
3212
3213struct SupervisedModuleInner {
3214 module_id: String,
3215 registry: Arc<Registry>,
3216 snapshot: SharedSnapshot,
3217 configuration: Arc<Mutex<SupervisedConfiguration>>,
3218 stderr_ring: Arc<Mutex<StderrRing>>,
3219 terminal_ring: Arc<Mutex<TerminalRing>>,
3220 commands: mpsc::Sender<SupervisorCommand>,
3221 monitor: Mutex<Option<JoinHandle<()>>>,
3222 restart_policy: RestartPolicy,
3226 effective_drain_timeout: Arc<Mutex<Duration>>,
3227 provenance_probe: ExecutableIdentityProbe,
3228}
3229
3230impl fmt::Debug for SupervisedModule {
3231 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3232 f.debug_struct("SupervisedModule")
3233 .field("module_id", &self.inner.module_id)
3234 .field("status", &self.status())
3235 .finish_non_exhaustive()
3236 }
3237}
3238
3239impl SupervisedModule {
3240 pub fn module_id(&self) -> &str {
3241 &self.inner.module_id
3242 }
3243
3244 #[cfg(test)]
3248 pub(crate) fn record_health_probe_failure_for_test(
3249 &self,
3250 detail: &str,
3251 ) -> Result<(), SuperviseError> {
3252 update_snapshot(&self.inner.snapshot, Some(&self.inner.module_id), |state| {
3253 state.health.consecutive_failures = state.health.consecutive_failures.saturating_add(1);
3254 state.health.detail = Some(detail.to_string());
3255 })
3256 }
3257
3258 pub fn state(&self) -> Result<ModuleState, SuperviseError> {
3259 Ok(lock_snapshot(&self.inner.snapshot)?.state)
3260 }
3261
3262 pub(crate) fn registration_end_reason(
3263 &self,
3264 ) -> Result<Option<RegistrationEndReason>, SuperviseError> {
3265 let snapshot = lock_snapshot(&self.inner.snapshot)?;
3266 Ok(match snapshot.state {
3267 ModuleState::Draining if snapshot.draining_to_replace => {
3268 Some(RegistrationEndReason::SupervisorRestart)
3269 }
3270 ModuleState::Draining => Some(RegistrationEndReason::SupervisorStop),
3271 ModuleState::Restarting if snapshot.draining_to_replace => {
3272 Some(RegistrationEndReason::SupervisorRestart)
3273 }
3274 _ => None,
3275 })
3276 }
3277
3278 pub fn stderr_tail(
3285 &self,
3286 max_lines: Option<usize>,
3287 max_bytes: Option<usize>,
3288 ) -> StderrTailSnapshot {
3289 self.inner
3290 .stderr_ring
3291 .lock()
3292 .unwrap_or_else(|poisoned| poisoned.into_inner())
3293 .snapshot(max_lines, max_bytes)
3294 }
3295
3296 pub fn terminal_history(&self) -> TerminalHistorySnapshot {
3301 self.inner
3302 .terminal_ring
3303 .lock()
3304 .unwrap_or_else(|poisoned| poisoned.into_inner())
3305 .snapshot()
3306 }
3307
3308 pub fn durable_terminal_history(&self) -> subc_control::TerminalHistory {
3313 durable_terminal_history_of(&self.inner.terminal_ring, &self.inner.module_id)
3314 }
3315
3316 pub(crate) async fn read_durable_terminal_history(
3321 &self,
3322 ) -> Result<subc_control::TerminalHistory, tokio::task::JoinError> {
3323 let terminal_ring = Arc::clone(&self.inner.terminal_ring);
3324 let module_id = self.inner.module_id.clone();
3325 tokio::task::spawn_blocking(move || durable_terminal_history_of(&terminal_ring, &module_id))
3326 .await
3327 }
3328
3329 pub fn status(&self) -> Result<ModuleStatus, SuperviseError> {
3330 self.status_with_snapshot_lock(&self.inner.snapshot, None)
3331 .map(|(status, _)| status)
3332 }
3333
3334 pub(crate) fn record_deliberate_severance(
3335 &self,
3336 identity: ProcessIdentity,
3337 ) -> Result<bool, SuperviseError> {
3338 let mut snapshot = lock_snapshot(&self.inner.snapshot)?;
3339 if snapshot.pid != Some(identity.pid)
3340 || snapshot.process_start_time != Some(identity.start_time)
3341 {
3342 return Ok(false);
3343 }
3344 snapshot.deliberate_severance = Some(identity);
3345 Ok(true)
3346 }
3347
3348 pub(crate) fn status_for_control(
3353 &self,
3354 caller: &'static str,
3355 ) -> Result<ModuleStatus, SuperviseError> {
3356 self.status_with_snapshot_lock(&self.inner.snapshot, Some(caller))
3357 .map(|(status, _)| status)
3358 }
3359
3360 fn status_with_snapshot_lock(
3361 &self,
3362 snapshot: &SharedSnapshot,
3363 caller: Option<&'static str>,
3364 ) -> Result<(ModuleStatus, Option<SpawnedFileIdentity>), SuperviseError> {
3365 let mut guard = match caller {
3366 Some(caller) => lock_snapshot_for_control(snapshot, &self.inner.module_id, caller)?,
3367 None => lock_snapshot(snapshot)?,
3368 };
3369 let restart_count =
3372 guard.crash_restarts_in_window(self.inner.restart_policy.window, Instant::now());
3373 let snapshot = guard.clone();
3374 drop(guard);
3375 let drain_timeout = *self.inner.effective_drain_timeout.lock().map_err(|_| {
3376 SuperviseError::StatePoisoned {
3377 module_id: Some(self.inner.module_id.clone()),
3378 }
3379 })?;
3380 let registration_active = self
3381 .inner
3382 .registry
3383 .get_module(&self.inner.module_id)
3384 .map_err(SuperviseError::Registry)?
3385 .is_some();
3386 let protocol = snapshot
3387 .spawned_protocol
3388 .unwrap_or(self.declared_protocol()?);
3389 let running_process =
3390 snapshot.enabled && snapshot.state == ModuleState::Running && snapshot.process_alive;
3391 let live = match protocol {
3397 ModuleProtocol::Subc => running_process && registration_active,
3398 ModuleProtocol::None => running_process,
3399 };
3400
3401 Ok((
3402 ModuleStatus {
3403 waiting_for_previous_pid: snapshot.waiting_for_previous_pid,
3404 module_id: self.inner.module_id.clone(),
3405 launch_nonce_source: observed_launch_nonce_source(&snapshot),
3406 state: snapshot.state,
3407 enabled: snapshot.enabled,
3408 process_alive: snapshot.process_alive,
3409 registration_active,
3410 protocol,
3411 live,
3412 restart_count,
3413 lifetime_restarts: snapshot.lifetime_restarts,
3414 spawn_generation: snapshot.spawn_generation,
3415 max_restarts: self.inner.restart_policy.max_restarts,
3416 restart_window: self.inner.restart_policy.window,
3417 drain_timeout,
3418 restart_backoff: self.inner.restart_policy.backoff,
3419 restart_max_backoff: self.inner.restart_policy.max_backoff,
3420 pid: snapshot.reported_pid(),
3421 spawned_at_ms: snapshot.spawned_at_ms,
3422 spawned_from: snapshot.spawned_from,
3423 process_start_time: snapshot.process_start_time,
3424 last_exit: snapshot.last_exit,
3425 health: snapshot.health,
3426 },
3427 snapshot.spawned_file_identity.clone(),
3428 ))
3429 }
3430
3431 #[cfg(test)]
3432 pub(crate) fn hold_snapshot_for_test(
3433 &self,
3434 acquired: std::sync::mpsc::Sender<()>,
3435 hold: Duration,
3436 ) -> std::thread::JoinHandle<()> {
3437 let snapshot = Arc::clone(&self.inner.snapshot);
3438 std::thread::spawn(move || {
3439 let _guard = snapshot.lock().expect("test snapshot lock is not poisoned");
3440 acquired
3441 .send(())
3442 .expect("test receiver waits for snapshot lock");
3443 std::thread::sleep(hold);
3444 })
3445 }
3446
3447 pub(crate) async fn status_and_running_image_agreement(
3453 &self,
3454 ) -> Result<(ModuleStatus, subc_control::RunningImageAgreement), SuperviseError> {
3455 let (status, identity) = self.status_with_snapshot_lock(&self.inner.snapshot, None)?;
3456 let image = self
3457 .inner
3458 .provenance_probe
3459 .observe(
3460 status.pid,
3461 status.spawned_from.as_deref(),
3462 identity,
3463 status.process_start_time,
3464 )
3465 .await;
3466 Ok((status, image))
3467 }
3468
3469 pub(crate) async fn running_image_agreement(&self) -> subc_control::RunningImageAgreement {
3470 let snapshot = match lock_snapshot(&self.inner.snapshot) {
3471 Ok(snapshot) => snapshot.clone(),
3472 Err(_) => {
3473 return subc_control::RunningImageAgreement::Unavailable {
3474 reason: subc_control::RunningImageUnavailableReason::NotRunning,
3475 };
3476 }
3477 };
3478 self.inner
3479 .provenance_probe
3480 .observe(
3481 snapshot.reported_pid(),
3482 snapshot.spawned_from.as_deref(),
3483 snapshot.spawned_file_identity.clone(),
3484 snapshot.process_start_time,
3485 )
3486 .await
3487 }
3488
3489 pub(crate) fn child_resource_usage(&self) -> subc_control::ChildResourceUsage {
3492 #[cfg(windows)]
3493 {
3494 let image = lock_snapshot(&self.inner.snapshot)
3495 .ok()
3496 .and_then(|snapshot| {
3497 snapshot
3498 .spawned_file_identity
3499 .as_ref()
3500 .and_then(|identity| identity.windows_image.clone())
3501 });
3502 match image {
3503 Some(image) => {
3504 crate::child_resources::read_windows_handle(image.process(), image.start_time())
3505 }
3506 None => subc_control::ChildResourceUsage::Unavailable {
3507 reason: subc_control::ChildResourceUnavailableReason::NotRunning,
3508 },
3509 }
3510 }
3511 #[cfg(not(windows))]
3512 {
3513 let (pid, start_time) = match lock_snapshot(&self.inner.snapshot) {
3514 Ok(snapshot) => (snapshot.reported_pid(), snapshot.process_start_time),
3515 Err(_) => {
3516 return subc_control::ChildResourceUsage::Unavailable {
3517 reason: subc_control::ChildResourceUnavailableReason::Unreadable,
3518 }
3519 }
3520 };
3521 crate::child_resources::read(pid, start_time)
3522 }
3523 }
3524
3525 pub(crate) fn will_recover_after_connection_loss(&self) -> Result<bool, SuperviseError> {
3526 let mut snapshot = lock_snapshot(&self.inner.snapshot)?;
3527 Ok(match snapshot.state {
3528 ModuleState::Restarting => true,
3529 ModuleState::Failed | ModuleState::Disabled => false,
3530 _ => daemon_will_restart(&mut snapshot, &self.inner.restart_policy, Instant::now()),
3531 })
3532 }
3533
3534 #[cfg(test)]
3535 pub(crate) fn is_warming(&self) -> Result<bool, SuperviseError> {
3536 self.is_warming_with_snapshot_lock(None)
3537 }
3538
3539 pub(crate) fn is_warming_for_control(
3540 &self,
3541 caller: &'static str,
3542 ) -> Result<bool, SuperviseError> {
3543 self.is_warming_with_snapshot_lock(Some(caller))
3544 }
3545
3546 fn is_warming_with_snapshot_lock(
3547 &self,
3548 caller: Option<&'static str>,
3549 ) -> Result<bool, SuperviseError> {
3550 let snapshot = match caller {
3551 Some(caller) => {
3552 lock_snapshot_for_control(&self.inner.snapshot, &self.inner.module_id, caller)?
3553 }
3554 None => lock_snapshot(&self.inner.snapshot)?,
3555 }
3556 .clone();
3557 Ok(matches!(
3558 snapshot.state,
3559 ModuleState::Starting | ModuleState::Running | ModuleState::Restarting
3560 ))
3561 }
3562
3563 pub async fn drain(&self) -> Result<(), SuperviseError> {
3565 self.stop().await
3566 }
3567
3568 pub(crate) async fn retire(&self) -> Result<(), SuperviseError> {
3569 match self.state()? {
3570 ModuleState::Stopped | ModuleState::Failed => return Ok(()),
3571 ModuleState::Starting
3572 | ModuleState::Running
3573 | ModuleState::Unresponsive
3574 | ModuleState::Restarting
3575 | ModuleState::Draining
3576 | ModuleState::Disabled => {}
3577 }
3578
3579 let (reply_tx, reply_rx) = oneshot::channel();
3580 self.inner
3581 .commands
3582 .send(SupervisorCommand::Retire { reply: reply_tx })
3583 .await
3584 .map_err(|_| SuperviseError::CommandClosed {
3585 module_id: self.inner.module_id.clone(),
3586 })?;
3587 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3588 module_id: self.inner.module_id.clone(),
3589 })?
3590 }
3591
3592 pub async fn stop(&self) -> Result<(), SuperviseError> {
3593 match self.state()? {
3594 ModuleState::Stopped | ModuleState::Failed => return Ok(()),
3595 ModuleState::Starting
3596 | ModuleState::Running
3597 | ModuleState::Unresponsive
3598 | ModuleState::Restarting
3599 | ModuleState::Draining
3600 | ModuleState::Disabled => {}
3601 }
3602
3603 let (reply_tx, reply_rx) = oneshot::channel();
3604 self.inner
3605 .commands
3606 .send(SupervisorCommand::Drain { reply: reply_tx })
3607 .await
3608 .map_err(|_| SuperviseError::CommandClosed {
3609 module_id: self.inner.module_id.clone(),
3610 })?;
3611 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3612 module_id: self.inner.module_id.clone(),
3613 })?
3614 }
3615
3616 pub async fn restart(&self, drain_timeout_ms: Option<u64>) -> Result<(), SuperviseError> {
3617 let received_at_generation = lock_snapshot(&self.inner.snapshot)?.spawn_generation;
3618 let (reply_tx, reply_rx) = oneshot::channel();
3619 self.inner
3620 .commands
3621 .send(SupervisorCommand::Restart {
3622 drain_timeout_ms,
3623 received_at_generation,
3624 queued_at: Instant::now(),
3625 reply: reply_tx,
3626 })
3627 .await
3628 .map_err(|_| SuperviseError::CommandClosed {
3629 module_id: self.inner.module_id.clone(),
3630 })?;
3631 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3632 module_id: self.inner.module_id.clone(),
3633 })?
3634 }
3635
3636 pub async fn swap(&self, ready_timeout: Option<Duration>) -> Result<(), SuperviseError> {
3641 let (reply_tx, reply_rx) = oneshot::channel();
3642 self.inner
3643 .commands
3644 .send(SupervisorCommand::Swap {
3645 ready_timeout,
3646 reply: reply_tx,
3647 })
3648 .await
3649 .map_err(|_| SuperviseError::CommandClosed {
3650 module_id: self.inner.module_id.clone(),
3651 })?;
3652 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3653 module_id: self.inner.module_id.clone(),
3654 })?
3655 }
3656
3657 pub async fn reload(&self) -> Result<(), SuperviseError> {
3658 let (reply_tx, reply_rx) = oneshot::channel();
3659 self.inner
3660 .commands
3661 .send(SupervisorCommand::Reload { reply: reply_tx })
3662 .await
3663 .map_err(|_| SuperviseError::CommandClosed {
3664 module_id: self.inner.module_id.clone(),
3665 })?;
3666 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3667 module_id: self.inner.module_id.clone(),
3668 })?
3669 }
3670
3671 pub async fn set_enabled(&self, enabled: bool) -> Result<bool, SuperviseError> {
3672 let (reply_tx, reply_rx) = oneshot::channel();
3673 self.inner
3674 .commands
3675 .send(SupervisorCommand::SetEnabled {
3676 enabled,
3677 reply: reply_tx,
3678 })
3679 .await
3680 .map_err(|_| SuperviseError::CommandClosed {
3681 module_id: self.inner.module_id.clone(),
3682 })?;
3683 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3684 module_id: self.inner.module_id.clone(),
3685 })?
3686 }
3687
3688 pub(crate) fn declared_protocol(&self) -> Result<ModuleProtocol, SuperviseError> {
3692 let configured = self
3693 .inner
3694 .configuration
3695 .lock()
3696 .map_err(|_| SuperviseError::StatePoisoned {
3697 module_id: Some(self.inner.module_id.clone()),
3698 })?
3699 .spec
3700 .protocol;
3701 let state = lock_snapshot(&self.inner.snapshot)?;
3702 Ok(state.spawned_protocol.unwrap_or(configured))
3703 }
3704
3705 pub(crate) fn configuration(&self) -> Result<(ModuleSpec, HealthConfig), SuperviseError> {
3706 let configuration =
3707 self.inner
3708 .configuration
3709 .lock()
3710 .map_err(|_| SuperviseError::StatePoisoned {
3711 module_id: Some(self.inner.module_id.clone()),
3712 })?;
3713 Ok((configuration.spec.clone(), configuration.health.clone()))
3714 }
3715
3716 #[cfg(any(test, feature = "test-support"))]
3720 pub async fn update_spec_for_test(&self, spec: ModuleSpec) -> Result<(), SuperviseError> {
3721 let (_, health) = self.configuration()?;
3722 let drain_timeout_ms = u64::try_from(
3723 self.inner
3724 .effective_drain_timeout
3725 .lock()
3726 .unwrap_or_else(|poisoned| poisoned.into_inner())
3727 .as_millis(),
3728 )
3729 .ok();
3730 self.update_configuration(spec, health, drain_timeout_ms)
3731 .await
3732 }
3733
3734 pub(crate) async fn update_configuration(
3735 &self,
3736 spec: ModuleSpec,
3737 health: HealthConfig,
3738 drain_timeout_ms: Option<u64>,
3739 ) -> Result<(), SuperviseError> {
3740 if spec.module_id != self.inner.module_id {
3741 return Err(SuperviseError::InvalidSpec {
3742 reason: "a supervised module's module_id cannot be changed".to_string(),
3743 });
3744 }
3745 validate_spec(&spec)?;
3746 let (reply_tx, reply_rx) = oneshot::channel();
3747 self.inner
3748 .commands
3749 .send(SupervisorCommand::UpdateConfiguration {
3750 spec: spec.clone(),
3751 health: health.clone(),
3752 drain_timeout_ms,
3753 reply: reply_tx,
3754 })
3755 .await
3756 .map_err(|_| SuperviseError::CommandClosed {
3757 module_id: self.inner.module_id.clone(),
3758 })?;
3759 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3760 module_id: self.inner.module_id.clone(),
3761 })?;
3762 let mut configuration =
3763 self.inner
3764 .configuration
3765 .lock()
3766 .map_err(|_| SuperviseError::StatePoisoned {
3767 module_id: Some(self.inner.module_id.clone()),
3768 })?;
3769 configuration.spec = spec;
3770 configuration.health = health;
3771 Ok(())
3772 }
3773}
3774
3775impl Drop for SupervisedModuleInner {
3776 fn drop(&mut self) {
3777 let Ok(mut monitor) = self.monitor.lock() else {
3778 return;
3779 };
3780 if let Some(monitor) = monitor.as_ref().filter(|monitor| !monitor.is_finished()) {
3781 let _ = update_snapshot(&self.snapshot, Some(&self.module_id), |state| {
3782 state.state = ModuleState::Stopped;
3783 clear_current_process_facts(state);
3784 });
3785 monitor.abort();
3786 }
3787 let _ = monitor.take();
3788 }
3789}
3790
3791#[derive(Debug)]
3792enum SupervisorCommand {
3793 Drain {
3794 reply: oneshot::Sender<Result<(), SuperviseError>>,
3795 },
3796 Retire {
3797 reply: oneshot::Sender<Result<(), SuperviseError>>,
3798 },
3799 Restart {
3800 drain_timeout_ms: Option<u64>,
3805 received_at_generation: u64,
3809 queued_at: Instant,
3812 reply: oneshot::Sender<Result<(), SuperviseError>>,
3813 },
3814 Reload {
3815 reply: oneshot::Sender<Result<(), SuperviseError>>,
3816 },
3817 SetEnabled {
3818 enabled: bool,
3819 reply: oneshot::Sender<Result<bool, SuperviseError>>,
3820 },
3821 UpdateConfiguration {
3822 spec: ModuleSpec,
3823 health: HealthConfig,
3824 drain_timeout_ms: Option<u64>,
3827 reply: oneshot::Sender<()>,
3828 },
3829 Swap {
3830 ready_timeout: Option<Duration>,
3833 reply: oneshot::Sender<Result<(), SuperviseError>>,
3835 },
3836}
3837
3838#[derive(Debug)]
3839pub enum SuperviseError {
3840 InvalidSpec {
3841 reason: String,
3842 },
3843 Spawn {
3844 program: PathBuf,
3845 source: io::Error,
3846 cgroup_path: Option<PathBuf>,
3847 },
3848 Cgroup {
3849 module_id: String,
3850 source: io::Error,
3851 },
3852 LaunchNonce {
3855 reason: String,
3856 },
3857 Wait {
3858 module_id: String,
3859 source: io::Error,
3860 },
3861 Kill {
3862 module_id: String,
3863 source: io::Error,
3864 },
3865 Forwarding(ForwardingError),
3866 Registry(RegistryError),
3867 ReloadUnavailable {
3868 module_id: String,
3869 reason: String,
3870 },
3871 Disabled {
3876 module_id: String,
3877 },
3878 ReloadFailed {
3879 module_id: String,
3880 reason: String,
3881 },
3882 RegistrationStillActive {
3883 module_id: String,
3884 waited: Duration,
3885 },
3886 StatePoisoned {
3887 module_id: Option<String>,
3888 },
3889 CommandClosed {
3890 module_id: String,
3891 },
3892 SwapInProgress {
3896 module_id: String,
3897 },
3898 SwapRefused {
3900 module_id: String,
3901 reason: SwapRefusal,
3902 },
3903 SwapFailed {
3907 module_id: String,
3908 arm: SwapFailureArm,
3909 detail: String,
3910 candidate_exit: Option<ExitReport>,
3913 },
3914}
3915
3916#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3918pub enum SwapRefusal {
3919 OverlapExclusive,
3921 NotRegistered,
3924 ProtocolNone,
3927 NotConfigured,
3930 AlreadySwapping,
3932}
3933
3934impl SwapRefusal {
3935 pub fn as_str(self) -> &'static str {
3936 match self {
3937 Self::OverlapExclusive => "overlap_exclusive",
3938 Self::NotRegistered => "not_registered",
3939 Self::ProtocolNone => "protocol_none",
3940 Self::NotConfigured => "not_configured",
3941 Self::AlreadySwapping => "already_swapping",
3942 }
3943 }
3944}
3945
3946#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3949pub enum SwapFailureArm {
3950 SpawnFailed,
3952 NeverRegistered,
3954 NeverReady,
3956 CandidateExited,
3958 CandidateUnhealthy,
3960 Interrupted,
3964 CutoverLost,
3969}
3970
3971impl SwapFailureArm {
3972 pub fn as_str(self) -> &'static str {
3973 match self {
3974 Self::SpawnFailed => "spawn_failed",
3975 Self::NeverRegistered => "never_registered",
3976 Self::NeverReady => "never_ready",
3977 Self::CandidateExited => "candidate_exited",
3978 Self::CandidateUnhealthy => "candidate_unhealthy",
3979 Self::Interrupted => "interrupted",
3980 Self::CutoverLost => "cutover_lost",
3981 }
3982 }
3983}
3984
3985impl fmt::Display for SuperviseError {
3986 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3987 match self {
3988 Self::InvalidSpec { reason } => write!(f, "invalid module spec: {reason}"),
3989 Self::Spawn {
3990 program,
3991 source,
3992 cgroup_path: Some(cgroup_path),
3993 } => write!(
3994 f,
3995 "failed to place module in cgroup '{}' while spawning '{}': {source}",
3996 cgroup_path.display(),
3997 program.display()
3998 ),
3999 Self::Spawn {
4000 program,
4001 source,
4002 cgroup_path: None,
4003 } => write!(
4004 f,
4005 "failed to spawn module '{}': {source}",
4006 program.display()
4007 ),
4008 Self::Cgroup { module_id, source } => {
4009 write!(
4010 f,
4011 "failed to prepare cgroup for module '{module_id}': {source}"
4012 )
4013 }
4014 Self::LaunchNonce { reason } => {
4015 write!(
4016 f,
4017 "failed to generate reserved-module launch nonce: {reason}"
4018 )
4019 }
4020 Self::Wait { module_id, source } => {
4021 write!(f, "failed to wait for module '{module_id}': {source}")
4022 }
4023 Self::Kill { module_id, source } => {
4024 write!(f, "failed to kill module '{module_id}': {source}")
4025 }
4026 Self::Forwarding(err) => write!(f, "forwarding error: {err}"),
4027 Self::Registry(err) => write!(f, "registry error: {err}"),
4028 Self::ReloadUnavailable { module_id, reason } => {
4029 write!(f, "reload unavailable for module '{module_id}': {reason}")
4030 }
4031 Self::Disabled { module_id } => {
4032 write!(
4033 f,
4034 "module '{module_id}' is disabled; enable it before restart or reload"
4035 )
4036 }
4037 Self::ReloadFailed { module_id, reason } => {
4038 write!(f, "reload failed for module '{module_id}': {reason}")
4039 }
4040 Self::RegistrationStillActive { module_id, waited } => write!(
4041 f,
4042 "module '{module_id}' registration remained active after waiting {waited:?}"
4043 ),
4044 Self::StatePoisoned { module_id } => match module_id {
4045 Some(module_id) => {
4046 write!(f, "supervisor state for module '{module_id}' was poisoned")
4047 }
4048 None => write!(f, "supervisor state was poisoned"),
4049 },
4050 Self::CommandClosed { module_id } => {
4051 write!(
4052 f,
4053 "supervisor command channel for module '{module_id}' is closed"
4054 )
4055 }
4056 Self::SwapInProgress { module_id } => write!(
4057 f,
4058 "module '{module_id}' is being swapped; retry once the swap has cut over or failed, or stop the module to abort the swap"
4059 ),
4060 Self::SwapRefused { module_id, reason } => match reason {
4061 SwapRefusal::OverlapExclusive => write!(
4062 f,
4063 "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"
4064 ),
4065 SwapRefusal::NotRegistered => write!(
4066 f,
4067 "module '{module_id}' is not registered, so there is no serving process to keep while a replacement warms; use a plain restart"
4068 ),
4069 SwapRefusal::ProtocolNone => write!(
4070 f,
4071 "module '{module_id}' is protocol: \"none\" and never registers, so a swap could never see its replacement become ready; use a plain restart"
4072 ),
4073 SwapRefusal::NotConfigured => write!(
4074 f,
4075 "module '{module_id}' cannot be swapped: the supervisor was built without the forwarding table or shared handle a swap needs"
4076 ),
4077 SwapRefusal::AlreadySwapping => {
4078 write!(f, "module '{module_id}' is already being swapped")
4079 }
4080 },
4081 Self::SwapFailed {
4082 module_id,
4083 arm,
4084 detail,
4085 ..
4086 } => write!(
4087 f,
4088 "swap of module '{module_id}' failed ({}): {detail}; the running process was left serving",
4089 arm.as_str()
4090 ),
4091 }
4092 }
4093}
4094
4095impl Error for SuperviseError {
4096 fn source(&self) -> Option<&(dyn Error + 'static)> {
4097 match self {
4098 Self::Spawn { source, .. }
4099 | Self::Cgroup { source, .. }
4100 | Self::Wait { source, .. }
4101 | Self::Kill { source, .. } => Some(source),
4102 Self::Forwarding(err) => Some(err),
4103 Self::Registry(err) => Some(err),
4104 Self::LaunchNonce { .. }
4105 | Self::InvalidSpec { .. }
4106 | Self::ReloadUnavailable { .. }
4107 | Self::Disabled { .. }
4108 | Self::ReloadFailed { .. }
4109 | Self::RegistrationStillActive { .. }
4110 | Self::StatePoisoned { .. }
4111 | Self::CommandClosed { .. }
4112 | Self::SwapInProgress { .. }
4113 | Self::SwapRefused { .. }
4114 | Self::SwapFailed { .. } => None,
4115 }
4116 }
4117}
4118
4119pub(crate) fn validate_spec(spec: &ModuleSpec) -> Result<(), SuperviseError> {
4120 if spec.module_id.trim().is_empty() {
4121 return Err(SuperviseError::InvalidSpec {
4122 reason: "module_id must not be empty".to_string(),
4123 });
4124 }
4125
4126 Ok(())
4127}
4128
4129#[derive(Debug, Default)]
4130struct HealthProbeRuntime {
4131 configured_health: Option<HealthConfig>,
4132 registered_connection: Option<crate::ConnectionId>,
4133 advertised: bool,
4134 next_probe_at: Option<Instant>,
4135 probe_index: u64,
4136}
4137
4138fn running_protocol(spec: &ModuleSpec, snapshot: &SharedSnapshot) -> ModuleProtocol {
4139 lock_snapshot(snapshot)
4140 .ok()
4141 .and_then(|state| state.spawned_protocol)
4142 .unwrap_or(spec.protocol)
4143}
4144
4145impl HealthProbeRuntime {
4146 fn refresh_registration(
4147 &mut self,
4148 spec: &ModuleSpec,
4149 runtime: &SupervisorRuntimeConfig,
4150 registry: &Registry,
4151 snapshot: &SharedSnapshot,
4152 ) {
4153 if self.configured_health.as_ref() != Some(&runtime.health) {
4154 self.configured_health = Some(runtime.health.clone());
4155 self.next_probe_at = None;
4156 self.registered_connection = None;
4157 self.probe_index = 0;
4158 }
4159 if running_protocol(spec, snapshot) == ModuleProtocol::None {
4163 self.registered_connection = None;
4164 self.advertised = runtime.health.http.is_some();
4165 if !self.advertised {
4166 self.next_probe_at = None;
4167 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4168 let unknown = ModuleHealthStatus::default();
4169 if state.health != unknown {
4170 state.health = unknown;
4171 }
4172 });
4173 } else if self.next_probe_at.is_none() {
4174 self.next_probe_at = Some(
4175 Instant::now()
4176 + jittered_health_delay(&spec.module_id, 0, runtime.health.cadence),
4177 );
4178 }
4179 return;
4180 }
4181
4182 let registration = match registry.get_module(&spec.module_id) {
4183 Ok(registration) => registration,
4184 Err(err) => {
4185 warn!(module_id = %spec.module_id, error = %err, "health prober could not read registry");
4186 self.advertised = false;
4187 self.next_probe_at = None;
4188 return;
4189 }
4190 };
4191
4192 let Some(registration) = registration else {
4193 self.registered_connection = None;
4194 self.advertised = false;
4195 self.next_probe_at = None;
4196 return;
4197 };
4198
4199 let advertised = registration
4200 .control_ops
4201 .iter()
4202 .any(|op| op == MODULE_CONTROL_OP_HEALTH_CHECK);
4203 if !advertised {
4204 self.registered_connection = Some(registration.connection_id);
4205 self.advertised = false;
4206 self.next_probe_at = None;
4207 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4208 if state.health.status != SupervisorHealthStatus::Unknown
4209 || state.health.consecutive_failures != 0
4210 || state.health.last_probe_ms.is_some()
4211 || state.health.detail.is_some()
4212 || state.health.metrics.is_some()
4213 {
4214 state.health.status = SupervisorHealthStatus::Unknown;
4215 state.health.consecutive_failures = 0;
4216 state.health.last_probe_ms = None;
4217 state.health.detail = None;
4218 state.health.metrics = None;
4219 }
4220 });
4221 return;
4222 }
4223
4224 let reregistered = self.registered_connection != Some(registration.connection_id);
4225 self.registered_connection = Some(registration.connection_id);
4226 self.advertised = true;
4227 if reregistered || self.next_probe_at.is_none() {
4228 self.probe_index = 0;
4229 self.next_probe_at = Some(
4230 Instant::now() + jittered_health_delay(&spec.module_id, 0, runtime.health.cadence),
4231 );
4232 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4233 state.health.status = SupervisorHealthStatus::Unknown;
4234 state.health.consecutive_failures = 0;
4235 state.health.detail = None;
4236 state.health.metrics = None;
4237 });
4238 }
4239 }
4240
4241 fn wake_after(&self) -> Option<Duration> {
4242 if !self.advertised {
4243 return None;
4244 }
4245 self.next_probe_at
4246 .map(|next| next.saturating_duration_since(Instant::now()))
4247 }
4248
4249 fn due(&self) -> bool {
4250 self.advertised
4251 && self
4252 .next_probe_at
4253 .is_some_and(|next| Instant::now() >= next)
4254 }
4255
4256 fn schedule_next(&mut self, spec: &ModuleSpec, cadence: Duration) {
4257 self.probe_index = self.probe_index.wrapping_add(1);
4258 self.next_probe_at = Some(
4259 Instant::now() + jittered_health_delay(&spec.module_id, self.probe_index, cadence),
4260 );
4261 }
4262}
4263
4264#[derive(Debug)]
4299enum HealthProbeEvidence {
4300 LaneDead,
4302 NoAnswer,
4304 BadAnswer,
4306 Misconfigured,
4308}
4309
4310#[derive(Debug)]
4311struct HealthProbeError {
4312 evidence: HealthProbeEvidence,
4313 message: String,
4314}
4315
4316impl HealthProbeError {
4317 fn lane_dead(message: impl Into<String>) -> Self {
4318 Self::with(HealthProbeEvidence::LaneDead, message)
4319 }
4320
4321 fn no_answer(message: impl Into<String>) -> Self {
4322 Self::with(HealthProbeEvidence::NoAnswer, message)
4323 }
4324
4325 fn bad_answer(message: impl Into<String>) -> Self {
4326 Self::with(HealthProbeEvidence::BadAnswer, message)
4327 }
4328
4329 fn misconfigured(message: impl Into<String>) -> Self {
4330 Self::with(HealthProbeEvidence::Misconfigured, message)
4331 }
4332
4333 fn with(evidence: HealthProbeEvidence, message: impl Into<String>) -> Self {
4334 Self {
4335 evidence,
4336 message: message.into(),
4337 }
4338 }
4339
4340 #[allow(dead_code)]
4354 fn is_proof_of_death(&self) -> bool {
4355 matches!(self.evidence, HealthProbeEvidence::LaneDead)
4356 }
4357
4358 fn label(&self) -> &'static str {
4366 match self.evidence {
4367 HealthProbeEvidence::LaneDead => "lane-dead",
4368 HealthProbeEvidence::NoAnswer => "no-answer",
4369 HealthProbeEvidence::BadAnswer => "bad-answer",
4370 HealthProbeEvidence::Misconfigured => "daemon-misconfigured",
4371 }
4372 }
4373}
4374
4375impl fmt::Display for HealthProbeError {
4376 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4377 f.write_str(&self.message)
4378 }
4379}
4380
4381async fn run_health_probe_cycle(
4382 spec: &ModuleSpec,
4383 runtime: &SupervisorRuntimeConfig,
4384 registry: &Registry,
4385 process_liveness: &SupervisorProcessLiveness,
4386 snapshot: &SharedSnapshot,
4387 child: &mut Option<SupervisedChild>,
4388) {
4389 let now_ms = unix_ms_now();
4390 let http = (running_protocol(spec, snapshot) == ModuleProtocol::None)
4391 .then_some(runtime.health.http.as_deref())
4392 .flatten();
4393 let result = match http {
4394 Some(url) => probe_http_health(url, runtime.health.deadline).await,
4395 None => probe_module_health(&spec.module_id, runtime, None).await,
4396 };
4397 match result {
4398 Ok(report) => {
4399 handle_health_report(
4400 spec,
4401 runtime,
4402 registry,
4403 process_liveness,
4404 snapshot,
4405 child,
4406 report,
4407 now_ms,
4408 )
4409 .await;
4410 }
4411 Err(err) => {
4412 if http.is_some() {
4413 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4414 state.health.status = SupervisorHealthStatus::Failing;
4415 });
4416 }
4417 handle_health_probe_failure(
4418 spec,
4419 runtime,
4420 registry,
4421 process_liveness,
4422 snapshot,
4423 child,
4424 err,
4425 now_ms,
4426 )
4427 .await;
4428 }
4429 }
4430}
4431
4432pub(crate) struct HttpProbeTarget<'a> {
4433 address: std::net::SocketAddr,
4434 localhost: bool,
4435 authority: &'a str,
4436 path: String,
4437}
4438
4439pub(crate) fn parse_http_probe_url(url: &str) -> Result<HttpProbeTarget<'_>, String> {
4442 if url.bytes().any(|b| b <= b' ' || b == 127) || url.contains('#') {
4443 return Err("must not contain whitespace, controls, or a fragment".into());
4444 }
4445 let rest = url
4446 .strip_prefix("http://")
4447 .ok_or("must use plain http://")?;
4448 let split = rest.find(['/', '?']).unwrap_or(rest.len());
4449 let (authority, suffix) = rest.split_at(split);
4450 let (host, port) = if let Some(rest) = authority.strip_prefix("[::1]") {
4451 ("::1", rest)
4452 } else {
4453 let split = authority.find(':').unwrap_or(authority.len());
4454 authority.split_at(split)
4455 };
4456 let ip: std::net::IpAddr = match host {
4457 "127.0.0.1" | "localhost" => std::net::Ipv4Addr::LOCALHOST.into(),
4458 "::1" => std::net::Ipv6Addr::LOCALHOST.into(),
4459 _ => return Err("host must be 127.0.0.1, [::1], or localhost".into()),
4460 };
4461 let port = if port.is_empty() {
4462 80
4463 } else {
4464 port.strip_prefix(':')
4465 .and_then(|p| p.parse::<u16>().ok())
4466 .filter(|p| *p > 0)
4467 .ok_or("must have a valid nonzero TCP port")?
4468 };
4469 let path = if suffix.is_empty() {
4470 "/".into()
4471 } else if suffix.starts_with('?') {
4472 format!("/{suffix}")
4473 } else {
4474 suffix.into()
4475 };
4476 Ok(HttpProbeTarget {
4477 address: std::net::SocketAddr::new(ip, port),
4478 localhost: host == "localhost",
4479 authority,
4480 path,
4481 })
4482}
4483
4484async fn probe_http_health(
4485 url: &str,
4486 deadline: Duration,
4487) -> Result<HealthReport, HealthProbeError> {
4488 use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader};
4489 let target = parse_http_probe_url(url).map_err(HealthProbeError::misconfigured)?;
4490 let mut response_status = String::new();
4493 let mut body = Vec::new();
4494 let probe = async {
4495 let connection = match tokio::net::TcpStream::connect(target.address).await {
4498 Err(_) if target.localhost => {
4499 tokio::net::TcpStream::connect((
4500 std::net::Ipv6Addr::LOCALHOST,
4501 target.address.port(),
4502 ))
4503 .await
4504 }
4505 result => result,
4506 };
4507 let mut stream = connection.map_err(|error| {
4508 HealthProbeError::no_answer(format!("HTTP connect failed: {error}"))
4509 })?;
4510 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
4511 .map_err(|error| HealthProbeError::no_answer(format!("HTTP write failed: {error}")))?;
4512 let mut reader = BufReader::new(stream);
4513 let mut budget = 16 * 1024;
4514 let status = http_line(&mut reader, &mut budget).await?;
4515 let mut words = status.split_ascii_whitespace();
4516 let version = words.next();
4517 let code = words
4518 .next()
4519 .filter(|word| word.len() == 3)
4520 .and_then(|word| word.parse::<u16>().ok());
4521 if !matches!(version, Some("HTTP/1.1" | "HTTP/1.0"))
4522 || !code.is_some_and(|code| (100..600).contains(&code))
4523 {
4524 return Err(HealthProbeError::bad_answer(format!(
4525 "invalid HTTP status: {status}"
4526 )));
4527 }
4528 let code = code.expect("validated status code");
4529 response_status = status.clone();
4530 let mut length = None;
4531 let mut chunked = false;
4532 loop {
4533 let line = http_line(&mut reader, &mut budget).await?;
4534 if line.is_empty() {
4535 break;
4536 }
4537 if let Some((name, value)) = line.split_once(':') {
4538 if name.eq_ignore_ascii_case("content-length") {
4539 length = Some(value.trim().parse::<u64>().map_err(|_| {
4540 HealthProbeError::bad_answer("invalid HTTP Content-Length")
4541 })?);
4542 } else if name.eq_ignore_ascii_case("transfer-encoding") {
4543 chunked = value.trim().eq_ignore_ascii_case("chunked");
4544 }
4545 }
4546 }
4547 if chunked {
4548 while body.len() < 200 {
4549 let line = http_line(&mut reader, &mut budget).await?;
4550 let size = u64::from_str_radix(line.split(';').next().unwrap_or("").trim(), 16)
4551 .map_err(|_| HealthProbeError::bad_answer("invalid HTTP chunk size"))?;
4552 if size == 0 {
4553 break;
4554 }
4555 let count = size.min((200 - body.len()) as u64) as usize;
4556 let start = body.len();
4557 (&mut reader)
4558 .take(count as u64)
4559 .read_to_end(&mut body)
4560 .await
4561 .map_err(|error| {
4562 HealthProbeError::no_answer(format!("HTTP body read failed: {error}"))
4563 })?;
4564 if body.len() - start != count {
4565 return Err(HealthProbeError::bad_answer("truncated HTTP chunk"));
4566 }
4567 if size > count as u64 || body.len() == 200 {
4568 break;
4569 }
4570 if !http_line(&mut reader, &mut budget).await?.is_empty() {
4571 return Err(HealthProbeError::bad_answer("invalid HTTP chunk delimiter"));
4572 }
4573 }
4574 } else {
4575 reader
4576 .take(length.unwrap_or(200).min(200))
4577 .read_to_end(&mut body)
4578 .await
4579 .map_err(|error| {
4580 HealthProbeError::no_answer(format!("HTTP body read failed: {error}"))
4581 })?;
4582 }
4583 if (200..300).contains(&code) {
4584 Ok(HealthReport::ok())
4585 } else {
4586 Err(HealthProbeError::bad_answer(
4587 "HTTP health endpoint returned non-2xx",
4588 ))
4589 }
4590 };
4591 let mut result = timeout(deadline, probe).await.unwrap_or_else(|_| {
4592 Err(HealthProbeError::no_answer(format!(
4593 "HTTP probe timed out after {deadline:?}"
4594 )))
4595 });
4596 if let Err(error) = &mut result {
4597 if !response_status.is_empty() {
4598 error.message = format!(
4599 "{}; {response_status}: {}",
4600 error.message,
4601 String::from_utf8_lossy(&body)
4602 );
4603 }
4604 }
4605 result
4606}
4607
4608async fn http_line(
4609 reader: &mut tokio::io::BufReader<tokio::net::TcpStream>,
4610 remaining: &mut usize,
4611) -> Result<String, HealthProbeError> {
4612 use tokio::io::{AsyncBufReadExt, AsyncReadExt};
4613 let mut line = Vec::new();
4614 (&mut *reader)
4615 .take(*remaining as u64)
4616 .read_until(b'\n', &mut line)
4617 .await
4618 .map_err(|error| {
4619 HealthProbeError::no_answer(format!("HTTP header read failed: {error}"))
4620 })?;
4621 *remaining -= line.len();
4622 if !line.ends_with(b"\r\n") {
4623 return Err(HealthProbeError::bad_answer(
4624 "HTTP headers are incomplete or exceed 16 KiB",
4625 ));
4626 }
4627 line.truncate(line.len() - 2);
4628 String::from_utf8(line).map_err(|_| HealthProbeError::bad_answer("HTTP header is not UTF-8"))
4629}
4630
4631async fn probe_module_health(
4632 module_id: &str,
4633 runtime: &SupervisorRuntimeConfig,
4634 drain_deadline: Option<Instant>,
4635) -> Result<HealthReport, HealthProbeError> {
4636 let Some(forwarding) = runtime.forwarding.as_ref() else {
4637 return Err(HealthProbeError::misconfigured(
4638 "supervisor was not configured with a forwarding table",
4639 ));
4640 };
4641 let probe_started_at = Instant::now();
4642 let mut deadline = probe_started_at + runtime.health.deadline;
4643 if let Some(drain_deadline) = drain_deadline {
4644 deadline = deadline.min(drain_deadline);
4645 }
4646 let pending = if drain_deadline.is_some() {
4647 forwarding.begin_drain_health_probe_rpc_for(
4648 module_id,
4649 MODULE_CONTROL_OP_HEALTH_CHECK,
4650 probe_started_at,
4651 deadline,
4652 )
4653 } else {
4654 forwarding.begin_health_probe_rpc_for(
4655 module_id,
4656 MODULE_CONTROL_OP_HEALTH_CHECK,
4657 probe_started_at,
4658 deadline,
4659 )
4660 }
4661 .map_err(|err| {
4662 HealthProbeError::lane_dead(format!("failed to begin health.check RPC: {err}"))
4665 })?;
4666 await_health_probe(forwarding, pending, deadline, runtime.health.deadline).await
4667}
4668
4669async fn probe_endpoint_health(
4676 endpoint: crate::ModuleEndpointId,
4677 runtime: &SupervisorRuntimeConfig,
4678 deadline_cap: Option<Instant>,
4679) -> Result<HealthReport, HealthProbeError> {
4680 let Some(forwarding) = runtime.forwarding.as_ref() else {
4681 return Err(HealthProbeError::misconfigured(
4682 "supervisor was not configured with a forwarding table",
4683 ));
4684 };
4685 let probe_started_at = Instant::now();
4686 let mut deadline = probe_started_at + runtime.health.deadline;
4687 if let Some(cap) = deadline_cap {
4688 deadline = deadline.min(cap);
4689 }
4690 let pending = forwarding
4691 .begin_endpoint_health_probe_rpc_for(
4692 endpoint,
4693 MODULE_CONTROL_OP_HEALTH_CHECK,
4694 probe_started_at,
4695 deadline,
4696 )
4697 .map_err(|err| {
4698 HealthProbeError::lane_dead(format!("failed to begin health.check RPC: {err}"))
4699 })?;
4700 await_health_probe(forwarding, pending, deadline, runtime.health.deadline).await
4701}
4702
4703async fn await_health_probe(
4705 forwarding: &ForwardingTable,
4706 pending: PendingModuleControlRpc,
4707 deadline: Instant,
4708 probe_budget: Duration,
4709) -> Result<HealthReport, HealthProbeError> {
4710 let PendingModuleControlRpc {
4711 endpoint,
4712 module_sink,
4713 negotiated_ver,
4714 corr,
4715 receiver,
4716 } = pending;
4717 let body = serde_json::to_vec(&ModuleControlRequest::HealthCheck {}).map_err(|err| {
4718 HealthProbeError::misconfigured(format!("failed to encode health.check: {err}"))
4719 })?;
4720 let frame = Frame::build_with_version(
4721 negotiated_ver,
4722 FrameType::Request,
4723 control_flags(),
4724 0,
4725 0,
4726 corr,
4727 body,
4728 )
4729 .map_err(|err| {
4730 HealthProbeError::misconfigured(format!("failed to build health.check frame: {err}"))
4731 })?;
4732
4733 match timeout_at(deadline, module_sink.send(frame)).await {
4739 Ok(Ok(())) => {}
4740 Ok(Err(err)) => {
4741 let _ = forwarding.cancel_module_control_rpc(endpoint, corr);
4742 return Err(HealthProbeError::lane_dead(format!(
4745 "failed to send health.check: {err}"
4746 )));
4747 }
4748 Err(_elapsed) => {
4749 let _ = forwarding.cancel_module_control_rpc(endpoint, corr);
4750 return Err(HealthProbeError::no_answer(
4754 "health.check send timed out before enqueue (module egress full)",
4755 ));
4756 }
4757 }
4758
4759 match timeout_at(deadline, receiver).await {
4760 Ok(Ok(ModuleControlRpcOutcome::Response(response))) => {
4764 response.health_report().ok_or_else(|| {
4765 HealthProbeError::bad_answer("health.check RPC returned a non-health response")
4766 })
4767 }
4768 Ok(Ok(ModuleControlRpcOutcome::Rejected(body))) => Err(HealthProbeError::bad_answer(
4769 format!("health.check rejected: {}", body.message),
4770 )),
4771 Ok(Ok(ModuleControlRpcOutcome::ModuleGone(message))) => {
4772 Err(HealthProbeError::lane_dead(message))
4773 }
4774 Ok(Ok(ModuleControlRpcOutcome::MalformedResponse(message))) => {
4775 Err(HealthProbeError::bad_answer(message))
4776 }
4777 Ok(Ok(ModuleControlRpcOutcome::UnexpectedOp { expected, actual })) => {
4778 Err(HealthProbeError::bad_answer(format!(
4779 "expected module-control op '{expected}', got '{actual}'"
4780 )))
4781 }
4782 Ok(Ok(ModuleControlRpcOutcome::DeadlineElapsed)) => Err(HealthProbeError::bad_answer(
4786 "module answered health.check after its daemon deadline",
4787 )),
4788 Ok(Err(_)) => Err(HealthProbeError::misconfigured(
4789 "health.check waiter was canceled before the module responded",
4790 )),
4791 Err(_) => {
4792 let _ = forwarding.tombstone_health_probe_rpc(endpoint, corr);
4793 Err(HealthProbeError::no_answer(format!(
4794 "module did not answer health.check within {probe_budget:?}"
4795 )))
4796 }
4797 }
4798}
4799
4800#[allow(clippy::too_many_arguments)]
4801async fn handle_health_report(
4802 spec: &ModuleSpec,
4803 runtime: &SupervisorRuntimeConfig,
4804 registry: &Registry,
4805 process_liveness: &SupervisorProcessLiveness,
4806 snapshot: &SharedSnapshot,
4807 child: &mut Option<SupervisedChild>,
4808 report: HealthReport,
4809 now_ms: u64,
4810) {
4811 let status = supervisor_health_status(report.status);
4812 let detail = report.detail.clone();
4813 let metrics = truncate_health_metrics(report.metrics);
4814 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4815 state.health.status = status;
4816 state.health.last_probe_ms = Some(now_ms);
4817 state.health.detail = detail.clone();
4818 state.health.metrics = metrics.clone();
4819 state.health.consecutive_failures = 0;
4820 });
4821
4822 let action = match report.status {
4823 HealthStatus::Ok => return,
4824 HealthStatus::Degraded => runtime.health.on_degraded,
4825 HealthStatus::Failing => runtime.health.on_failing,
4826 };
4827 apply_l3_health_action(
4828 spec,
4829 runtime,
4830 registry,
4831 process_liveness,
4832 snapshot,
4833 child,
4834 status,
4835 detail.as_deref(),
4836 action,
4837 now_ms,
4838 )
4839 .await;
4840}
4841
4842#[allow(clippy::too_many_arguments)]
4843async fn handle_health_probe_failure(
4844 spec: &ModuleSpec,
4845 runtime: &SupervisorRuntimeConfig,
4846 registry: &Registry,
4847 process_liveness: &SupervisorProcessLiveness,
4848 snapshot: &SharedSnapshot,
4849 child: &mut Option<SupervisedChild>,
4850 err: HealthProbeError,
4851 now_ms: u64,
4852) {
4853 let threshold = runtime.health.failure_threshold.max(1);
4854 let mut failures = 0;
4855 let detail = format!("[{}] {err}", err.label());
4860 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4861 if state.spawned_protocol.unwrap_or(spec.protocol) == ModuleProtocol::Subc {
4864 state.health.status = SupervisorHealthStatus::Unknown;
4865 }
4866 state.health.last_probe_ms = Some(now_ms);
4867 state.health.consecutive_failures = state.health.consecutive_failures.saturating_add(1);
4868 state.health.detail = Some(detail.clone());
4869 state.health.metrics = None;
4870 failures = state.health.consecutive_failures;
4871 });
4872
4873 if failures < threshold {
4874 warn!(
4875 module_id = %spec.module_id,
4876 consecutive_failures = failures,
4877 threshold,
4878 evidence = err.label(),
4879 detail = %detail,
4880 "health.check probe failed"
4881 );
4882 return;
4883 }
4884
4885 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4886 state.state = ModuleState::Unresponsive;
4887 state.health.status = SupervisorHealthStatus::Unresponsive;
4888 });
4889 if runtime.health.critical {
4893 error!(
4894 module_id = %spec.module_id,
4895 status = "unresponsive",
4896 evidence = err.label(),
4897 detail = %detail,
4898 "critical module health alert"
4899 );
4900 } else {
4901 warn!(
4902 module_id = %spec.module_id,
4903 status = "unresponsive",
4904 evidence = err.label(),
4905 detail = %detail,
4906 "module health threshold breached"
4907 );
4908 }
4909 if let Err(err) = health_restart_child(
4910 spec,
4911 runtime,
4912 registry,
4913 process_liveness,
4914 snapshot,
4915 child,
4916 SupervisorHealthStatus::Unresponsive,
4917 Some(&detail),
4918 now_ms,
4919 )
4920 .await
4921 {
4922 error!(module_id = %spec.module_id, error = %err, "health-triggered restart failed");
4923 }
4924}
4925
4926#[allow(clippy::too_many_arguments)]
4927async fn apply_l3_health_action(
4928 spec: &ModuleSpec,
4929 runtime: &SupervisorRuntimeConfig,
4930 registry: &Registry,
4931 process_liveness: &SupervisorProcessLiveness,
4932 snapshot: &SharedSnapshot,
4933 child: &mut Option<SupervisedChild>,
4934 status: SupervisorHealthStatus,
4935 detail: Option<&str>,
4936 action: HealthAction,
4937 now_ms: u64,
4938) {
4939 record_health_action(snapshot, &spec.module_id, action.to_string(), now_ms);
4940 match action {
4941 HealthAction::Report => {
4942 info!(
4943 module_id = %spec.module_id,
4944 status = ?status,
4945 detail,
4946 "module reported non-ok health"
4947 );
4948 }
4949 HealthAction::Alert => {
4950 error!(
4951 module_id = %spec.module_id,
4952 status = ?status,
4953 detail,
4954 "module health alert"
4955 );
4956 }
4957 HealthAction::Restart => {
4958 if let Err(err) = health_restart_child(
4959 spec,
4960 runtime,
4961 registry,
4962 process_liveness,
4963 snapshot,
4964 child,
4965 status,
4966 detail,
4967 now_ms,
4968 )
4969 .await
4970 {
4971 error!(module_id = %spec.module_id, error = %err, "health-triggered restart failed");
4972 }
4973 }
4974 }
4975}
4976
4977#[allow(clippy::too_many_arguments)]
4978async fn health_restart_child(
4979 spec: &ModuleSpec,
4980 runtime: &SupervisorRuntimeConfig,
4981 registry: &Registry,
4982 process_liveness: &SupervisorProcessLiveness,
4983 snapshot: &SharedSnapshot,
4984 child: &mut Option<SupervisedChild>,
4985 status: SupervisorHealthStatus,
4986 detail: Option<&str>,
4987 now_ms: u64,
4988) -> Result<(), SuperviseError> {
4989 let (enabled, schedule) = {
4990 let mut state = lock_snapshot(snapshot)?;
4991 let enabled = state.enabled;
4992 let schedule = if enabled {
4993 state.next_crash_restart(&runtime.restart_policy, Instant::now())
4994 } else {
4995 None
4996 };
4997 (enabled, schedule)
4998 };
4999
5000 if !enabled {
5001 return Err(SuperviseError::Disabled {
5002 module_id: spec.module_id.clone(),
5003 });
5004 }
5005
5006 if schedule.is_none() {
5007 record_health_action(snapshot, &spec.module_id, "failed".to_string(), now_ms);
5008 error!(
5009 module_id = %spec.module_id,
5010 status = ?status,
5011 detail,
5012 max_restarts = runtime.restart_policy.max_restarts,
5013 window_secs = runtime.restart_policy.window.as_secs(),
5014 reason = %runtime.restart_policy.budget_exhausted_detail(),
5015 "health restart budget exhausted; marking module failed"
5016 );
5017 let stop_notice = begin_forwarding_drain_if_configured(
5018 spec,
5019 runtime,
5020 registry,
5021 snapshot,
5022 Some(true),
5023 RouteCloseReason::Disable,
5024 )
5025 .await?;
5026 update_snapshot(snapshot, Some(&spec.module_id), |state| {
5027 state.drain_disposition_detail = Some(runtime.restart_policy.budget_exhausted_detail());
5028 })?;
5029 drain_optional_child(
5030 &spec.module_id,
5031 spec.protocol,
5032 stop_notice,
5033 registry,
5034 runtime.forwarding.as_deref(),
5035 snapshot,
5036 &runtime.terminal_ring,
5037 &runtime.spawn_events,
5038 child,
5039 runtime.drain_timeout,
5040 ModuleState::Failed,
5041 Some(true),
5042 )
5043 .await?;
5044 process_liveness.untrack_if_current(&spec.module_id, snapshot);
5045 return Ok(());
5046 }
5047
5048 let schedule = schedule.expect("a health restart must have a crash-restart schedule");
5049 let mut restart_count = 0;
5050 update_snapshot(snapshot, Some(&spec.module_id), |state| {
5051 restart_count = state.crash_restarts.len();
5052 state.state = ModuleState::Unresponsive;
5053 state.health.status = status;
5054 state.health.last_action = Some(HealthAction::Restart.to_string());
5055 state.health.last_action_ms = Some(now_ms);
5056 })?;
5057 warn!(
5058 module_id = %spec.module_id,
5059 status = ?status,
5060 detail,
5061 restart_count,
5062 restart_in_window = schedule.restart_in_window,
5063 delay_ms = schedule.delay.as_millis() as u64,
5064 "health-triggered module restart"
5065 );
5066
5067 let stop_notice = begin_forwarding_drain_if_configured(
5068 spec,
5069 runtime,
5070 registry,
5071 snapshot,
5072 Some(true),
5073 RouteCloseReason::Restart,
5074 )
5075 .await?;
5076 drain_optional_child(
5077 &spec.module_id,
5078 spec.protocol,
5079 stop_notice,
5080 registry,
5081 runtime.forwarding.as_deref(),
5082 snapshot,
5083 &runtime.terminal_ring,
5084 &runtime.spawn_events,
5085 child,
5086 runtime.drain_timeout,
5087 ModuleState::Restarting,
5088 Some(true),
5089 )
5090 .await?;
5091 schedule_respawn(
5092 runtime,
5093 snapshot,
5094 &spec.module_id,
5095 schedule.delay,
5096 RespawnKind::Spawn,
5097 )
5098}
5099
5100fn cancel_deferred_reload(runtime: &SupervisorRuntimeConfig, module_id: &str, reason: &str) {
5101 if let Some(reply) = runtime
5102 .deferred_reload_reply
5103 .lock()
5104 .unwrap_or_else(|p| p.into_inner())
5105 .take()
5106 {
5107 let _ = reply.send(Err(SuperviseError::ReloadFailed {
5108 module_id: module_id.to_string(),
5109 reason: reason.to_string(),
5110 }));
5111 }
5112}
5113
5114fn schedule_respawn(
5115 runtime: &SupervisorRuntimeConfig,
5116 snapshot: &SharedSnapshot,
5117 module_id: &str,
5118 delay: Duration,
5119 kind: RespawnKind,
5120) -> Result<(), SuperviseError> {
5121 cancel_deferred_reload(runtime, module_id, "respawn superseded by another restart");
5122 update_snapshot(snapshot, Some(module_id), |state| {
5123 state.respawn_pending = true
5124 })?;
5125 *runtime
5126 .scheduled_respawn
5127 .lock()
5128 .unwrap_or_else(|p| p.into_inner()) = Some(PendingRespawn {
5129 deadline: Instant::now() + delay,
5130 kind,
5131 });
5132 Ok(())
5133}
5134
5135fn record_health_action(snapshot: &SharedSnapshot, module_id: &str, action: String, now_ms: u64) {
5136 let _ = update_snapshot(snapshot, Some(module_id), |state| {
5137 state.health.last_action = Some(action);
5138 state.health.last_action_ms = Some(now_ms);
5139 });
5140}
5141
5142fn supervisor_health_status(status: HealthStatus) -> SupervisorHealthStatus {
5143 match status {
5144 HealthStatus::Ok => SupervisorHealthStatus::Ok,
5145 HealthStatus::Degraded => SupervisorHealthStatus::Degraded,
5146 HealthStatus::Failing => SupervisorHealthStatus::Failing,
5147 }
5148}
5149
5150fn truncate_health_metrics(metrics: Option<Value>) -> Option<Value> {
5162 let metrics = metrics?;
5163 match serde_json::to_vec(&metrics) {
5164 Ok(encoded) if encoded.len() > MAX_HEALTH_METRICS_BYTES => Some(serde_json::json!({
5165 "truncated": true,
5166 "original_bytes": encoded.len(),
5167 })),
5168 Ok(_) | Err(_) => Some(metrics),
5169 }
5170}
5171
5172fn jittered_health_delay(module_id: &str, probe_index: u64, cadence: Duration) -> Duration {
5178 if cadence.is_zero() {
5179 return Duration::ZERO;
5180 }
5181 let cadence_ms = cadence.as_millis() as u64;
5182 if cadence_ms == 0 {
5198 return cadence;
5199 }
5200 let jitter_span = (cadence_ms / 10).max(1);
5215 let hash = module_id.as_bytes().iter().fold(
5216 probe_index.wrapping_mul(0x9E37_79B9_7F4A_7C15),
5217 |acc, byte| {
5218 acc.wrapping_mul(1099511628211)
5219 .wrapping_add(u64::from(*byte))
5220 },
5221 );
5222 cadence + Duration::from_millis(hash % jitter_span)
5223}
5224
5225#[cfg(test)]
5226mod tests {
5227 use super::*;
5228
5229 #[test]
5230 fn readding_a_module_clears_its_rescan_removal_tombstone() {
5231 let handle = SupervisorHandle::new();
5232 let module_id = "readded-tombstone";
5233 handle.record_rescan_removal(module_id);
5234 assert!(handle.removal_tombstone_age_ms(module_id).is_some());
5235
5236 handle.apply_identity_configuration(&ModuleSpec {
5237 module_id: module_id.to_string(),
5238 program: PathBuf::from("/test/module"),
5239 args: Vec::new(),
5240 env: Vec::new(),
5241 reserved: false,
5242 reserved_prefixes: Vec::new(),
5243 protocol: ModuleProtocol::Subc,
5244 overlap: Default::default(),
5245 });
5246
5247 assert!(
5248 handle.removal_tombstone_age_ms(module_id).is_none(),
5249 "a re-added module must not retain a stale removal tombstone"
5250 );
5251 }
5252
5253 #[derive(Debug, PartialEq, Eq)]
5256 struct OwnerInSpawnWindow {
5257 module_id: String,
5258 configured: bool,
5259 on_roster: bool,
5260 admission_refusal: Option<&'static str>,
5261 }
5262
5263 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5274 async fn a_new_module_is_configured_before_its_first_process_can_register() {
5275 use crate::scopes::ScopeTable;
5276 use subc_protocol::{error_codes, scope::ScopeSelector, Principal};
5277
5278 let dir = cortexkit_test_support::ScratchDir::new("configured-before-spawn");
5279 let stub = |module_id: &str, program: PathBuf| ModuleSpec {
5280 module_id: module_id.to_string(),
5281 program,
5282 args: Vec::new(),
5283 env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
5284 .into_iter()
5285 .map(|key| (key.to_string(), dir.path().display().to_string()))
5286 .chain([("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string())])
5287 .collect(),
5288 reserved: false,
5289 reserved_prefixes: Vec::new(),
5290 protocol: ModuleProtocol::Subc,
5291 overlap: Default::default(),
5292 };
5293 let live = super::terminal_history_tests::fake_aft_stub_path();
5294 let missing = dir.path().join("definitely-missing-module");
5295
5296 let handle = SupervisorHandle::new();
5297 let mut supervisor =
5298 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5299 .with_handle(handle.clone());
5300 let observed = Arc::new(Mutex::new(Vec::<OwnerInSpawnWindow>::new()));
5301 let hook_handle = handle.clone();
5302 let hook_observed = Arc::clone(&observed);
5303 supervisor.test_after_first_spawn = AfterFirstSpawnHook(Some(Arc::new(move |module_id| {
5304 let configured = hook_handle.is_configured(module_id);
5307 let selector = ScopeSelector {
5308 owner: Principal::Reserved {
5309 module_id: module_id.to_string(),
5310 },
5311 scope_ref: "s".to_string(),
5312 scope_epoch: Some(1),
5313 };
5314 let carrier = Principal::Reserved {
5315 module_id: "carrier".to_string(),
5316 };
5317 let admission_refusal = match ScopeTable::new(Vec::<String>::new())
5318 .admit(&carrier, module_id, &selector, configured)
5319 {
5320 Ok(_) => None,
5321 Err(refusal) => Some(refusal.code),
5322 };
5323 hook_observed.lock().unwrap().push(OwnerInSpawnWindow {
5324 module_id: module_id.to_string(),
5325 configured,
5326 on_roster: hook_handle.get(module_id).is_some(),
5327 admission_refusal,
5328 });
5329 })));
5330
5331 let plain = supervisor.spawn(stub("plain", live.clone())).unwrap();
5332 let configured = supervisor
5333 .supervise_configured(stub("configured", live.clone()), true)
5334 .unwrap();
5335 let with_health = supervisor
5336 .supervise_configured_with_health(
5337 stub("with-health", live.clone()),
5338 true,
5339 HealthConfig::default(),
5340 None,
5341 RestartPolicy::default(),
5342 )
5343 .unwrap();
5344 let failed = supervisor
5347 .supervise_configured_with_health(
5348 stub("failed-spawn", missing.clone()),
5349 true,
5350 HealthConfig::default(),
5351 None,
5352 RestartPolicy::default(),
5353 )
5354 .unwrap();
5355 assert!(supervisor.spawn(stub("spawn-error", missing)).is_err());
5358
5359 let expected = [
5360 "plain",
5361 "configured",
5362 "with-health",
5363 "failed-spawn",
5364 "spawn-error",
5365 ]
5366 .into_iter()
5367 .map(|module_id| OwnerInSpawnWindow {
5368 module_id: module_id.to_string(),
5369 configured: true,
5370 on_roster: false,
5371 admission_refusal: Some(error_codes::SCOPE_NOT_SYNCED),
5372 })
5373 .collect::<Vec<_>>();
5374 assert_eq!(*observed.lock().unwrap(), expected);
5375
5376 for module_id in ["plain", "configured", "with-health", "failed-spawn"] {
5377 assert!(
5378 handle.get(module_id).is_some(),
5379 "{module_id} is on the roster"
5380 );
5381 assert!(
5382 handle.is_configured(module_id),
5383 "{module_id} stays configured"
5384 );
5385 }
5386 assert!(handle.get("spawn-error").is_none());
5387 assert!(
5388 !handle.is_configured("spawn-error"),
5389 "a plain spawn that failed must not leave its module marked configured"
5390 );
5391
5392 handle.retire("failed-spawn");
5394 assert!(!handle.is_configured("failed-spawn"));
5395
5396 for module in [plain, configured, with_health] {
5397 module.stop().await.unwrap();
5398 }
5399 drop(failed);
5400 }
5401
5402 fn stale_process_snapshot(state: ModuleState, enabled: bool) -> SharedSnapshot {
5403 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(state, enabled)));
5404 update_snapshot(&snapshot, Some("stale-process-facts"), |snapshot| {
5405 snapshot.process_alive = true;
5406 snapshot.pid = Some(41);
5407 snapshot.spawned_at_ms = Some(42);
5408 snapshot.spawned_from = Some(PathBuf::from("/spawned/module"));
5409 snapshot.spawned_file_identity = Some(SpawnedFileIdentity {
5410 device: 43,
5411 inode: 44,
5412 #[cfg(windows)]
5413 windows_image: None,
5414 });
5415 })
5416 .unwrap();
5417 snapshot
5418 }
5419
5420 fn assert_snapshot_process_facts_cleared(snapshot: &SharedSnapshot) {
5421 let snapshot = lock_snapshot(snapshot).unwrap();
5422 assert!(!snapshot.process_alive);
5423 assert_eq!(snapshot.pid, None);
5424 assert_eq!(snapshot.spawned_at_ms, None);
5425 assert_eq!(snapshot.spawned_from, None);
5426 assert_eq!(snapshot.spawned_file_identity, None);
5427 }
5428
5429 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5430 async fn failed_enable_spawn_clears_preexisting_current_process_facts() {
5431 let supervisor =
5432 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5433 let mut runtime = supervisor.runtime_config();
5434 runtime.test_seed_stale_facts_before_enable_spawn = true;
5435 let snapshot = stale_process_snapshot(ModuleState::Disabled, false);
5436 let mut child = None;
5437 let spec = ModuleSpec {
5438 module_id: "failed-enable-clears-facts".to_string(),
5439 program: PathBuf::from("/definitely/missing/failed-enable-module"),
5440 args: Vec::new(),
5441 env: Vec::new(),
5442 reserved: false,
5443 reserved_prefixes: Vec::new(),
5444 protocol: ModuleProtocol::Subc,
5445 overlap: Default::default(),
5446 };
5447
5448 let result = set_child_enabled(
5449 &spec,
5450 &runtime,
5451 &supervisor.registry,
5452 &supervisor.process_liveness,
5453 &snapshot,
5454 &mut child,
5455 true,
5456 )
5457 .await;
5458
5459 assert!(matches!(result, Err(SuperviseError::Spawn { .. })));
5460 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
5461 assert_snapshot_process_facts_cleared(&snapshot);
5462 }
5463
5464 #[tokio::test]
5465 async fn start_revives_stranded_restarting_but_not_pending_backoff() {
5466 let supervisor =
5467 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5468 let runtime = supervisor.runtime_config();
5469 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(
5470 ModuleState::Restarting,
5471 true,
5472 )));
5473 let spec = ModuleSpec {
5474 module_id: "start-stranded-restarting".to_string(),
5475 program: super::terminal_history_tests::fake_aft_stub_path(),
5476 args: Vec::new(),
5477 env: vec![("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string())],
5478 reserved: false,
5479 reserved_prefixes: Vec::new(),
5480 protocol: ModuleProtocol::None,
5481 overlap: Default::default(),
5482 };
5483 let mut child = None;
5484 lock_snapshot(&snapshot).unwrap().respawn_pending = true;
5485 assert!(!super::set_child_enabled(
5486 &spec,
5487 &runtime,
5488 &Registry::default(),
5489 &supervisor.process_liveness,
5490 &snapshot,
5491 &mut child,
5492 true
5493 )
5494 .await
5495 .unwrap());
5496 assert!(child.is_none());
5497 lock_snapshot(&snapshot).unwrap().respawn_pending = false;
5498 assert!(super::set_child_enabled(
5499 &spec,
5500 &runtime,
5501 &Registry::default(),
5502 &supervisor.process_liveness,
5503 &snapshot,
5504 &mut child,
5505 true
5506 )
5507 .await
5508 .unwrap());
5509 assert_eq!(
5510 lock_snapshot(&snapshot).unwrap().state,
5511 ModuleState::Running
5512 );
5513 let mut child = child.unwrap();
5514 child.start_kill().unwrap();
5515 child.wait().await.unwrap();
5516 }
5517
5518 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5519 async fn failed_reload_spawn_clears_current_process_facts() {
5520 let supervisor =
5521 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5522 let mut runtime = supervisor.runtime_config();
5523 runtime.restart_policy = RestartPolicy::new(0, Duration::ZERO);
5524 let snapshot = stale_process_snapshot(ModuleState::Running, true);
5525 let mut child = None;
5526 let spec = ModuleSpec {
5527 module_id: "failed-reload-clears-facts".to_string(),
5528 program: PathBuf::from("/unused/failed-reload-module"),
5529 args: Vec::new(),
5530 env: Vec::new(),
5531 reserved: false,
5532 reserved_prefixes: Vec::new(),
5533 protocol: ModuleProtocol::Subc,
5534 overlap: Default::default(),
5535 };
5536
5537 let result = handle_reload_spawn_failure(
5538 &spec,
5539 &runtime,
5540 &supervisor.process_liveness,
5541 &snapshot,
5542 &mut child,
5543 "forced reload spawn failure".to_string(),
5544 )
5545 .await;
5546
5547 assert!(matches!(result, Err(SuperviseError::ReloadFailed { .. })));
5548 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
5549 assert_snapshot_process_facts_cleared(&snapshot);
5550 }
5551
5552 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5553 async fn dropping_a_module_with_an_active_monitor_clears_current_process_facts() {
5554 let supervisor =
5555 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5556 let snapshot = stale_process_snapshot(ModuleState::Running, true);
5557 let module = supervisor.supervised_module(
5558 ModuleSpec {
5559 module_id: "drop-clears-facts".to_string(),
5560 program: PathBuf::from("/unused/drop-module"),
5561 args: Vec::new(),
5562 env: Vec::new(),
5563 reserved: false,
5564 reserved_prefixes: Vec::new(),
5565 protocol: ModuleProtocol::Subc,
5566 overlap: Default::default(),
5567 },
5568 supervisor.runtime_config(),
5569 Arc::clone(&snapshot),
5570 None,
5571 );
5572 assert!(!module
5573 .inner
5574 .monitor
5575 .lock()
5576 .unwrap()
5577 .as_ref()
5578 .unwrap()
5579 .is_finished());
5580
5581 drop(module);
5582
5583 assert_eq!(
5584 lock_snapshot(&snapshot).unwrap().state,
5585 ModuleState::Stopped
5586 );
5587 assert_snapshot_process_facts_cleared(&snapshot);
5588 }
5589
5590 #[cfg(unix)]
5591 #[tokio::test]
5592 async fn rescan_preserves_running_protocol_until_respawn() {
5593 let dir = cortexkit_test_support::ScratchDir::new("rescan-protocol");
5594 let initial = ModuleSpec {
5595 module_id: "rescan-protocol".into(),
5596 program: PathBuf::from("/bin/sleep"),
5597 args: vec!["60".into()],
5598 env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
5599 .into_iter()
5600 .map(|key| (key.into(), dir.path().to_string_lossy().into_owned()))
5601 .collect(),
5602 reserved: false,
5603 reserved_prefixes: vec![],
5604 protocol: ModuleProtocol::None,
5605 overlap: Default::default(),
5606 };
5607 let supervisor =
5608 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
5609 let module = supervisor.spawn(initial.clone()).unwrap();
5610 assert!(module.status().unwrap().live);
5611 let mut next = initial;
5612 next.protocol = ModuleProtocol::Subc;
5613 module
5614 .update_configuration(next.clone(), HealthConfig::default(), None)
5615 .await
5616 .unwrap();
5617 assert!(
5618 module.status().unwrap().live,
5619 "rescan must not require HELLO from the old non-wire process"
5620 );
5621 let runtime = supervisor.runtime_config();
5622 let action = on_child_exit(
5623 &next,
5624 RestartPolicy::default(),
5625 &supervisor.registry,
5626 &module.inner.snapshot,
5627 &runtime.terminal_ring,
5628 &runtime.spawn_events,
5629 &runtime.child_roster,
5630 ExitReport {
5631 kind: ExitKind::Clean,
5632 code: Some(0),
5633 signal: None,
5634 at_ms: unix_ms_now(),
5635 },
5636 )
5637 .await;
5638 assert!(
5639 matches!(action, NextAction::Restart { .. }),
5640 "the old non-wire process's clean exit must restart"
5641 );
5642 module.drain().await.unwrap();
5643 }
5644
5645 #[cfg(unix)]
5646 #[tokio::test]
5647 async fn orphan_identity_matches_path_names_and_shebang_interpreters() {
5648 use std::os::unix::fs::PermissionsExt;
5649 let dir = cortexkit_test_support::ScratchDir::new("spawn-image-identity");
5650 let script = dir.join("module.sh");
5651 std::fs::write(&script, "#!/bin/sh\nwhile :; do sleep 0.1; done\n").unwrap();
5652 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o700)).unwrap();
5653 let record_path = dir.join("live-children.json");
5654 let supervisor =
5655 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5656 .with_live_children_record(&record_path);
5657 for (program, args) in [
5658 (PathBuf::from("sleep"), vec!["60".into()]),
5659 (script, vec![]),
5660 ] {
5661 let spec = ModuleSpec {
5662 module_id: "image-identity".into(),
5663 program: program.clone(),
5664 args,
5665 env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
5666 .into_iter()
5667 .map(|key| (key.into(), dir.to_string_lossy().into_owned()))
5668 .collect(),
5669 reserved: false,
5670 reserved_prefixes: vec![],
5671 protocol: ModuleProtocol::None,
5672 overlap: Default::default(),
5673 };
5674 let module = supervisor.spawn(spec).unwrap();
5675 #[cfg(target_os = "macos")]
5676 {
5677 let deadline = Instant::now() + Duration::from_secs(5);
5680 while crate::live_children::read_record(&record_path)
5681 .unwrap()
5682 .iter()
5683 .all(|entry| entry.executable.is_none())
5684 {
5685 assert!(Instant::now() < deadline, "module image was not confirmed");
5686 tokio::time::sleep(Duration::from_millis(5)).await;
5687 }
5688 }
5689 let entry = crate::live_children::read_record(&record_path)
5690 .unwrap()
5691 .pop()
5692 .unwrap();
5693 let observed = subc_os::Process::open(entry.pid)
5694 .unwrap()
5695 .unwrap()
5696 .observe()
5697 .unwrap();
5698 let verdict = crate::live_children::identity_verdict(&entry, entry.pid, &observed);
5699 module.drain().await.unwrap();
5700 assert_eq!(
5701 verdict,
5702 crate::live_children::IdentityVerdict::Matches,
5703 "program {program:?}: recorded {entry:?}, observed {observed:?}"
5704 );
5705 }
5706 }
5707
5708 #[cfg(unix)]
5709 fn http_fixture(
5710 dir: &std::path::Path,
5711 url: &str,
5712 threshold: u32,
5713 ) -> crate::daemon_config::ConfiguredModule {
5714 let path = dir.join("subc.jsonc");
5715 std::fs::write(&path, serde_json::json!({"version": 1, "modules": {"http-process": {
5716 "program": "/bin/sleep", "args": ["60"], "protocol": "none",
5717 "env": {"XDG_DATA_HOME":dir,"XDG_RUNTIME_DIR":dir,"XDG_CONFIG_HOME":dir},
5718 "health": {"http":url,"cadence_ms":20,"deadline_ms":200,"failure_threshold":threshold},
5719 "restart": {"backoff_ms":1,"max_backoff_ms":1}, "drain_timeout_ms":10
5720 }}}).to_string()).unwrap();
5721 crate::daemon_config::load(&path)
5722 .unwrap()
5723 .unwrap()
5724 .modules
5725 .pop()
5726 .unwrap()
5727 }
5728
5729 #[cfg(unix)]
5730 async fn wait_http_health(module: &SupervisedModule, status: SupervisorHealthStatus) {
5731 timeout(Duration::from_secs(5), async {
5732 loop {
5733 if module.status().unwrap().health.status == status {
5734 break;
5735 }
5736 sleep(Duration::from_millis(5)).await;
5737 }
5738 })
5739 .await
5740 .unwrap_or_else(|_| {
5741 panic!(
5742 "expected {status:?}, got {:?}",
5743 module.status().unwrap().health
5744 )
5745 });
5746 }
5747
5748 #[cfg(unix)]
5749 #[tokio::test]
5750 async fn http_health_status_flips_ok_failing_ok() {
5751 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5752 let dir = cortexkit_test_support::ScratchDir::new("http-status-flips");
5753 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5754 let status = Arc::new(std::sync::atomic::AtomicU16::new(200));
5755 let serving_status = status.clone();
5756 let url = format!("http://{}/healthz", listener.local_addr().unwrap());
5757 let server = tokio::spawn(async move {
5758 loop {
5759 let (mut stream, _) = listener.accept().await.unwrap();
5760 let mut request = [0u8; 2048];
5761 let count = stream.read(&mut request).await.unwrap();
5762 assert!(count > 0, "a probe must send an HTTP request");
5763 let code = serving_status.load(std::sync::atomic::Ordering::SeqCst);
5764 let body = if code == 200 {
5765 "ready"
5766 } else {
5767 "{\"status\":\"unavailable\",\"error\":\"scratch failure\"}"
5768 };
5769 let response = format!("HTTP/1.1 {code} scratch\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());
5770 let _ = stream.write_all(response.as_bytes()).await;
5771 }
5772 });
5773 let configured = http_fixture(&dir, &url, 1000);
5774 let module =
5775 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5776 .supervise_configured_with_health(
5777 configured.module_spec(),
5778 true,
5779 configured.health,
5780 configured.drain_timeout_ms,
5781 configured.restart,
5782 )
5783 .unwrap();
5784 wait_http_health(&module, SupervisorHealthStatus::Ok).await;
5785 status.store(503, std::sync::atomic::Ordering::SeqCst);
5786 wait_http_health(&module, SupervisorHealthStatus::Failing).await;
5787 assert!(module
5788 .status()
5789 .unwrap()
5790 .health
5791 .detail
5792 .unwrap()
5793 .contains("scratch failure"));
5794 status.store(200, std::sync::atomic::Ordering::SeqCst);
5795 wait_http_health(&module, SupervisorHealthStatus::Ok).await;
5796 assert_eq!(module.status().unwrap().health.consecutive_failures, 0);
5797 let before = module.status().unwrap();
5798 let (spec, mut health) = module.configuration().unwrap();
5799 health.http = None;
5800 module
5801 .update_configuration(spec.clone(), health.clone(), Some(10))
5802 .await
5803 .unwrap();
5804 wait_http_health(&module, SupervisorHealthStatus::Unknown).await;
5805 health.http = Some(url);
5806 module
5807 .update_configuration(spec, health, Some(10))
5808 .await
5809 .unwrap();
5810 wait_http_health(&module, SupervisorHealthStatus::Ok).await;
5811 assert_eq!(
5812 module.status().unwrap().pid,
5813 before.pid,
5814 "changing a probe must apply live, not restart its process"
5815 );
5816 let (spec, mut health) = module.configuration().unwrap();
5817 health.failure_threshold = 2;
5818 module
5819 .update_configuration(spec, health, Some(10))
5820 .await
5821 .unwrap();
5822 status.store(503, std::sync::atomic::Ordering::SeqCst);
5823 timeout(Duration::from_secs(5), async {
5824 while module.status().unwrap().spawn_generation == before.spawn_generation {
5825 sleep(Duration::from_millis(5)).await;
5826 }
5827 })
5828 .await
5829 .expect("sustained HTTP 503 responses must trigger the health restart policy");
5830 module.drain().await.unwrap();
5831 server.abort();
5832 }
5833
5834 #[cfg(unix)]
5835 #[tokio::test]
5836 async fn http_health_no_listener_restarts_after_consecutive_failures() {
5837 let dir = cortexkit_test_support::ScratchDir::new("http-refused");
5838 let unused = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5839 let url = format!("http://{}/healthz", unused.local_addr().unwrap());
5840 drop(unused);
5841 let configured = http_fixture(&dir, &url, 2);
5842 let module =
5843 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
5844 .supervise_configured_with_health(
5845 configured.module_spec(),
5846 true,
5847 configured.health,
5848 configured.drain_timeout_ms,
5849 configured.restart,
5850 )
5851 .unwrap();
5852 let before = module.status().unwrap().spawn_generation;
5853 timeout(Duration::from_secs(5), async {
5854 loop {
5855 let status = module.status().unwrap();
5856 if status.spawn_generation > before {
5857 assert!(status.lifetime_restarts > 0);
5858 break;
5859 }
5860 sleep(Duration::from_millis(5)).await;
5861 }
5862 })
5863 .await
5864 .expect("sustained HTTP refusal must trigger the health restart policy");
5865 module.drain().await.unwrap();
5866 }
5867
5868 #[cfg(unix)]
5869 #[tokio::test]
5870 async fn http_health_timeout_honours_deadline() {
5871 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5872 let url = format!("http://{}/healthz", listener.local_addr().unwrap());
5873 let server = tokio::spawn(async move {
5874 let _held = listener.accept().await.unwrap();
5875 std::future::pending::<()>().await;
5876 });
5877 let error = timeout(
5878 Duration::from_secs(1),
5879 probe_http_health(&url, Duration::from_millis(10)),
5880 )
5881 .await
5882 .expect("the probe must enforce its own deadline")
5883 .unwrap_err();
5884 server.abort();
5885 assert!(error.to_string().contains("timed out"));
5886 }
5887
5888 #[cfg(unix)]
5889 #[tokio::test]
5890 async fn http_health_localhost_reaches_an_ipv6_only_listener() {
5891 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5892 let listener = tokio::net::TcpListener::bind("[::1]:0").await.unwrap();
5893 let url = format!(
5894 "http://localhost:{}/healthz",
5895 listener.local_addr().unwrap().port()
5896 );
5897 let server = tokio::spawn(async move {
5898 let (mut stream, _) = listener.accept().await.unwrap();
5899 let mut request = [0u8; 2048];
5900 assert!(stream.read(&mut request).await.unwrap() > 0);
5901 stream
5902 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
5903 .await
5904 .unwrap();
5905 });
5906 assert_eq!(
5911 probe_http_health(&url, Duration::from_secs(10))
5912 .await
5913 .unwrap()
5914 .status,
5915 HealthStatus::Ok
5916 );
5917 server.await.unwrap();
5918 }
5919
5920 #[cfg(unix)]
5921 #[tokio::test]
5922 async fn http_health_timeout_keeps_partial_status_and_body() {
5923 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5924 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5925 let url = format!("http://{}/healthz", listener.local_addr().unwrap());
5926 let server = tokio::spawn(async move {
5927 let (mut stream, _) = listener.accept().await.unwrap();
5928 let mut request = [0u8; 2048];
5929 assert!(stream.read(&mut request).await.unwrap() > 0);
5930 stream
5931 .write_all(
5932 b"HTTP/1.1 503 Unavailable\r\nContent-Length: 100\r\n\r\npartial diagnostic",
5933 )
5934 .await
5935 .unwrap();
5936 std::future::pending::<()>().await;
5937 });
5938 let error = probe_http_health(&url, Duration::from_secs(1))
5939 .await
5940 .unwrap_err()
5941 .to_string();
5942 server.abort();
5943 assert!(
5944 error.contains("timed out")
5945 && error.contains("503 Unavailable")
5946 && error.contains("partial diagnostic"),
5947 "{error}"
5948 );
5949 }
5950
5951 #[cfg(unix)]
5952 #[tokio::test]
5953 async fn http_health_diagnostic_body_is_bounded_to_200_bytes() {
5954 use tokio::io::{AsyncReadExt, AsyncWriteExt};
5955 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5956 let url = format!("http://{}/healthz", listener.local_addr().unwrap());
5957 let server = tokio::spawn(async move {
5958 let (mut stream, _) = listener.accept().await.unwrap();
5959 let mut request = [0u8; 2048];
5960 assert!(stream.read(&mut request).await.unwrap() > 0);
5961 let body = format!("{}not-in-diagnostic", "x".repeat(400));
5962 let response = format!(
5963 "HTTP/1.1 503 Unavailable\r\nContent-Length: {}\r\n\r\n{body}",
5964 body.len()
5965 );
5966 stream.write_all(response.as_bytes()).await.unwrap();
5967 });
5968 let error = probe_http_health(&url, Duration::from_secs(1))
5969 .await
5970 .unwrap_err()
5971 .to_string();
5972 server.await.unwrap();
5973 assert!(error.ends_with(&"x".repeat(200)), "{error}");
5974 assert_eq!(error.split(": ").last().unwrap().len(), 200);
5975 assert!(!error.contains("not-in-diagnostic"));
5976 }
5977
5978 #[cfg(unix)]
5979 #[tokio::test]
5980 async fn http_health_real_nats_server_monitoring() {
5981 if std::process::Command::new("nats-server")
5982 .arg("--version")
5983 .env("XDG_DATA_HOME", std::env::temp_dir())
5984 .env("XDG_RUNTIME_DIR", std::env::temp_dir())
5985 .env("XDG_CONFIG_HOME", std::env::temp_dir())
5986 .output()
5987 .is_err()
5988 {
5989 eprintln!(
5990 "skipped: http_health_real_nats_server_monitoring (nats-server is not on PATH)"
5991 );
5992 return;
5993 }
5994 let dir = cortexkit_test_support::ScratchDir::new("nats-http-monitoring");
5995 let monitor = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5996 let port = monitor.local_addr().unwrap().port();
5997 let client = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5998 let client_port = client.local_addr().unwrap().port();
5999 let config = dir.join("server.conf");
6000 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();
6001 let mut configured = http_fixture(&dir, &format!("http://127.0.0.1:{port}/healthz"), 1000);
6002 configured.program = PathBuf::from("nats-server");
6003 configured.args = vec!["-c".into(), config.to_string_lossy().into_owned()];
6004 drop(monitor);
6005 drop(client);
6006 let module =
6007 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
6008 .supervise_configured_with_health(
6009 configured.module_spec(),
6010 true,
6011 configured.health,
6012 configured.drain_timeout_ms,
6013 configured.restart,
6014 )
6015 .unwrap();
6016 wait_http_health(&module, SupervisorHealthStatus::Ok).await;
6017 module.drain().await.unwrap();
6018 }
6019
6020 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
6021 async fn configuration_update_does_not_replace_captured_running_process_facts() {
6022 let supervisor =
6023 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default());
6024 let snapshot = stale_process_snapshot(ModuleState::Running, true);
6025 let initial = ModuleSpec {
6026 module_id: "rescan-preserves-spawn-facts".to_string(),
6027 program: PathBuf::from("/spawned/module"),
6028 args: Vec::new(),
6029 env: Vec::new(),
6030 reserved: false,
6031 reserved_prefixes: Vec::new(),
6032 protocol: ModuleProtocol::Subc,
6033 overlap: Default::default(),
6034 };
6035 let module = supervisor.supervised_module(
6036 initial.clone(),
6037 supervisor.runtime_config(),
6038 snapshot,
6039 None,
6040 );
6041 let before = module.status().unwrap();
6042 let mut replacement = initial;
6043 replacement.program = PathBuf::from("/rescanned/replacement-module");
6044
6045 module
6046 .update_configuration(replacement, HealthConfig::default(), None)
6047 .await
6048 .unwrap();
6049
6050 let after = module.status().unwrap();
6051 assert_eq!(after.pid, before.pid);
6052 assert_eq!(after.spawned_at_ms, before.spawned_at_ms);
6053 assert_eq!(after.spawned_from, before.spawned_from);
6054 drop(module);
6055 }
6056}
6057
6058fn unix_ms_now() -> u64 {
6059 SystemTime::now()
6060 .duration_since(UNIX_EPOCH)
6061 .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64)
6062 .unwrap_or(0)
6063}
6064
6065async fn supervise_loop(
6066 mut spec: ModuleSpec,
6067 mut runtime: SupervisorRuntimeConfig,
6068 registry: Arc<Registry>,
6069 process_liveness: Arc<SupervisorProcessLiveness>,
6070 snapshot: SharedSnapshot,
6071 mut child: Option<SupervisedChild>,
6072 mut commands: mpsc::Receiver<SupervisorCommand>,
6073) {
6074 let mut health_probe = HealthProbeRuntime::default();
6075 let mut registration_changes = match registry.subscribe_module_changes(&spec.module_id) {
6079 Ok(changes) => Some(changes),
6080 Err(err) => {
6081 warn!(module_id = %spec.module_id, error = %err, "health prober could not subscribe to registry");
6084 None
6085 }
6086 };
6087 let mut pending_respawn: Option<PendingRespawn> = None;
6091 let mut requeued: VecDeque<SupervisorCommand> = VecDeque::new();
6094 #[cfg(target_os = "linux")]
6095 let mut previous_instance_recheck = tokio::time::interval_at(
6096 Instant::now() + Duration::from_secs(5),
6097 Duration::from_secs(5),
6098 );
6099 #[cfg(target_os = "linux")]
6100 previous_instance_recheck.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
6101 loop {
6102 #[cfg(test)]
6103 {
6104 lock_snapshot(&snapshot).unwrap().actor_select = None;
6105 }
6106 #[cfg(target_os = "macos")]
6107 if let Some(active) = child.as_mut() {
6108 active.confirm_privacy_exec().await;
6109 }
6110 if let Some(scheduled) = runtime
6111 .scheduled_respawn
6112 .lock()
6113 .unwrap_or_else(|p| p.into_inner())
6114 .take()
6115 {
6116 pending_respawn = Some(scheduled);
6117 }
6118 if pending_respawn.is_some() && (child.is_some() || !respawn_still_pending(&snapshot)) {
6119 pending_respawn = None;
6120 cancel_deferred_reload(
6121 &runtime,
6122 &spec.module_id,
6123 "respawn cancelled by a supervisor command",
6124 );
6125 }
6126 if child.is_none()
6127 && pending_respawn.is_none()
6128 && lock_snapshot(&snapshot).is_ok_and(|state| state.waiting_for_previous_pid.is_none())
6129 {
6130 cancel_deferred_reload(
6131 &runtime,
6132 &spec.module_id,
6133 "respawn cancelled before a replacement was spawned",
6134 );
6135 let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| {
6136 state.respawn_pending = false;
6137 state.coalesced_restart_pending = false;
6138 if matches!(
6139 state.state,
6140 ModuleState::Restarting
6141 | ModuleState::Starting
6142 | ModuleState::Draining
6143 | ModuleState::Unresponsive
6144 ) {
6145 error!(module_id = %spec.module_id, state = ?state.state,
6146 "supervision operation ended without a child or pending respawn; marking failed so start can retry");
6147 state.state = ModuleState::Failed;
6148 clear_current_process_facts(state);
6149 }
6150 });
6151 }
6152 if let Some(command) = requeued.pop_front() {
6153 if !handle_supervisor_command(
6154 command,
6155 &mut spec,
6156 &mut runtime,
6157 ®istry,
6158 &process_liveness,
6159 &snapshot,
6160 &mut child,
6161 &mut commands,
6162 &mut requeued,
6163 )
6164 .await
6165 {
6166 return;
6167 }
6168 if child.is_some() || !respawn_still_pending(&snapshot) {
6169 pending_respawn = None;
6170 let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| {
6171 state.respawn_pending = false
6172 });
6173 }
6174 continue;
6175 }
6176 if child.is_some() {
6177 #[cfg(test)]
6178 {
6179 lock_snapshot(&snapshot).unwrap().actor_turns += 1;
6180 }
6181 health_probe.refresh_registration(&spec, &runtime, ®istry, &snapshot);
6182 let wake_after = health_probe.wake_after();
6183 let probe_sleep = async move {
6184 match wake_after {
6185 Some(delay) => sleep(delay).await,
6186 None => std::future::pending().await,
6187 }
6188 };
6189 tokio::pin!(probe_sleep);
6190 let wire_child = running_protocol(&spec, &snapshot) == ModuleProtocol::Subc;
6195 #[cfg(test)]
6196 {
6197 let registration_pending = wire_child
6198 && registration_changes
6199 .as_ref()
6200 .is_some_and(|changes| changes.has_changed().unwrap());
6201 let mut state = lock_snapshot(&snapshot).unwrap();
6202 state.actor_select = (!registration_pending).then_some(ActorSelectCheckpoint {
6203 generation: state.spawn_generation,
6204 turn: state.actor_turns,
6205 registered_connection: health_probe.registered_connection,
6206 next_probe_at: health_probe.next_probe_at,
6207 wake_after,
6208 });
6209 }
6210 let active_child = child.as_mut().expect("child checked above");
6211 tokio::select! {
6212 wait_result = active_child.wait() => {
6213 #[cfg(test)]
6214 { lock_snapshot(&snapshot).unwrap().actor_select = None; }
6215 let exit_report = match wait_result {
6224 Ok(status) => classify_reaped_child_exit(&snapshot, active_child, &status),
6225 Err(err) => {
6226 active_child.drain_stderr(&spec.module_id).await;
6227 fail_after_wait_error(
6228 &spec.module_id,
6229 &snapshot,
6230 &runtime.terminal_ring,
6231 &runtime.spawn_events,
6232 format!("failed to wait for supervised module: {err}"),
6233 );
6234 untrack_if_registration_released(
6235 &process_liveness,
6236 ®istry,
6237 &spec.module_id,
6238 &snapshot,
6239 );
6240 error!(module_id = %spec.module_id, error = %err, "failed to wait for supervised module");
6241 child = None;
6242 continue;
6243 }
6244 };
6245 active_child.drain_stderr(&spec.module_id).await;
6246
6247 let next = on_child_exit(
6248 &spec,
6249 runtime.restart_policy,
6250 ®istry,
6251 &snapshot,
6252 &runtime.terminal_ring,
6253 &runtime.spawn_events,
6254 &runtime.child_roster,
6255 exit_report,
6256 ).await;
6257 active_child.release_roster();
6260 match next {
6261 NextAction::Stop { registration_released } => {
6262 if registration_released {
6263 process_liveness.untrack_if_current(&spec.module_id, &snapshot);
6264 }
6265 child = None;
6266 }
6267 NextAction::Restart { schedule } => {
6268 let delay = schedule.map_or(
6269 runtime.restart_policy.delay_for_restart(0),
6270 |schedule| schedule.delay,
6271 );
6272 if let Some(schedule) = schedule {
6273 log_crash_respawn(&spec.module_id, schedule);
6274 }
6275 child = None;
6283 pending_respawn = Some(PendingRespawn { deadline: Instant::now() + delay, kind: RespawnKind::Spawn });
6284 let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| state.respawn_pending = true);
6285 }
6286 }
6287 }
6288 command = commands.recv() => {
6289 #[cfg(test)]
6290 { lock_snapshot(&snapshot).unwrap().actor_select = None; }
6291 let Some(command) = command else {
6292 return;
6293 };
6294 if !handle_supervisor_command(
6295 command,
6296 &mut spec,
6297 &mut runtime,
6298 ®istry,
6299 &process_liveness,
6300 &snapshot,
6301 &mut child,
6302 &mut commands,
6303 &mut requeued,
6304 ).await {
6305 return;
6306 }
6307 }
6308 _ = async {
6309 match registration_changes.as_mut() {
6310 Some(changes) => { let _ = changes.changed().await; }
6311 None => std::future::pending().await,
6312 }
6313 }, if wire_child => {
6314 #[cfg(test)]
6315 { lock_snapshot(&snapshot).unwrap().actor_select = None; }
6316 }
6317 _ = &mut probe_sleep => {
6318 #[cfg(test)]
6319 { lock_snapshot(&snapshot).unwrap().actor_select = None; }
6320 if health_probe.due() {
6321 run_health_probe_cycle(
6322 &spec,
6323 &runtime,
6324 ®istry,
6325 &process_liveness,
6326 &snapshot,
6327 &mut child,
6328 ).await;
6329 if child.is_some() {
6330 health_probe.schedule_next(&spec, runtime.health.cadence);
6331 }
6332 }
6333 }
6334 }
6335 } else if let Some(pending) = pending_respawn {
6336 tokio::select! {
6337 _ = sleep_until(pending.deadline) => {
6338 pending_respawn = None;
6339 let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| state.respawn_pending = false);
6340 if !respawn_still_pending(&snapshot) {
6344 continue;
6345 }
6346 if runtime.child_roster.is_closed() {
6351 let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| {
6352 state.state = ModuleState::Stopped;
6353 });
6354 debug!(module_id = %spec.module_id, "crash respawn cancelled by daemon shutdown");
6355 continue;
6356 }
6357 if let Err(err) = release_dead_registration(
6358 ®istry,
6359 runtime.forwarding.as_deref(),
6360 &snapshot,
6361 &spec.module_id,
6362 ).await {
6363 fail_snapshot(&snapshot, Some(&spec.module_id), None);
6364 error!(module_id = %spec.module_id, error = %err, "registration did not release before restart");
6365 continue;
6366 }
6367
6368 if matches!(pending.kind, RespawnKind::Reload) {
6369 let reply = runtime.deferred_reload_reply.lock().unwrap_or_else(|p| p.into_inner()).take();
6370 let result = finish_reload_child(&spec, &runtime, ®istry, &process_liveness, &snapshot, &mut child).await;
6371 if let Some(reply) = reply { let _ = reply.send(result); }
6372 continue;
6373 }
6374 process_liveness.track(spec.module_id.clone(), Arc::clone(&snapshot));
6375 match spawn_and_mark_running(&spec, &runtime, &snapshot) {
6376 Ok(next_child) => {
6377 child = Some(next_child);
6378 debug!(module_id = %spec.module_id, "supervised module restarted after crash");
6379 }
6380 Err(err) => {
6381 fail_snapshot(&snapshot, Some(&spec.module_id), None);
6382 process_liveness.untrack_if_current(&spec.module_id, &snapshot);
6383 error!(module_id = %spec.module_id, error = %err, "failed to restart supervised module");
6384 }
6385 }
6386 }
6387 command = commands.recv() => {
6388 let Some(command) = command else {
6389 return;
6390 };
6391 if !handle_supervisor_command(
6392 command,
6393 &mut spec,
6394 &mut runtime,
6395 ®istry,
6396 &process_liveness,
6397 &snapshot,
6398 &mut child,
6399 &mut commands,
6400 &mut requeued,
6401 ).await {
6402 return;
6403 }
6404 if child.is_some() || !respawn_still_pending(&snapshot) {
6409 pending_respawn = None;
6410 let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| state.respawn_pending = false);
6411 }
6412 }
6413 }
6414 } else {
6415 #[cfg(target_os = "linux")]
6416 let command = if lock_snapshot(&snapshot)
6417 .is_ok_and(|state| state.waiting_for_previous_pid.is_some())
6418 {
6419 tokio::select! {
6420 command = commands.recv() => command,
6421 _ = previous_instance_recheck.tick() => {
6422 if runtime.child_roster.previous_instance_pid(&spec.module_id).is_none() {
6423 let enabled = lock_snapshot(&snapshot).is_ok_and(|state| state.enabled);
6424 if enabled && !runtime.child_roster.is_closed() {
6425 process_liveness.track(spec.module_id.clone(), Arc::clone(&snapshot));
6426 match spawn_and_mark_running(&spec, &runtime, &snapshot) {
6427 Ok(next_child) => child = Some(next_child),
6428 Err(error) => {
6429 fail_snapshot(&snapshot, Some(&spec.module_id), None);
6430 error!(module_id = %spec.module_id, %error, "failed to start module after its previous instance exited");
6431 }
6432 }
6433 } else {
6434 let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| {
6435 state.waiting_for_previous_pid = None;
6436 });
6437 }
6438 }
6439 continue;
6440 }
6441 }
6442 } else {
6443 commands.recv().await
6444 };
6445 #[cfg(not(target_os = "linux"))]
6446 let command = commands.recv().await;
6447 let Some(command) = command else {
6448 return;
6449 };
6450 if !handle_supervisor_command(
6451 command,
6452 &mut spec,
6453 &mut runtime,
6454 ®istry,
6455 &process_liveness,
6456 &snapshot,
6457 &mut child,
6458 &mut commands,
6459 &mut requeued,
6460 )
6461 .await
6462 {
6463 return;
6464 }
6465 }
6466 }
6467}
6468
6469fn log_crash_respawn(module_id: &str, schedule: CrashRestartSchedule) {
6470 info!(
6471 module_id,
6472 restart_in_window = schedule.restart_in_window,
6473 delay_ms = schedule.delay.as_millis() as u64,
6474 "respawning after crash"
6475 );
6476}
6477
6478fn respawn_still_pending(snapshot: &SharedSnapshot) -> bool {
6484 matches!(
6485 lock_snapshot(snapshot),
6486 Ok(state) if state.enabled && state.state == ModuleState::Restarting
6487 )
6488}
6489
6490enum NextAction {
6491 Stop {
6492 registration_released: bool,
6493 },
6494 Restart {
6495 schedule: Option<CrashRestartSchedule>,
6496 },
6497}
6498
6499#[allow(clippy::too_many_arguments)]
6500async fn handle_supervisor_command(
6501 command: SupervisorCommand,
6502 spec: &mut ModuleSpec,
6503 runtime: &mut SupervisorRuntimeConfig,
6504 registry: &Arc<Registry>,
6505 process_liveness: &SupervisorProcessLiveness,
6506 snapshot: &SharedSnapshot,
6507 child: &mut Option<SupervisedChild>,
6508 commands: &mut mpsc::Receiver<SupervisorCommand>,
6509 requeued: &mut VecDeque<SupervisorCommand>,
6510) -> bool {
6511 #[cfg(target_os = "linux")]
6512 let command = if let Some(pid) = runtime.child_roster.previous_instance_pid(&spec.module_id) {
6513 match command {
6514 SupervisorCommand::Restart { reply, .. }
6515 | SupervisorCommand::Reload { reply }
6516 | SupervisorCommand::Swap { reply, .. } => {
6517 let _ = reply.send(Err(previous_instance_error(spec, pid)));
6518 return true;
6519 }
6520 SupervisorCommand::SetEnabled {
6521 enabled: true,
6522 reply,
6523 } => {
6524 let _ = reply.send(Err(previous_instance_error(spec, pid)));
6525 return true;
6526 }
6527 other => other,
6528 }
6529 } else {
6530 command
6531 };
6532 match command {
6533 SupervisorCommand::Drain { reply } => {
6534 let result = drain_optional_child(
6537 &spec.module_id,
6538 spec.protocol,
6539 StopNotice::NotSent,
6540 registry,
6541 runtime.forwarding.as_deref(),
6542 snapshot,
6543 &runtime.terminal_ring,
6544 &runtime.spawn_events,
6545 child,
6546 runtime.drain_timeout,
6547 ModuleState::Stopped,
6548 None,
6549 )
6550 .await;
6551 let registration_released = result.is_ok();
6552 let _ = reply.send(result);
6553 if registration_released {
6554 process_liveness.untrack_if_current(&spec.module_id, snapshot);
6555 }
6556 false
6557 }
6558 SupervisorCommand::Retire { reply } => {
6559 let result = async {
6560 let stop_notice = begin_forwarding_drain_if_configured(
6561 spec,
6562 runtime,
6563 registry,
6564 snapshot,
6565 None,
6566 RouteCloseReason::Disable,
6567 )
6568 .await?;
6569 drain_optional_child(
6570 &spec.module_id,
6571 spec.protocol,
6572 stop_notice,
6573 registry,
6574 runtime.forwarding.as_deref(),
6575 snapshot,
6576 &runtime.terminal_ring,
6577 &runtime.spawn_events,
6578 child,
6579 runtime.drain_timeout,
6580 ModuleState::Stopped,
6581 None,
6582 )
6583 .await
6584 }
6585 .await;
6586 let registration_released = result.is_ok();
6587 let _ = reply.send(result);
6588 if registration_released {
6589 process_liveness.untrack_if_current(&spec.module_id, snapshot);
6590 }
6591 false
6592 }
6593 SupervisorCommand::Restart {
6594 drain_timeout_ms,
6595 received_at_generation,
6596 queued_at,
6597 reply,
6598 } => {
6599 info!(
6603 module_id = %spec.module_id,
6604 queued_ms = u64::try_from(queued_at.elapsed().as_millis()).unwrap_or(u64::MAX),
6605 "restart command dequeued"
6606 );
6607 let validation = match lock_snapshot(snapshot) {
6619 Ok(state) if !state.enabled => Err(SuperviseError::Disabled {
6620 module_id: spec.module_id.clone(),
6621 }),
6622 Ok(_) => Ok(()),
6623 Err(err) => Err(err),
6624 };
6625 let initiated = validation.is_ok();
6626 let _ = reply.send(validation);
6627 let satisfied_by_generation = if initiated && child.is_some() {
6638 lock_snapshot(snapshot).ok().and_then(|state| {
6639 (state.spawn_generation > received_at_generation
6640 && !state.configuration_updated_since_spawn)
6641 .then_some(state.spawn_generation)
6642 })
6643 } else {
6644 None
6645 };
6646 let satisfied_by_pending = initiated
6647 && child.is_none()
6648 && lock_snapshot(snapshot).ok().is_some_and(|mut state| {
6649 let pending = state.respawn_pending && !state.configuration_updated_since_spawn;
6650 if pending {
6651 state.coalesced_restart_pending = true;
6652 }
6653 pending
6654 });
6655 if satisfied_by_pending {
6656 debug!(module_id = %spec.module_id, "restart coalesced into the pending replacement");
6657 } else if let Some(generation) = satisfied_by_generation {
6658 info!(
6659 module_id = %spec.module_id,
6660 received_at_generation,
6661 "restart already satisfied by generation {generation}; not restarting again"
6662 );
6663 } else if initiated {
6664 let drain_timeout = drain_timeout_ms
6667 .map(Duration::from_millis)
6668 .unwrap_or(runtime.drain_timeout);
6669 if let Err(err) = restart_child(
6670 spec,
6671 runtime,
6672 registry,
6673 process_liveness,
6674 snapshot,
6675 child,
6676 drain_timeout,
6677 )
6678 .await
6679 {
6680 warn!(
6681 module_id = %spec.module_id,
6682 error = %err,
6683 "operator restart failed after initiation ack; module state carries the outcome"
6684 );
6685 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
6686 state.state = ModuleState::Failed;
6687 clear_current_process_facts(state);
6688 });
6689 }
6690 }
6691 true
6692 }
6693 SupervisorCommand::Reload { reply } => {
6694 let result =
6695 reload_child(spec, runtime, registry, process_liveness, snapshot, child).await;
6696 if result.is_ok()
6697 && runtime
6698 .scheduled_respawn
6699 .lock()
6700 .unwrap_or_else(|p| p.into_inner())
6701 .as_ref()
6702 .is_some_and(|pending| matches!(pending.kind, RespawnKind::Reload))
6703 {
6704 *runtime
6705 .deferred_reload_reply
6706 .lock()
6707 .unwrap_or_else(|p| p.into_inner()) = Some(reply);
6708 } else {
6709 let _ = reply.send(result);
6710 }
6711 true
6712 }
6713 SupervisorCommand::SetEnabled { enabled, reply } => {
6714 let result = set_child_enabled(
6715 spec,
6716 runtime,
6717 registry,
6718 process_liveness,
6719 snapshot,
6720 child,
6721 enabled,
6722 )
6723 .await;
6724 let _ = reply.send(result);
6725 true
6726 }
6727 SupervisorCommand::UpdateConfiguration {
6728 spec: next_spec,
6729 health,
6730 drain_timeout_ms,
6731 reply,
6732 } => {
6733 if let Some(handle) = &runtime.supervisor_handle {
6734 handle.apply_identity_configuration(&next_spec);
6735 }
6736 *spec = next_spec;
6737 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
6738 state.configuration_updated_since_spawn = true;
6739 });
6740 let health_changed = runtime.health != health;
6741 runtime.health = health;
6742 if health_changed {
6745 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
6746 state.health = ModuleHealthStatus::default();
6747 });
6748 }
6749 runtime.drain_timeout = drain_timeout_ms
6750 .map(Duration::from_millis)
6751 .unwrap_or(runtime.default_drain_timeout);
6752 *runtime
6753 .effective_drain_timeout
6754 .lock()
6755 .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.drain_timeout;
6756 let _ = reply.send(());
6757 true
6758 }
6759 SupervisorCommand::Swap {
6760 ready_timeout,
6761 reply,
6762 } => {
6763 let end = swap::run_swap(
6764 spec,
6765 runtime,
6766 registry,
6767 process_liveness,
6768 snapshot,
6769 child,
6770 commands,
6771 ready_timeout.unwrap_or(DEFAULT_SWAP_READY_TIMEOUT),
6772 reply,
6773 )
6774 .await;
6775 requeued.extend(end.requeue);
6776 true
6777 }
6778 }
6779}
6780
6781async fn restart_child(
6782 spec: &ModuleSpec,
6783 runtime: &SupervisorRuntimeConfig,
6784 registry: &Registry,
6785 process_liveness: &SupervisorProcessLiveness,
6786 snapshot: &SharedSnapshot,
6787 child: &mut Option<SupervisedChild>,
6788 drain_timeout: Duration,
6789) -> Result<(), SuperviseError> {
6790 if !lock_snapshot(snapshot)?.enabled {
6792 return Err(SuperviseError::Disabled {
6793 module_id: spec.module_id.clone(),
6794 });
6795 }
6796 let stop_notice = begin_forwarding_drain_with_timeout(
6797 spec,
6798 runtime,
6799 registry,
6800 snapshot,
6801 None,
6802 RouteCloseReason::Restart,
6803 drain_timeout,
6804 )
6805 .await?;
6806
6807 if child.is_some() {
6808 drain_optional_child(
6809 &spec.module_id,
6810 spec.protocol,
6811 stop_notice,
6812 registry,
6813 runtime.forwarding.as_deref(),
6814 snapshot,
6815 &runtime.terminal_ring,
6816 &runtime.spawn_events,
6817 child,
6818 drain_timeout,
6819 ModuleState::Restarting,
6820 Some(true),
6821 )
6822 .await?;
6823 } else {
6824 update_snapshot(snapshot, Some(&spec.module_id), |state| {
6825 state.enabled = true;
6826 state.state = ModuleState::Restarting;
6827 clear_current_process_facts(state);
6828 })?;
6829 release_dead_registration(
6830 registry,
6831 runtime.forwarding.as_deref(),
6832 snapshot,
6833 &spec.module_id,
6834 )
6835 .await?;
6836 }
6837
6838 reset_restart_count(snapshot, &spec.module_id)?;
6839 process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
6840 schedule_respawn(
6841 runtime,
6842 snapshot,
6843 &spec.module_id,
6844 runtime.restart_policy.backoff,
6845 RespawnKind::Spawn,
6846 )
6847}
6848
6849async fn reload_child(
6850 spec: &ModuleSpec,
6851 runtime: &SupervisorRuntimeConfig,
6852 registry: &Registry,
6853 process_liveness: &SupervisorProcessLiveness,
6854 snapshot: &SharedSnapshot,
6855 child: &mut Option<SupervisedChild>,
6856) -> Result<(), SuperviseError> {
6857 if !lock_snapshot(snapshot)?.enabled {
6859 return Err(SuperviseError::Disabled {
6860 module_id: spec.module_id.clone(),
6861 });
6862 }
6863 let stop_notice = begin_forwarding_drain(
6864 spec,
6865 runtime,
6866 registry,
6867 snapshot,
6868 Some(true),
6869 RouteCloseReason::Reload,
6870 )
6871 .await?;
6872
6873 if child.is_some() {
6874 drain_optional_child(
6875 &spec.module_id,
6876 spec.protocol,
6877 stop_notice,
6878 registry,
6879 runtime.forwarding.as_deref(),
6880 snapshot,
6881 &runtime.terminal_ring,
6882 &runtime.spawn_events,
6883 child,
6884 runtime.drain_timeout,
6885 ModuleState::Restarting,
6886 Some(true),
6887 )
6888 .await?;
6889 } else {
6890 update_snapshot(snapshot, Some(&spec.module_id), |state| {
6891 state.enabled = true;
6892 state.state = ModuleState::Restarting;
6893 clear_current_process_facts(state);
6894 })?;
6895 release_dead_registration(
6896 registry,
6897 runtime.forwarding.as_deref(),
6898 snapshot,
6899 &spec.module_id,
6900 )
6901 .await?;
6902 }
6903
6904 reset_restart_count(snapshot, &spec.module_id)?;
6905 process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
6906 schedule_respawn(
6907 runtime,
6908 snapshot,
6909 &spec.module_id,
6910 runtime.restart_policy.backoff,
6911 RespawnKind::Reload,
6912 )
6913}
6914
6915async fn finish_reload_child(
6916 spec: &ModuleSpec,
6917 runtime: &SupervisorRuntimeConfig,
6918 registry: &Registry,
6919 process_liveness: &SupervisorProcessLiveness,
6920 snapshot: &SharedSnapshot,
6921 child: &mut Option<SupervisedChild>,
6922) -> Result<(), SuperviseError> {
6923 process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
6924 let next_child = match spawn_and_mark_running(spec, runtime, snapshot) {
6925 Ok(next_child) => next_child,
6926 Err(err) => {
6927 return handle_reload_spawn_failure(
6928 spec,
6929 runtime,
6930 process_liveness,
6931 snapshot,
6932 child,
6933 format!("new child failed to spawn: {err}"),
6934 )
6935 .await;
6936 }
6937 };
6938 *child = Some(next_child);
6939
6940 let wait_outcome = {
6941 let active_child = child.as_mut().expect("new reload child was just stored");
6942 wait_for_registration_after_reload(
6943 registry,
6944 &spec.module_id,
6945 snapshot,
6946 active_child,
6947 REGISTRY_RELEASE_TIMEOUT,
6948 )
6949 .await?
6950 };
6951
6952 match wait_outcome {
6953 RegistrationWaitOutcome::Registered => {
6954 debug!(module_id = %spec.module_id, "supervised module reloaded and registered");
6955 Ok(())
6956 }
6957 RegistrationWaitOutcome::Exited(exit_report) => {
6958 if let Some(active_child) = child.as_mut() {
6959 active_child.drain_stderr(&spec.module_id).await;
6960 }
6961 let mut exited_child = child.take().expect("exited reload child is still stored");
6964 #[cfg(test)]
6965 if let Some(gate) = &runtime.test_reload_exit_record_gate {
6966 gate.reached.notify_one();
6967 gate.resume.notified().await;
6968 }
6969 let result = handle_reload_child_registration_failure(
6970 spec,
6971 runtime,
6972 registry,
6973 process_liveness,
6974 snapshot,
6975 child,
6976 ReloadRegistrationFailure {
6977 exit_report: registration_failure_exit_report(exit_report),
6978 reason: exited_child
6979 .spawn_failure
6980 .clone()
6981 .unwrap_or_else(|| "new child exited before registering".to_string()),
6982 },
6983 )
6984 .await;
6985 exited_child.release_roster();
6986 result
6987 }
6988 RegistrationWaitOutcome::TimedOut => {
6989 let mut timed_out_child = child
6990 .take()
6991 .expect("timed-out reload child is still running");
6992 timed_out_child
6993 .start_kill()
6994 .map_err(|source| SuperviseError::Kill {
6995 module_id: spec.module_id.clone(),
6996 source,
6997 })?;
6998 let status = timed_out_child
6999 .wait()
7000 .await
7001 .map_err(|source| SuperviseError::Wait {
7002 module_id: spec.module_id.clone(),
7003 source,
7004 })?;
7005 timed_out_child.drain_stderr(&spec.module_id).await;
7006 handle_reload_child_registration_failure(
7007 spec,
7008 runtime,
7009 registry,
7010 process_liveness,
7011 snapshot,
7012 child,
7013 ReloadRegistrationFailure {
7014 exit_report: registration_failure_exit_report(classify_reaped_child_exit(
7015 snapshot,
7016 &timed_out_child,
7017 &status,
7018 )),
7019 reason: format!(
7020 "new child did not register within {:?}",
7021 REGISTRY_RELEASE_TIMEOUT
7022 ),
7023 },
7024 )
7025 .await
7026 }
7027 }
7028}
7029
7030async fn set_child_enabled(
7031 spec: &ModuleSpec,
7032 runtime: &SupervisorRuntimeConfig,
7033 registry: &Registry,
7034 process_liveness: &SupervisorProcessLiveness,
7035 snapshot: &SharedSnapshot,
7036 child: &mut Option<SupervisedChild>,
7037 enabled: bool,
7038) -> Result<bool, SuperviseError> {
7039 let (current_enabled, current_state, respawn_pending) = {
7040 let state = lock_snapshot(snapshot)?;
7041 (state.enabled, state.state, state.respawn_pending)
7042 };
7043 let revive_terminal = enabled
7051 && current_enabled
7052 && child.is_none()
7053 && (matches!(current_state, ModuleState::Failed | ModuleState::Stopped)
7054 || (current_state == ModuleState::Restarting && !respawn_pending));
7055 if current_enabled == enabled && !revive_terminal {
7056 return Ok(false);
7057 }
7058
7059 if enabled {
7060 update_snapshot(snapshot, Some(&spec.module_id), |state| {
7061 state.enabled = true;
7062 state.state = ModuleState::Starting;
7063 clear_current_process_facts(state);
7064 })?;
7065 #[cfg(test)]
7066 if runtime.test_seed_stale_facts_before_enable_spawn {
7067 update_snapshot(snapshot, Some(&spec.module_id), |state| {
7068 state.process_alive = true;
7069 state.pid = Some(41);
7070 state.spawned_at_ms = Some(42);
7071 state.spawned_from = Some(PathBuf::from("/spawned/module"));
7072 state.spawned_file_identity = Some(SpawnedFileIdentity {
7073 device: 43,
7074 inode: 44,
7075 #[cfg(windows)]
7076 windows_image: None,
7077 });
7078 })?;
7079 }
7080 release_dead_registration(
7081 registry,
7082 runtime.forwarding.as_deref(),
7083 snapshot,
7084 &spec.module_id,
7085 )
7086 .await?;
7087 reset_restart_count(snapshot, &spec.module_id)?;
7088 process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
7089 let next_child = match spawn_and_mark_running(spec, runtime, snapshot) {
7090 Ok(next_child) => next_child,
7091 Err(err) => {
7092 if let Err(state_err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
7093 state.state = ModuleState::Failed;
7094 clear_current_process_facts(state);
7095 }) {
7096 error!(module_id = %spec.module_id, error = %state_err, "failed to record enable spawn failure");
7097 }
7098 process_liveness.untrack_if_current(&spec.module_id, snapshot);
7099 return Err(err);
7100 }
7101 };
7102 *child = Some(next_child);
7103 debug!(module_id = %spec.module_id, "supervised module enabled");
7104 Ok(true)
7105 } else {
7106 let stop_notice = begin_forwarding_drain_if_configured(
7107 spec,
7108 runtime,
7109 registry,
7110 snapshot,
7111 Some(false),
7112 RouteCloseReason::Disable,
7113 )
7114 .await?;
7115 drain_optional_child(
7116 &spec.module_id,
7117 spec.protocol,
7118 stop_notice,
7119 registry,
7120 runtime.forwarding.as_deref(),
7121 snapshot,
7122 &runtime.terminal_ring,
7123 &runtime.spawn_events,
7124 child,
7125 runtime.drain_timeout,
7126 ModuleState::Disabled,
7127 Some(false),
7128 )
7129 .await?;
7130 debug!(module_id = %spec.module_id, "supervised module disabled");
7131 Ok(true)
7132 }
7133}
7134
7135#[allow(clippy::too_many_arguments)]
7136async fn on_child_exit(
7137 spec: &ModuleSpec,
7138 policy: RestartPolicy,
7139 registry: &Registry,
7140 snapshot: &SharedSnapshot,
7141 terminal_ring: &Arc<Mutex<TerminalRing>>,
7142 spawn_events: &SpawnEventFeed,
7143 roster: &ChildRoster,
7144 exit_report: ExitReport,
7145) -> NextAction {
7146 if roster.is_closed() {
7152 return on_child_exit_during_daemon_shutdown(
7153 spec,
7154 registry,
7155 snapshot,
7156 terminal_ring,
7157 spawn_events,
7158 exit_report,
7159 )
7160 .await;
7161 }
7162 let unrequested_clean_exit_of_protocol_none = exit_report.kind == ExitKind::Clean
7177 && running_protocol(spec, snapshot) == ModuleProtocol::None;
7178 match exit_report.kind {
7179 ExitKind::Clean if !unrequested_clean_exit_of_protocol_none => {
7180 info!(
7181 module_id = %spec.module_id,
7182 exit_code = ?exit_report.code,
7183 exit_signal = ?exit_report.signal,
7184 "supervised module exited cleanly"
7185 );
7186 if let Err(err) = publish_after_terminal_record(
7187 &spec.module_id,
7188 || {
7189 record_terminal(
7190 &spec.module_id,
7191 terminal_ring,
7192 spawn_events,
7193 &exit_report,
7194 TerminalDisposition::Stopped,
7195 )
7196 },
7197 || {
7198 update_snapshot(snapshot, Some(&spec.module_id), |state| {
7199 state.state = ModuleState::Stopped;
7200 clear_current_process_facts(state);
7201 state.last_exit = Some(exit_report.clone());
7202 })
7203 },
7204 ) {
7205 error!(module_id = %spec.module_id, error = %err, "failed to publish clean module exit");
7206 }
7207 let registration_released = match wait_for_registration_release(
7208 registry,
7209 &spec.module_id,
7210 REGISTRY_RELEASE_TIMEOUT,
7211 )
7212 .await
7213 {
7214 Ok(()) => true,
7215 Err(err) => {
7216 warn!(module_id = %spec.module_id, error = %err, "registration still active after clean exit");
7217 false
7218 }
7219 };
7220 NextAction::Stop {
7221 registration_released,
7222 }
7223 }
7224 ExitKind::Clean | ExitKind::Crash => {
7225 if unrequested_clean_exit_of_protocol_none {
7226 warn!(
7227 module_id = %spec.module_id,
7228 exit_code = ?exit_report.code,
7229 exit_signal = ?exit_report.signal,
7230 "protocol-none module exited cleanly without a stop request; handling it as a crash"
7231 );
7232 } else {
7233 warn!(
7234 module_id = %spec.module_id,
7235 exit_code = ?exit_report.code,
7236 exit_signal = ?exit_report.signal,
7237 "supervised module exited abnormally (crash)"
7238 );
7239 }
7240 let mut restart_schedule = None;
7241 let mut disposition = TerminalDisposition::Disabled;
7242 let mut final_state = ModuleState::Disabled;
7243 let mut disposition_detail = lock_snapshot(snapshot)
7247 .ok()
7248 .and_then(|mut state| state.spawn_failure.take());
7249 let now = Instant::now();
7250 if let Err(err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
7251 if state.enabled {
7252 if let Some(schedule) = state.next_crash_restart(&policy, now) {
7253 final_state = ModuleState::Restarting;
7254 restart_schedule = Some(schedule);
7255 disposition = TerminalDisposition::Restarting;
7256 } else {
7257 final_state = ModuleState::Failed;
7258 disposition = TerminalDisposition::Failed;
7259 let budget = policy.budget_exhausted_detail();
7260 disposition_detail =
7261 Some(disposition_detail.take().map_or_else(
7262 || budget.clone(),
7263 |cause| format!("{cause}; {budget}"),
7264 ));
7265 }
7266 } else {
7267 disposition = TerminalDisposition::Disabled;
7268 }
7269 }) {
7270 error!(module_id = %spec.module_id, error = %err, "failed to record crashed module exit");
7271 return NextAction::Stop {
7272 registration_released: false,
7273 };
7274 }
7275 if disposition == TerminalDisposition::Failed {
7276 error!(
7281 module_id = %spec.module_id,
7282 max_restarts = policy.max_restarts,
7283 window_secs = policy.window.as_secs(),
7284 "module stopped: {}",
7285 policy.budget_exhausted_detail()
7286 );
7287 }
7288 if let Err(err) = publish_after_terminal_record(
7289 &spec.module_id,
7290 || {
7291 record_terminal_with_detail(
7292 &spec.module_id,
7293 terminal_ring,
7294 spawn_events,
7295 &exit_report,
7296 disposition,
7297 disposition_detail,
7298 )
7299 },
7300 || {
7301 update_snapshot(snapshot, Some(&spec.module_id), |state| {
7302 state.state = final_state;
7303 clear_current_process_facts(state);
7304 state.last_exit = Some(exit_report.clone());
7305 })
7306 },
7307 ) {
7308 error!(module_id = %spec.module_id, error = %err, "failed to publish crashed module exit");
7309 }
7310
7311 if let Some(schedule) = restart_schedule {
7312 NextAction::Restart {
7313 schedule: Some(schedule),
7314 }
7315 } else {
7316 let registration_released = match wait_for_registration_release(
7317 registry,
7318 &spec.module_id,
7319 REGISTRY_RELEASE_TIMEOUT,
7320 )
7321 .await
7322 {
7323 Ok(()) => true,
7324 Err(err) => {
7325 warn!(module_id = %spec.module_id, error = %err, "registration still active after failed module");
7326 false
7327 }
7328 };
7329 NextAction::Stop {
7330 registration_released,
7331 }
7332 }
7333 }
7334 ExitKind::DeliberateSeverance => {
7335 warn!(
7336 module_id = %spec.module_id,
7337 exit_code = ?exit_report.code,
7338 exit_signal = ?exit_report.signal,
7339 "supervised module exited after deliberate connection severance"
7340 );
7341 let should_restart = match lock_snapshot(snapshot) {
7342 Ok(state) => state.enabled,
7343 Err(err) => {
7344 error!(module_id = %spec.module_id, error = %err, "failed to read deliberately severed module state");
7345 return NextAction::Stop {
7346 registration_released: false,
7347 };
7348 }
7349 };
7350 let final_state = if should_restart {
7351 ModuleState::Restarting
7352 } else {
7353 ModuleState::Disabled
7354 };
7355 if let Err(err) = publish_after_terminal_record(
7356 &spec.module_id,
7357 || {
7358 record_terminal(
7359 &spec.module_id,
7360 terminal_ring,
7361 spawn_events,
7362 &exit_report,
7363 terminal_disposition(final_state),
7364 )
7365 },
7366 || {
7367 update_snapshot(snapshot, Some(&spec.module_id), |state| {
7368 state.state = final_state;
7369 clear_current_process_facts(state);
7370 state.last_exit = Some(exit_report.clone());
7371 state.lifetime_restarts += 1;
7372 })
7373 },
7374 ) {
7375 error!(module_id = %spec.module_id, error = %err, "failed to publish deliberately severed module exit");
7376 return NextAction::Stop {
7377 registration_released: false,
7378 };
7379 }
7380
7381 if should_restart {
7382 NextAction::Restart { schedule: None }
7383 } else {
7384 let registration_released = match wait_for_registration_release(
7385 registry,
7386 &spec.module_id,
7387 REGISTRY_RELEASE_TIMEOUT,
7388 )
7389 .await
7390 {
7391 Ok(()) => true,
7392 Err(err) => {
7393 warn!(module_id = %spec.module_id, error = %err, "registration still active after deliberately severed module exit");
7394 false
7395 }
7396 };
7397 NextAction::Stop {
7398 registration_released,
7399 }
7400 }
7401 }
7402 }
7403}
7404
7405async fn on_child_exit_during_daemon_shutdown(
7406 spec: &ModuleSpec,
7407 registry: &Registry,
7408 snapshot: &SharedSnapshot,
7409 terminal_ring: &Arc<Mutex<TerminalRing>>,
7410 spawn_events: &SpawnEventFeed,
7411 exit_report: ExitReport,
7412) -> NextAction {
7413 info!(
7414 module_id = %spec.module_id,
7415 exit_code = ?exit_report.code,
7416 exit_signal = ?exit_report.signal,
7417 exit_kind = ?exit_report.kind,
7418 "supervised module exited during daemon shutdown; not restarting it"
7419 );
7420 if let Err(err) = publish_after_terminal_record(
7421 &spec.module_id,
7422 || {
7423 record_terminal(
7424 &spec.module_id,
7425 terminal_ring,
7426 spawn_events,
7427 &exit_report,
7428 TerminalDisposition::DaemonShutdown,
7429 )
7430 },
7431 || {
7432 update_snapshot(snapshot, Some(&spec.module_id), |state| {
7433 state.state = ModuleState::Stopped;
7434 clear_current_process_facts(state);
7435 state.last_exit = Some(exit_report.clone());
7436 })
7437 },
7438 ) {
7439 error!(module_id = %spec.module_id, error = %err, "failed to publish module exit during daemon shutdown");
7440 }
7441 let registration_released =
7442 wait_for_registration_release(registry, &spec.module_id, REGISTRY_RELEASE_TIMEOUT)
7443 .await
7444 .is_ok();
7445 NextAction::Stop {
7446 registration_released,
7447 }
7448}
7449
7450fn publish_after_terminal_record<T>(
7455 module_id: &str,
7456 record: impl FnOnce(),
7457 publish: impl FnOnce() -> T,
7458) -> T {
7459 let recorded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(record));
7460 let published = publish();
7461 if let Err(panic) = recorded {
7462 error!(
7463 module_id,
7464 "terminal recording panicked; published final state before resuming unwind"
7465 );
7466 std::panic::resume_unwind(panic);
7467 }
7468 published
7469}
7470
7471fn fail_after_wait_error(
7472 module_id: &str,
7473 snapshot: &SharedSnapshot,
7474 terminal_ring: &Arc<Mutex<TerminalRing>>,
7475 spawn_events: &SpawnEventFeed,
7476 detail: String,
7477) {
7478 let report = wait_error_exit_report();
7479 publish_after_terminal_record(
7480 module_id,
7481 || {
7482 record_terminal_with_detail(
7483 module_id,
7484 terminal_ring,
7485 spawn_events,
7486 &report,
7487 TerminalDisposition::Failed,
7488 Some(detail),
7489 )
7490 },
7491 || fail_snapshot(snapshot, Some(module_id), Some(report.clone())),
7492 );
7493}
7494
7495#[cfg(test)]
7496fn record_wait_error_terminal(
7497 module_id: &str,
7498 terminal_ring: &Arc<Mutex<TerminalRing>>,
7499 spawn_events: &SpawnEventFeed,
7500) {
7501 record_terminal(
7502 module_id,
7503 terminal_ring,
7504 spawn_events,
7505 &wait_error_exit_report(),
7506 TerminalDisposition::Failed,
7507 );
7508}
7509
7510fn record_terminal(
7511 module_id: &str,
7512 terminal_ring: &Arc<Mutex<TerminalRing>>,
7513 spawn_events: &SpawnEventFeed,
7514 exit_report: &ExitReport,
7515 disposition: TerminalDisposition,
7516) {
7517 record_terminal_with_detail(
7518 module_id,
7519 terminal_ring,
7520 spawn_events,
7521 exit_report,
7522 disposition,
7523 None,
7524 );
7525}
7526
7527fn durable_terminal_history_of(
7531 terminal_ring: &Mutex<TerminalRing>,
7532 module_id: &str,
7533) -> subc_control::TerminalHistory {
7534 let read = terminal_ring
7535 .lock()
7536 .unwrap_or_else(|p| p.into_inner())
7537 .capture_durable_history();
7538 read.read(module_id)
7539}
7540
7541fn record_terminal_with_detail(
7542 module_id: &str,
7543 terminal_ring: &Arc<Mutex<TerminalRing>>,
7544 spawn_events: &SpawnEventFeed,
7545 exit_report: &ExitReport,
7546 disposition: TerminalDisposition,
7547 disposition_detail: Option<String>,
7548) {
7549 #[cfg(test)]
7550 terminal_history_tests::before_terminal_record(spawn_events);
7551 spawn_events.emit_exited(module_id, exit_report.code, exit_report.signal);
7552 let record = TerminalRecord {
7553 exit_code: exit_report.code,
7554 exit_signal: exit_report.signal,
7555 at_ms: exit_report.at_ms,
7556 disposition,
7557 exit_kind: exit_report.kind.into(),
7558 disposition_detail,
7559 };
7560 terminal_ring
7561 .lock()
7562 .unwrap_or_else(|poisoned| poisoned.into_inner())
7563 .record_exit(module_id, record);
7564}
7565
7566fn untrack_if_registration_released(
7567 process_liveness: &SupervisorProcessLiveness,
7568 registry: &Registry,
7569 module_id: &str,
7570 snapshot: &SharedSnapshot,
7571) {
7572 match registry.get_module(module_id) {
7573 Ok(None) => process_liveness.untrack_if_current(module_id, snapshot),
7574 Ok(Some(_)) => {}
7575 Err(err) => {
7576 warn!(module_id, error = %err, "could not determine whether supervisor liveness can be untracked");
7577 }
7578 }
7579}
7580
7581#[cfg(test)]
7595fn apply_wire_spawn_args(
7596 command: &mut Command,
7597 spec: &ModuleSpec,
7598 connection_file_path: Option<&std::path::Path>,
7599 handle: Option<&SupervisorHandle>,
7600) -> Result<Option<NonceHandoff>, SuperviseError> {
7601 apply_wire_spawn_args_for_role(
7602 command,
7603 spec,
7604 connection_file_path,
7605 handle,
7606 SpawnRole::Plain,
7607 )
7608}
7609
7610#[cfg(unix)]
7615type NonceHandoff = subc_os::LaunchNonceHandoff;
7616#[cfg(windows)]
7617type NonceHandoff = subc_os::LaunchNoncePipeHandoff;
7618#[cfg(not(any(unix, windows)))]
7619type NonceHandoff = std::convert::Infallible;
7620
7621fn apply_wire_spawn_args_for_role(
7642 command: &mut Command,
7643 spec: &ModuleSpec,
7644 connection_file_path: Option<&std::path::Path>,
7645 handle: Option<&SupervisorHandle>,
7646 role: SpawnRole,
7647) -> Result<Option<NonceHandoff>, SuperviseError> {
7648 command.env(SUBC_MODULE_ID_ENV, &spec.module_id);
7649 command.env_remove(subc_os::LAUNCH_NONCE_FD_ENV);
7654 #[cfg(windows)]
7655 {
7656 command.env_remove(subc_os::LAUNCH_NONCE_PIPE_ENV);
7657 command.env_remove(subc_os::LAUNCH_NONCE_PIPE_FALLBACK_ENV);
7658 }
7659 command.env_remove(SUBC_LAUNCH_NONCE_ENV);
7661 if spec.protocol == ModuleProtocol::None {
7662 return Ok(None);
7663 }
7664 if let Some(connection_file_path) = connection_file_path {
7665 command.arg(SUBC_ARG).arg(connection_file_path);
7666 }
7667
7668 let nonce = generate_launch_nonce()?;
7672 if let Some(handle) = handle {
7673 match role {
7674 SpawnRole::Plain => {
7675 handle.set_spawn_nonce(&spec.module_id, nonce.clone());
7676 if spec.reserved {
7677 handle.set_reserved_nonce(&spec.module_id, nonce.clone());
7678 }
7679 }
7680 SpawnRole::SwapCandidate => handle.open_swap(&spec.module_id, nonce.clone()),
7681 }
7682 }
7683 #[cfg(unix)]
7684 let handoff = {
7685 let handoff =
7686 subc_os::LaunchNonceHandoff::new(&nonce).map_err(|source| SuperviseError::Spawn {
7687 program: spec.program.clone(),
7688 source,
7689 cgroup_path: None,
7690 })?;
7691 command.env(subc_os::LAUNCH_NONCE_FD_ENV, handoff.fd_env_value());
7692 Some(handoff)
7693 };
7694 #[cfg(windows)]
7695 let handoff = prepare_windows_nonce_pipe(command, spec, &nonce);
7696 #[cfg(not(any(unix, windows)))]
7697 let handoff = None;
7698 #[cfg(not(unix))]
7702 command.env(SUBC_LAUNCH_NONCE_ENV, nonce);
7703 Ok(handoff)
7704}
7705
7706fn apply_child_env(command: &mut Command, spec: &ModuleSpec) {
7707 command.env_remove(CK_LOG_ENV);
7708 command.env_remove(SUBC_SPAWN_ROLE_ENV);
7715 for (key, value) in &spec.env {
7716 if matches!(
7720 key.as_str(),
7721 CAPTURE_MAX_FILE_MB_ENV | CAPTURE_KEEP_ENV | CAPTURE_MAX_AGE_DAYS_ENV
7722 ) || key == SUBC_SPAWN_ROLE_ENV
7723 {
7724 continue;
7725 }
7726 command.env(key, value);
7727 }
7728}
7729
7730#[cfg(windows)]
7731fn prepare_windows_nonce_pipe(
7732 command: &mut Command,
7733 spec: &ModuleSpec,
7734 nonce: &str,
7735) -> Option<NonceHandoff> {
7736 match subc_os::LaunchNoncePipeHandoff::new(nonce) {
7737 Ok(handoff) => {
7738 command.env(subc_os::LAUNCH_NONCE_PIPE_ENV, handoff.name());
7739 command.env(subc_os::LAUNCH_NONCE_PIPE_FALLBACK_ENV, "env");
7744 Some(handoff)
7745 }
7746 Err(error) => {
7747 warn!(module_id = %spec.module_id, %error, "could not prepare launch nonce pipe; retaining the environment handoff");
7748 None
7749 }
7750 }
7751}
7752
7753#[cfg(windows)]
7754fn start_windows_nonce_pipe(
7755 handoff: Option<NonceHandoff>,
7756 pid: u32,
7757 spec: &ModuleSpec,
7758) -> Option<subc_os::LaunchNoncePipeDelivery> {
7759 handoff.and_then(|handoff| match handoff.serve(pid, REGISTRY_RELEASE_TIMEOUT) {
7763 Ok(delivery) => Some(delivery),
7764 Err(error) => {
7765 warn!(module_id = %spec.module_id, %error, "could not serve launch nonce pipe; retaining the environment handoff");
7766 None
7767 }
7768 })
7769}
7770
7771fn observed_launch_nonce_source(snapshot: &SupervisorSnapshot) -> Option<String> {
7772 #[cfg(windows)]
7773 if snapshot.process_alive && snapshot.spawned_protocol == Some(ModuleProtocol::Subc) {
7774 return Some(
7775 if snapshot
7776 .pipe_consumed
7777 .as_ref()
7778 .is_some_and(|consumed| consumed.get().is_some())
7779 {
7780 "pipe"
7781 } else {
7782 "env"
7783 }
7784 .to_owned(),
7785 );
7786 }
7787 let _ = snapshot;
7788 None
7789}
7790
7791#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7794enum SpawnRole {
7795 Plain,
7796 SwapCandidate,
7797}
7798
7799fn apply_spawn_role(command: &mut Command, role: SpawnRole) {
7802 if role == SpawnRole::SwapCandidate {
7803 command.env(SUBC_SPAWN_ROLE_ENV, SPAWN_ROLE_SWAP_CANDIDATE);
7804 }
7805}
7806
7807#[cfg(target_os = "linux")]
7808fn previous_instance_error(spec: &ModuleSpec, pid: u32) -> SuperviseError {
7809 SuperviseError::Spawn {
7810 program: spec.program.clone(),
7811 source: io::Error::new(io::ErrorKind::WouldBlock, format!(
7812 "waiting: previous instance (pid {pid}) is still running but hidden; it cannot be identified"
7813 )),
7814 cgroup_path: None,
7815 }
7816}
7817
7818fn spawn_child(
7819 spec: &ModuleSpec,
7820 connection_file_path: Option<&std::path::Path>,
7821 handle: Option<&SupervisorHandle>,
7822 ring: &Arc<Mutex<StderrRing>>,
7823 capture_logs_dir: Option<&std::path::Path>,
7824 roster: &ChildRoster,
7825 #[cfg(target_os = "linux")] cgroup_placement: Option<&subc_cgroup::Placement>,
7826) -> Result<SupervisedChild, SuperviseError> {
7827 spawn_child_in_slot(
7828 spec,
7829 connection_file_path,
7830 handle,
7831 ring,
7832 capture_logs_dir,
7833 roster,
7834 #[cfg(target_os = "linux")]
7835 cgroup_placement,
7836 SpawnRole::Plain,
7837 false,
7838 )
7839}
7840
7841#[allow(clippy::too_many_arguments)]
7854fn spawn_child_in_slot(
7855 spec: &ModuleSpec,
7856 connection_file_path: Option<&std::path::Path>,
7857 handle: Option<&SupervisorHandle>,
7858 ring: &Arc<Mutex<StderrRing>>,
7859 capture_logs_dir: Option<&std::path::Path>,
7860 roster: &ChildRoster,
7861 #[cfg(target_os = "linux")] cgroup_placement: Option<&subc_cgroup::Placement>,
7862 role: SpawnRole,
7863 alternate_slot: bool,
7864) -> Result<SupervisedChild, SuperviseError> {
7865 #[cfg(target_os = "linux")]
7866 if let Some(pid) = roster.previous_instance_pid(&spec.module_id) {
7867 return Err(previous_instance_error(spec, pid));
7868 }
7869 if roster.is_closed() {
7870 return Err(SuperviseError::Spawn {
7871 program: spec.program.clone(),
7872 source: io::Error::other("the daemon is shutting down; not starting a new process"),
7873 cgroup_path: None,
7874 });
7875 }
7876 #[cfg(target_os = "linux")]
7877 let cgroup_name = {
7878 if cgroup_placement.is_none() {
7883 swap::cgroup_name(&spec.module_id, alternate_slot)
7884 } else {
7885 let nonce = generate_launch_nonce()?;
7886 let suffix = u128::from_str_radix(&nonce[..32], 16).expect("hex launch nonce");
7887 let mut end = spec.module_id.len().min(64);
7890 while !spec.module_id.is_char_boundary(end) {
7891 end -= 1;
7892 }
7893 format!(
7894 "{}_{suffix}",
7895 swap::cgroup_name(&spec.module_id[..end], alternate_slot)
7896 )
7897 }
7898 };
7899 #[cfg(not(target_os = "linux"))]
7900 let _ = alternate_slot;
7901 #[cfg(target_os = "macos")]
7902 let (mut command, privacy_exec, exec_ack) = privacy_command(spec, roster)?;
7903 #[cfg(windows)]
7904 let image_capture = windows_spawn_capture(spec).map_err(|source| SuperviseError::Spawn {
7905 program: spec.program.clone(),
7906 source,
7907 cgroup_path: None,
7908 })?;
7909 #[cfg(windows)]
7910 let mut command = Command::new(image_capture.path());
7911 #[cfg(not(any(target_os = "macos", windows)))]
7912 let mut command = Command::new(&spec.program);
7913 command.args(&spec.args);
7914 apply_child_env(&mut command, spec);
7944 apply_spawn_role(&mut command, role);
7945 let nonce_handoff =
7946 apply_wire_spawn_args_for_role(&mut command, spec, connection_file_path, handle, role)?;
7947
7948 #[cfg(target_os = "linux")]
7949 let cgroup_path = cgroup_placement
7950 .map(|placement| placement.module_path(&cgroup_name))
7951 .transpose()
7952 .map_err(|source| SuperviseError::Cgroup {
7953 module_id: spec.module_id.clone(),
7954 source,
7955 })?;
7956 #[cfg(not(target_os = "linux"))]
7957 let cgroup_path: Option<PathBuf> = None;
7958 #[cfg(target_os = "linux")]
7959 if let Some(path) = &cgroup_path {
7960 if let Err(error) = apply_cgroup_placement(&mut command, spec, path) {
7961 if let Some(placement) = cgroup_placement {
7962 remove_module_cgroup(placement, &cgroup_name);
7963 }
7964 return Err(error);
7965 }
7966 }
7967
7968 let output_sink = if let Some(logs_dir) = capture_logs_dir {
7969 let path = logs_dir.join(format!("{}.stderr.log", spec.module_id));
7970 match ChildOutputSink::open(&path, capture_retention(spec)) {
7971 Ok(sink) => sink,
7972 Err(error) => {
7973 warn!(
7974 module_id = %spec.module_id,
7975 path = %path.display(),
7976 error = %error,
7977 "could not open child output capture file; forwarding to stderr"
7978 );
7979 ChildOutputSink::Stderr
7980 }
7981 }
7982 } else {
7983 ChildOutputSink::Stderr
7984 };
7985
7986 command.stdout(Stdio::piped());
7987 command.stderr(Stdio::piped());
7988 command.kill_on_drop(true);
7989 #[cfg(unix)]
8006 command.process_group(0);
8007 command.stdin(Stdio::null());
8008 #[cfg(unix)]
8012 if let Some(handoff) = nonce_handoff {
8013 handoff.install_last(command.as_std_mut());
8014 }
8015 #[cfg(not(any(unix, windows)))]
8016 let _ = nonce_handoff;
8017
8018 #[cfg(windows)]
8023 subc_jobobject::suspend_on_create_async(&mut command);
8024 let mut child = match command.spawn() {
8025 Ok(child) => child,
8026 Err(source) => {
8027 #[cfg(target_os = "linux")]
8028 if let Some(placement) = cgroup_placement {
8029 remove_module_cgroup(placement, &cgroup_name);
8030 }
8031 return Err(SuperviseError::Spawn {
8032 program: spec.program.clone(),
8033 source,
8034 cgroup_path,
8035 });
8036 }
8037 };
8038 #[cfg(target_os = "macos")]
8043 drop(exec_ack);
8044
8045 #[cfg(windows)]
8050 let windows_image =
8051 Arc::new(
8052 image_capture
8053 .bind_tokio(&child)
8054 .map_err(|source| SuperviseError::Spawn {
8055 program: spec.program.clone(),
8056 source,
8057 cgroup_path: None,
8058 })?,
8059 );
8060 #[cfg(windows)]
8062 let job = contain_spawned_child(&child, spec)?;
8063 let spawned_at_ms = unix_ms_now();
8064 #[cfg(not(windows))]
8065 let spawned_from = spec.program.clone();
8066 #[cfg(windows)]
8067 let spawned_from = windows_image.path().to_path_buf();
8068 #[cfg(not(windows))]
8069 let spawned_file_identity = spawned_file_identity(&spawned_from);
8070 #[cfg(windows)]
8071 let spawned_file_identity = Some(SpawnedFileIdentity {
8072 device: 0,
8073 inode: 0,
8074 windows_image: Some(Arc::clone(&windows_image)),
8075 });
8076 let pid = child.id().ok_or_else(|| SuperviseError::Spawn {
8077 program: spec.program.clone(),
8078 source: io::Error::other("spawned child exposed no live pid"),
8079 cgroup_path: cgroup_path.clone(),
8080 })?;
8081 #[cfg(windows)]
8082 let nonce_delivery = start_windows_nonce_pipe(nonce_handoff, pid, spec);
8083 #[cfg(not(windows))]
8084 let process_start_time = crate::provenance::process_start_time(pid);
8085 #[cfg(windows)]
8086 let process_start_time = Some(windows_image.start_time());
8087 let process_identity = process_start_time.map(|start_time| ProcessIdentity { pid, start_time });
8088 #[cfg(all(test, target_os = "macos"))]
8089 privacy_exec_boundary_tests::before_image_sample(spec, pid);
8090 #[cfg(not(windows))]
8096 let recorded_image = observe_spawned_image(pid);
8097 #[cfg(target_os = "macos")]
8101 let recorded_image = if privacy_exec.is_some() {
8102 None
8103 } else {
8104 recorded_image
8105 };
8106 #[cfg(target_os = "linux")]
8107 let recorded_cgroup_name = cgroup_path.as_ref().map(|_| cgroup_name.clone());
8108 #[cfg(not(target_os = "linux"))]
8109 let recorded_cgroup_name = None;
8110 let roster_guard = roster.admit(
8111 spec.module_id.clone(),
8112 pid,
8113 spec.protocol,
8114 process_start_time,
8115 crate::child_roster::RecordedIdentity {
8116 #[cfg(not(windows))]
8117 start_time: recorded_image.map(|image| image.start_time),
8118 #[cfg(windows)]
8119 start_time: process_start_time,
8120 #[cfg(not(windows))]
8121 executable: recorded_image
8122 .and_then(|image| image.executable)
8123 .map(crate::live_children::ExecutableIdentity::from),
8124 #[cfg(windows)]
8125 executable: Some(crate::live_children::ExecutableIdentity::from(
8126 windows_image.identity(),
8127 )),
8128 cgroup_name: recorded_cgroup_name,
8129 #[cfg(target_os = "linux")]
8130 cgroup_placement: cgroup_placement.cloned(),
8131 },
8132 );
8133 if roster.is_closed() {
8142 #[cfg(target_os = "linux")]
8144 kill_module_cgroup(cgroup_placement, &cgroup_name);
8145 if let Err(error) = child.start_kill() {
8146 debug!(module_id = %spec.module_id, pid, %error, "kill of a process spawned during daemon shutdown failed; it may already have exited");
8147 }
8148 #[cfg(target_os = "linux")]
8149 if let Some(placement) = cgroup_placement {
8150 while matches!(child.try_wait(), Ok(None)) {
8154 std::thread::yield_now();
8155 }
8156 if matches!(
8157 subc_cgroup::kill_module(Some(placement), &cgroup_name),
8158 subc_cgroup::KillOutcome::Killed
8159 ) {
8160 if let Ok(path) = placement.module_path(&cgroup_name) {
8161 while std::fs::read_to_string(path.join("cgroup.events"))
8162 .ok()
8163 .is_some_and(|events| events.lines().any(|line| line == "populated 1"))
8164 {
8165 std::thread::yield_now();
8166 }
8167 }
8168 }
8169 remove_module_cgroup(placement, &cgroup_name);
8170 }
8171 drop(roster_guard);
8172 return Err(SuperviseError::Spawn {
8173 program: spec.program.clone(),
8174 source: io::Error::other(
8175 "the daemon began shutting down while this process was starting; ended it",
8176 ),
8177 cgroup_path,
8178 });
8179 }
8180
8181 let stdout_pump = match child.stdout.take() {
8182 Some(stdout) => Some(tokio::spawn(pump_stdout_to(stdout, output_sink.clone()))),
8183 None => {
8184 warn!(
8185 module_id = %spec.module_id,
8186 "spawned child exposed no stdout pipe; file capture will be incomplete"
8187 );
8188 None
8189 }
8190 };
8191 let stderr_pump = match child.stderr.take() {
8192 Some(stderr) => {
8193 let generation = ring
8194 .lock()
8195 .unwrap_or_else(|poisoned| poisoned.into_inner())
8196 .begin_process();
8197 Some(StderrPump {
8198 task: tokio::spawn(pump_stderr_to(
8199 stderr,
8200 Arc::clone(ring),
8201 generation,
8202 output_sink,
8203 )),
8204 generation,
8205 })
8206 }
8207 None => {
8208 ring.lock()
8212 .unwrap_or_else(|poisoned| poisoned.into_inner())
8213 .mark_not_captured("stderr pipe was not available on spawn");
8214 warn!(
8215 module_id = %spec.module_id,
8216 "spawned child exposed no stderr pipe; tail will be unavailable"
8217 );
8218 None
8219 }
8220 };
8221
8222 Ok(SupervisedChild {
8223 child,
8224 #[cfg(windows)]
8225 nonce_delivery,
8226 protocol: spec.protocol,
8227 #[cfg(target_os = "linux")]
8228 module_id: cgroup_name,
8229 #[cfg(target_os = "linux")]
8230 cgroup_placement: cgroup_placement.cloned(),
8231 #[cfg(windows)]
8232 job,
8233 stdout_pump,
8234 stderr_pump,
8235 stderr_ring: Arc::clone(ring),
8236 spawned_at_ms,
8237 spawned_from,
8238 spawned_file_identity,
8239 process_start_time,
8240 process_identity,
8241 pid,
8242 roster_guard: Some(roster_guard),
8243 #[cfg(target_os = "macos")]
8244 privacy_exec,
8245 #[cfg(target_os = "macos")]
8246 report_ready: Arc::new(OnceLock::new()),
8247 spawn_failure: None,
8248 })
8249}
8250
8251#[cfg(target_os = "linux")]
8252pub(crate) fn kill_module_cgroup(placement: Option<&subc_cgroup::Placement>, module_id: &str) {
8253 use subc_cgroup::KillOutcome;
8254 match subc_cgroup::kill_module(placement, module_id) {
8255 KillOutcome::Killed => {}
8256 KillOutcome::NotPlaced | KillOutcome::Unsupported => {
8257 debug!(
8258 module_id,
8259 "cgroup tree kill unavailable; using direct-child kill"
8260 );
8261 }
8262 KillOutcome::IoError { path, error } => {
8263 warn!(module_id, path = %path.display(), %error, "cgroup tree kill failed; using direct-child kill");
8264 }
8265 }
8266}
8267
8268#[cfg(windows)]
8282fn contain_spawned_child(
8283 child: &Child,
8284 spec: &ModuleSpec,
8285) -> Result<Option<subc_jobobject::JobObject>, SuperviseError> {
8286 let module_id = spec.module_id.as_str();
8287 let Some(pid) = child.id() else {
8288 warn!(
8291 module_id,
8292 "spawned child had already exited before containment; no job object attached"
8293 );
8294 return Ok(None);
8295 };
8296
8297 let job = match subc_jobobject::JobObject::new() {
8298 Ok(job) => job,
8299 Err(source) => {
8300 warn!(
8301 module_id,
8302 error = %source,
8303 "could not create a job object; this module's helper processes will not be \
8304 reaped on teardown"
8305 );
8306 resume_suspended_child(pid, spec)?;
8309 return Ok(None);
8310 }
8311 };
8312
8313 if let Err(source) = job.assign(child) {
8314 warn!(
8315 module_id,
8316 error = %source,
8317 "could not assign the child to its job object; this module's helper processes \
8318 will not be reaped on teardown"
8319 );
8320 resume_suspended_child(pid, spec)?;
8321 return Ok(None);
8322 }
8323
8324 resume_suspended_child(pid, spec)?;
8325 Ok(Some(job))
8326}
8327
8328#[cfg(windows)]
8333fn resume_suspended_child(pid: u32, spec: &ModuleSpec) -> Result<(), SuperviseError> {
8334 if let Err(source) = subc_jobobject::resume_main_thread(pid) {
8335 let _ = std::process::Command::new("taskkill.exe")
8339 .args(["/PID", &pid.to_string(), "/T", "/F"])
8340 .stdin(Stdio::null())
8341 .stdout(Stdio::null())
8342 .stderr(Stdio::null())
8343 .status();
8344 return Err(SuperviseError::Spawn {
8345 program: spec.program.clone(),
8346 source,
8347 cgroup_path: None,
8348 });
8349 }
8350 Ok(())
8351}
8352
8353#[cfg(target_os = "linux")]
8354fn remove_module_cgroup(placement: &subc_cgroup::Placement, module_id: &str) {
8355 match placement.remove_module(module_id) {
8356 Ok(()) => debug!(module_id, "removed module cgroup after process exit"),
8357 Err(error) => warn!(
8358 module_id,
8359 error = %error,
8360 "could not remove module cgroup after process exit; continuing teardown"
8361 ),
8362 }
8363}
8364
8365#[cfg(target_os = "linux")]
8366async fn cleanup_reaped_cgroup(placement: &subc_cgroup::Placement, module_id: &str) {
8367 if matches!(
8371 subc_cgroup::kill_module(Some(placement), module_id),
8372 subc_cgroup::KillOutcome::Killed
8373 ) {
8374 if let Ok(path) = placement.module_path(module_id) {
8375 while std::fs::read_to_string(path.join("cgroup.events"))
8376 .ok()
8377 .is_some_and(|events| events.lines().any(|line| line == "populated 1"))
8378 {
8379 sleep(Duration::from_millis(1)).await;
8380 }
8381 }
8382 }
8383 remove_module_cgroup(placement, module_id);
8384}
8385
8386#[cfg(target_os = "linux")]
8387fn apply_cgroup_placement(
8388 command: &mut Command,
8389 spec: &ModuleSpec,
8390 path: &std::path::Path,
8391) -> Result<(), SuperviseError> {
8392 subc_cgroup::apply(command, path).map_err(|source| SuperviseError::Cgroup {
8393 module_id: spec.module_id.clone(),
8394 source,
8395 })
8396}
8397
8398fn capture_retention(spec: &ModuleSpec) -> Retention {
8399 let defaults = Retention::default();
8400 let value = |name: &str| {
8401 spec.env
8402 .iter()
8403 .rev()
8404 .find_map(|(key, value)| (key == name).then_some(value.as_str()))
8405 };
8406 Retention {
8407 max_file_mb: value(CAPTURE_MAX_FILE_MB_ENV)
8408 .and_then(|value| value.parse().ok())
8409 .unwrap_or(defaults.max_file_mb),
8410 keep: value(CAPTURE_KEEP_ENV)
8411 .and_then(|value| value.parse().ok())
8412 .unwrap_or(defaults.keep),
8413 max_age_days: value(CAPTURE_MAX_AGE_DAYS_ENV)
8414 .and_then(|value| value.parse().ok())
8415 .unwrap_or(defaults.max_age_days),
8416 }
8417}
8418
8419fn generate_launch_nonce() -> Result<String, SuperviseError> {
8422 let mut bytes = [0u8; 32];
8423 getrandom::getrandom(&mut bytes).map_err(|source| SuperviseError::LaunchNonce {
8424 reason: source.to_string(),
8425 })?;
8426 let mut hex = String::with_capacity(64);
8427 for b in bytes {
8428 use std::fmt::Write;
8429 let _ = write!(hex, "{b:02x}");
8430 }
8431 Ok(hex)
8432}
8433
8434fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
8437 if a.len() != b.len() {
8438 return false;
8439 }
8440 let mut diff = 0u8;
8441 for (x, y) in a.iter().zip(b.iter()) {
8442 diff |= x ^ y;
8443 }
8444 diff == 0
8445}
8446
8447#[cfg(not(windows))]
8451fn observe_spawned_image(pid: u32) -> Option<subc_os::Observation> {
8452 subc_os::Process::open(pid)
8453 .ok()
8454 .flatten()
8455 .and_then(|process| process.observe())
8456}
8457
8458#[cfg(windows)]
8461fn windows_spawn_capture(spec: &ModuleSpec) -> io::Result<subc_os::ExecutableCapture> {
8462 let mut program = spec.program.clone();
8463 if program.is_absolute() || program.components().count() > 1 {
8464 match subc_os::ExecutableCapture::open(&program) {
8467 Ok(capture) => return Ok(capture),
8468 Err(error)
8469 if error.kind() == io::ErrorKind::NotFound && program.extension().is_none() => {}
8470 Err(error) => return Err(error),
8471 }
8472 program.set_extension("exe");
8473 return subc_os::ExecutableCapture::open(&program);
8474 }
8475 if program.extension().is_none() {
8476 program.set_extension("exe");
8477 }
8478 let custom_path = spec
8479 .env
8480 .iter()
8481 .rev()
8482 .find(|(key, _)| key.eq_ignore_ascii_case("PATH"));
8483 let mut directories = Vec::new();
8484 if let Some(parent) = std::env::current_exe()?.parent() {
8487 directories.push(parent.to_path_buf());
8488 }
8489 directories.push(std::env::current_dir()?);
8490 if let Some(windows) = std::env::var_os("SystemRoot") {
8491 let windows = PathBuf::from(windows);
8492 directories.push(windows.join("System32"));
8493 directories.push(windows.clone());
8494 }
8495 let path = custom_path
8496 .map(|(_, value)| std::ffi::OsString::from(value))
8497 .or_else(|| std::env::var_os("PATH"));
8498 if let Some(path) = path {
8499 directories.extend(std::env::split_paths(&path));
8500 }
8501 for directory in directories {
8502 match subc_os::ExecutableCapture::open(&directory.join(&program)) {
8503 Ok(capture) => return Ok(capture),
8504 Err(error) if error.kind() == io::ErrorKind::NotFound => continue,
8505 Err(error) => return Err(error),
8506 }
8507 }
8508 Err(io::Error::new(
8509 io::ErrorKind::NotFound,
8510 "resolved executable not found",
8511 ))
8512}
8513
8514fn spawn_and_mark_running(
8515 spec: &ModuleSpec,
8516 runtime: &SupervisorRuntimeConfig,
8517 snapshot: &SharedSnapshot,
8518) -> Result<SupervisedChild, SuperviseError> {
8519 let child = spawn_child(
8520 spec,
8521 runtime.connection_file_path.as_deref(),
8522 runtime.supervisor_handle.as_ref(),
8523 &runtime.stderr_ring,
8524 runtime.capture_logs_dir.as_deref(),
8525 &runtime.child_roster,
8526 #[cfg(target_os = "linux")]
8527 runtime.cgroup_placement.as_ref(),
8528 )?;
8529 set_running(snapshot, &child, &spec.module_id, &runtime.spawn_events)?;
8530 Ok(child)
8531}
8532
8533enum RegistrationWaitOutcome {
8534 Registered,
8535 Exited(ExitReport),
8536 TimedOut,
8537}
8538
8539struct ReloadRegistrationFailure {
8540 exit_report: ExitReport,
8541 reason: String,
8542}
8543
8544#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8545enum BusyGaugeObservation {
8546 Quiescent,
8547 Busy,
8548 Omitted,
8549}
8550
8551fn busy_gauge_observation(metrics: Option<&Value>, gauges: &[String]) -> BusyGaugeObservation {
8552 let Some(metrics) = metrics.and_then(Value::as_object) else {
8553 return BusyGaugeObservation::Omitted;
8554 };
8555 let mut sum = 0u128;
8556 for gauge in gauges {
8557 let Some(value) = metrics.get(gauge) else {
8558 return BusyGaugeObservation::Omitted;
8559 };
8560 let Some(value) = value.as_u64() else {
8561 return BusyGaugeObservation::Busy;
8562 };
8563 sum = sum.saturating_add(u128::from(value));
8564 }
8565 if sum == 0 {
8566 BusyGaugeObservation::Quiescent
8567 } else {
8568 BusyGaugeObservation::Busy
8569 }
8570}
8571
8572fn declared_busy_gauges(
8573 registry: &Registry,
8574 module_id: &str,
8575) -> Result<Vec<String>, SuperviseError> {
8576 busy_gauges_of(
8577 registry
8578 .get_module(module_id)
8579 .map_err(SuperviseError::Registry)?,
8580 )
8581}
8582
8583fn declared_busy_gauges_for_connection(
8587 registry: &Registry,
8588 connection_id: ConnectionId,
8589) -> Result<Vec<String>, SuperviseError> {
8590 busy_gauges_of(
8591 registry
8592 .get_module_by_connection(connection_id)
8593 .map_err(SuperviseError::Registry)?,
8594 )
8595}
8596
8597fn busy_gauges_of(
8598 registration: Option<crate::registry::ModuleRegistration>,
8599) -> Result<Vec<String>, SuperviseError> {
8600 let Some(registration) = registration else {
8601 return Ok(Vec::new());
8602 };
8603 let Some(self_signals) = registration.manifest.self_signals else {
8604 return Ok(Vec::new());
8605 };
8606
8607 let mut gauges = Vec::new();
8608 for declaration in self_signals {
8609 if declaration.kind != SelfSignalKind::Busy {
8610 continue;
8611 }
8612 match declaration.anchored_to {
8613 SignalAnchor::HealthGauges { gauges: declared } if !declared.is_empty() => {
8614 gauges.extend(declared)
8615 }
8616 _ => {
8617 gauges.push(String::new());
8620 }
8621 }
8622 }
8623 Ok(gauges)
8624}
8625
8626async fn wait_for_forwarding_quiescence(
8631 forwarding: &ForwardingTable,
8632 module_id: &str,
8633 runtime: &SupervisorRuntimeConfig,
8634 endpoint: crate::ModuleEndpointId,
8635 deadline: Instant,
8636 busy_gauges: &[String],
8637 scope: DrainScope,
8638) -> Result<bool, SuperviseError> {
8639 let mut gauges_quiescent = busy_gauges.is_empty();
8640 let mut next_probe_at = Instant::now();
8641 let mut omission_counted = false;
8642
8643 loop {
8644 let now = Instant::now();
8645 if !busy_gauges.is_empty() && now >= next_probe_at && now < deadline {
8646 let report = match scope {
8647 DrainScope::Active => probe_module_health(module_id, runtime, Some(deadline)).await,
8648 DrainScope::Endpoint(endpoint) => {
8649 probe_endpoint_health(endpoint, runtime, Some(deadline)).await
8650 }
8651 };
8652 gauges_quiescent = match report {
8653 Ok(report) => match busy_gauge_observation(report.metrics.as_ref(), busy_gauges) {
8654 BusyGaugeObservation::Quiescent => true,
8655 BusyGaugeObservation::Busy => false,
8656 BusyGaugeObservation::Omitted => {
8657 if !omission_counted {
8658 forwarding
8659 .counters()
8660 .increment_drains_with_undeclared_gauge();
8661 omission_counted = true;
8662 }
8663 false
8664 }
8665 },
8666 Err(err) => {
8667 warn!(
8668 module_id,
8669 error = %err,
8670 "drain health.check did not produce declared busy gauges; treating module as busy"
8671 );
8672 false
8673 }
8674 };
8675 next_probe_at = Instant::now() + runtime.health.cadence.max(REGISTRY_RELEASE_POLL);
8676 }
8677
8678 let in_flight = forwarding
8679 .endpoint_in_flight_count(endpoint)
8680 .map_err(SuperviseError::Forwarding)?;
8681 if in_flight == 0 && gauges_quiescent {
8682 return Ok(true);
8683 }
8684
8685 let now = Instant::now();
8686 if now >= deadline {
8687 return Ok(false);
8688 }
8689 let mut wait = deadline
8690 .saturating_duration_since(now)
8691 .min(REGISTRY_RELEASE_POLL);
8692 if !busy_gauges.is_empty() {
8693 wait = wait.min(next_probe_at.saturating_duration_since(now));
8694 }
8695 sleep(wait).await;
8696 }
8697}
8698
8699fn drained_after_quiescence_wait(wait_result: &Result<bool, SuperviseError>) -> bool {
8707 match wait_result {
8708 Ok(drained) => *drained,
8709 Err(_) => false,
8710 }
8711}
8712
8713fn send_route_goodbyes(forwarding: &ForwardingTable, released_routes: Vec<GoodbyeTarget>) {
8714 for released in released_routes {
8715 let frame = match Frame::build_with_version(
8716 released.negotiated_ver,
8717 FrameType::Goodbye,
8718 control_flags(),
8719 released.channel,
8720 released.epoch,
8721 0,
8722 Vec::new(),
8723 ) {
8724 Ok(frame) => frame,
8725 Err(err) => {
8726 warn!(
8727 route_channel = released.channel,
8728 error = %err,
8729 "failed to build supervisor drain route GOODBYE frame"
8730 );
8731 continue;
8732 }
8733 };
8734 if !released.close_on_delivery_failure() {
8735 crate::forwarding::send_module_route_goodbye(
8736 &forwarding.counters(),
8737 &released.sink,
8738 frame,
8739 released.module_id.as_deref(),
8740 "supervisor drain",
8741 );
8742 continue;
8743 }
8744 if let Err(err) = released.sink.try_send(frame) {
8745 warn!(
8746 target_connection_id = released.connection_id.get(),
8747 route_channel = released.channel,
8748 error = %err,
8749 "supervisor drain route GOODBYE was not delivered to client; closing target connection"
8750 );
8751 let _ = forwarding.escalate_client_delivery_failure(
8752 released.connection_id,
8753 released.channel,
8754 released.epoch,
8755 CloseReason::new(
8756 "route_goodbye_delivery_failed",
8757 format!(
8758 "failed to enqueue supervisor drain route GOODBYE for channel {}: {err}",
8759 released.channel
8760 ),
8761 ),
8762 crate::forwarding::UndeliveredFrame {
8763 module_id: released.module_id.as_deref(),
8764 sink: &released.sink,
8765 },
8766 );
8767 }
8768 }
8769}
8770
8771fn send_module_draining(
8772 module_id: &str,
8773 reason: RouteCloseReason,
8774 deadline_ms: u64,
8775 target: &ModuleDrainTarget,
8776) {
8777 let body = match serde_json::to_vec(&ModuleControlCommand::Draining {
8778 reason,
8779 deadline_ms,
8780 }) {
8781 Ok(body) => body,
8782 Err(err) => {
8783 warn!(
8784 module_id,
8785 error = %err,
8786 "failed to encode module draining command"
8787 );
8788 return;
8789 }
8790 };
8791 let frame = match Frame::build_with_version(
8792 target.negotiated_ver,
8793 FrameType::Push,
8794 control_flags(),
8795 0,
8796 0,
8797 0,
8798 body,
8799 ) {
8800 Ok(frame) => frame,
8801 Err(err) => {
8802 warn!(
8803 module_id,
8804 error = %err,
8805 "failed to build module draining command frame"
8806 );
8807 return;
8808 }
8809 };
8810 if let Err(err) = target.sink.try_send(frame) {
8811 warn!(
8812 module_id,
8813 target_connection_id = target.endpoint.connection_id.get(),
8814 error = %err,
8815 "module draining command was not delivered to peer"
8816 );
8817 }
8818}
8819
8820fn module_goodbye_frame(module_id: &str, negotiated_ver: u8) -> Option<Frame> {
8822 match Frame::build_with_version(
8823 negotiated_ver,
8824 FrameType::Goodbye,
8825 control_flags(),
8826 0,
8827 0,
8828 0,
8829 Vec::new(),
8830 ) {
8831 Ok(frame) => Some(frame),
8832 Err(err) => {
8833 warn!(
8834 module_id,
8835 error = %err,
8836 "failed to build module GOODBYE frame"
8837 );
8838 None
8839 }
8840 }
8841}
8842
8843#[cfg(unix)]
8857async fn send_module_goodbyes_for_daemon_shutdown(
8858 forwarding: &Arc<ForwardingTable>,
8859 reason: &CloseReason,
8860 wait_for_flush: bool,
8861) {
8862 const GOODBYE_BUDGET: Duration = Duration::from_millis(500);
8863 let targets = match forwarding.module_connections() {
8864 Ok(targets) => targets,
8865 Err(err) => {
8866 warn!(error = %err, "could not list module connections for shutdown GOODBYE");
8867 return;
8868 }
8869 };
8870 let deadline = Instant::now() + GOODBYE_BUDGET;
8871 let mut sends = tokio::task::JoinSet::new();
8872 for target in targets {
8873 let Some(frame) = module_goodbye_frame(&target.module_id, target.negotiated_ver) else {
8874 continue;
8875 };
8876 if !wait_for_flush {
8877 if let Err(err) = target.sink.try_send(frame) {
8878 debug!(
8879 module_id = %target.module_id,
8880 error = %err,
8881 "shutdown module GOODBYE was not queued"
8882 );
8883 }
8884 continue;
8885 }
8886 let forwarding = Arc::clone(forwarding);
8887 let reason = reason.clone();
8888 sends.spawn(async move {
8889 match timeout_at(deadline, target.sink.send_flushed(frame)).await {
8890 Ok(Ok(())) => {}
8891 Ok(Err(err)) => debug!(
8892 module_id = %target.module_id,
8893 error = %err,
8894 "module connection closed before its shutdown GOODBYE was written"
8895 ),
8896 Err(_) => warn!(
8897 module_id = %target.module_id,
8898 budget = ?GOODBYE_BUDGET,
8899 "shutdown module GOODBYE was not written within its budget; closing anyway"
8900 ),
8901 }
8902 forwarding.request_connection_close(target.endpoint.connection_id, reason);
8903 });
8904 }
8905 while sends.join_next().await.is_some() {}
8907}
8908
8909fn send_module_goodbye(module_id: &str, forwarding: &ForwardingTable, target: &ModuleDrainTarget) {
8910 let Some(frame) = module_goodbye_frame(module_id, target.negotiated_ver) else {
8911 return;
8912 };
8913 if let Err(err) = target.sink.try_send(frame) {
8914 warn!(
8915 module_id,
8916 target_connection_id = target.endpoint.connection_id.get(),
8917 error = %err,
8918 "supervisor drain module GOODBYE was not delivered to peer; closing module connection"
8919 );
8920 forwarding.request_connection_close(
8921 target.endpoint.connection_id,
8922 CloseReason::new(
8923 "module_goodbye_delivery_failed",
8924 format!("failed to enqueue supervisor drain module GOODBYE for module '{module_id}': {err}"),
8925 ),
8926 );
8927 }
8928}
8929
8930#[derive(Clone, Copy)]
8931struct ForwardingDrainContext<'a> {
8932 spec: &'a ModuleSpec,
8933 runtime: &'a SupervisorRuntimeConfig,
8934 registry: &'a Registry,
8935 scope: DrainScope,
8936}
8937
8938#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8940enum DrainScope {
8941 Active,
8944 Endpoint(crate::ModuleEndpointId),
8949}
8950
8951#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8959enum StopNotice {
8960 SentOverConnection,
8963 NoConnection,
8967 NotSent,
8971}
8972
8973async fn begin_forwarding_drain(
8974 spec: &ModuleSpec,
8975 runtime: &SupervisorRuntimeConfig,
8976 registry: &Registry,
8977 snapshot: &SharedSnapshot,
8978 enabled: Option<bool>,
8979 reason: RouteCloseReason,
8980) -> Result<StopNotice, SuperviseError> {
8981 let Some(forwarding) = runtime.forwarding.as_ref() else {
8982 return Err(SuperviseError::ReloadUnavailable {
8983 module_id: spec.module_id.clone(),
8984 reason: "supervisor was not configured with a forwarding table".to_string(),
8985 });
8986 };
8987
8988 begin_forwarding_drain_with(
8989 forwarding,
8990 ForwardingDrainContext {
8991 spec,
8992 runtime,
8993 registry,
8994 scope: DrainScope::Active,
8995 },
8996 snapshot,
8997 enabled,
8998 reason,
8999 runtime.drain_timeout,
9000 )
9001 .await
9002}
9003
9004async fn begin_forwarding_drain_if_configured(
9005 spec: &ModuleSpec,
9006 runtime: &SupervisorRuntimeConfig,
9007 registry: &Registry,
9008 snapshot: &SharedSnapshot,
9009 enabled: Option<bool>,
9010 reason: RouteCloseReason,
9011) -> Result<StopNotice, SuperviseError> {
9012 begin_forwarding_drain_with_timeout(
9013 spec,
9014 runtime,
9015 registry,
9016 snapshot,
9017 enabled,
9018 reason,
9019 runtime.drain_timeout,
9020 )
9021 .await
9022}
9023
9024async fn begin_forwarding_drain_with_timeout(
9028 spec: &ModuleSpec,
9029 runtime: &SupervisorRuntimeConfig,
9030 registry: &Registry,
9031 snapshot: &SharedSnapshot,
9032 enabled: Option<bool>,
9033 reason: RouteCloseReason,
9034 drain_timeout: Duration,
9035) -> Result<StopNotice, SuperviseError> {
9036 let Some(forwarding) = runtime.forwarding.as_ref() else {
9037 return Ok(StopNotice::NotSent);
9038 };
9039
9040 begin_forwarding_drain_with(
9041 forwarding,
9042 ForwardingDrainContext {
9043 spec,
9044 runtime,
9045 registry,
9046 scope: DrainScope::Active,
9047 },
9048 snapshot,
9049 enabled,
9050 reason,
9051 drain_timeout,
9052 )
9053 .await
9054}
9055
9056async fn begin_forwarding_drain_with(
9057 forwarding: &ForwardingTable,
9058 context: ForwardingDrainContext<'_>,
9059 snapshot: &SharedSnapshot,
9060 enabled: Option<bool>,
9061 reason: RouteCloseReason,
9062 drain_timeout: Duration,
9063) -> Result<StopNotice, SuperviseError> {
9064 let ForwardingDrainContext {
9065 spec,
9066 runtime,
9067 registry,
9068 scope,
9069 } = context;
9070 debug_assert_ne!(reason, RouteCloseReason::Crash);
9071 let terminal = matches!(reason, RouteCloseReason::Disable);
9072 let drain_started_at = Instant::now();
9073 let drain_deadline = drain_started_at + drain_timeout;
9074 let deadline_ms =
9075 unix_ms_now().saturating_add(u64::try_from(drain_timeout.as_millis()).unwrap_or(u64::MAX));
9076 let busy_gauges = match scope {
9077 DrainScope::Active => declared_busy_gauges(registry, &spec.module_id)?,
9078 DrainScope::Endpoint(endpoint) => {
9079 declared_busy_gauges_for_connection(registry, endpoint.connection_id)?
9080 }
9081 };
9082
9083 let gate_started = Instant::now();
9086 let drain_target = match scope {
9087 DrainScope::Active => forwarding.begin_module_drain(&spec.module_id, reason),
9088 DrainScope::Endpoint(endpoint) => forwarding.begin_endpoint_drain(endpoint, reason),
9089 }
9090 .map_err(SuperviseError::Forwarding)?;
9091 info!(
9096 module_id = %spec.module_id,
9097 ?reason,
9098 gate_ms = u64::try_from(gate_started.elapsed().as_millis()).unwrap_or(u64::MAX),
9099 connected = drain_target.is_some(),
9100 "module drain began; route admission closed"
9101 );
9102 if scope == DrainScope::Active {
9103 update_snapshot(snapshot, Some(&spec.module_id), |state| {
9104 state.state = ModuleState::Draining;
9105 state.draining_to_replace =
9106 matches!(reason, RouteCloseReason::Restart | RouteCloseReason::Reload);
9107 if let Some(enabled) = enabled {
9108 state.enabled = enabled;
9109 }
9110 })?;
9111 }
9112
9113 let Some(target) = drain_target.as_ref() else {
9114 return Ok(StopNotice::NoConnection);
9118 };
9119 {
9120 send_module_draining(&spec.module_id, reason, deadline_ms, target);
9121 let routes = forwarding
9122 .endpoint_routes(target.endpoint)
9123 .map_err(SuperviseError::Forwarding)?;
9124 let routes_notified = routes.len();
9125 crate::control::send_route_control_pushes(
9126 forwarding,
9127 routes.clone(),
9128 ClientControlPush::RouteClosing {
9129 module_id: spec.module_id.clone(),
9130 channels: Vec::new(),
9131 reason,
9132 },
9133 );
9134 send_route_goodbyes(forwarding, target.abandoned_bindings.clone());
9135
9136 let wait_result = wait_for_forwarding_quiescence(
9142 forwarding,
9143 &spec.module_id,
9144 runtime,
9145 target.endpoint,
9146 drain_deadline,
9147 &busy_gauges,
9148 scope,
9149 )
9150 .await;
9151 let drained = drained_after_quiescence_wait(&wait_result);
9152 if let Err(err) = &wait_result {
9153 error!(
9154 module_id = %spec.module_id,
9155 ?reason,
9156 error = %err,
9157 "forwarding quiescence wait failed after route.closing; forcing route.closed(drained: false) so the client is not left waiting on an unfulfilled promise"
9158 );
9159 } else if !drained {
9160 let holdouts = forwarding
9166 .endpoint_drain_holdouts(target.endpoint)
9167 .unwrap_or_default();
9168 warn!(
9169 module_id = %spec.module_id,
9170 waited = ?drain_timeout,
9171 ?reason,
9172 held_requests = holdouts.requests,
9173 held_routes = holdouts.routes,
9174 total_routes = holdouts.total_routes,
9175 top_connections = ?holdouts.top_connections,
9176 held = %holdouts
9179 .held
9180 .iter()
9181 .map(|(channel, corr)| format!("{channel}:{corr}"))
9182 .collect::<Vec<_>>()
9183 .join(","),
9184 "route drain timed out before request quiescence; forcing teardown"
9185 );
9186 }
9187 crate::control::send_route_control_pushes(
9188 forwarding,
9189 routes,
9190 ClientControlPush::RouteClosed {
9191 module_id: spec.module_id.clone(),
9192 channels: Vec::new(),
9193 reason,
9194 drained,
9195 abandoned: target.abandoned_bindings.len() as u32,
9196 excluded_subscriptions: target.excluded_subscriptions,
9197 terminal: Some(terminal),
9198 },
9199 );
9200 wait_result?;
9201
9202 let released_routes = match forwarding.release_module_endpoint_routes(target.endpoint) {
9208 Ok(routes) => routes,
9209 Err(err) => {
9210 warn!(
9211 module_id = %spec.module_id,
9212 ?reason,
9213 error = %err,
9214 "failed to release module endpoint routes after route.closed; module GOODBYE will still be sent"
9215 );
9216 send_module_goodbye(&spec.module_id, forwarding, target);
9217 return Err(SuperviseError::Forwarding(err));
9218 }
9219 };
9220 let route_goodbye_count = released_routes.len();
9221 send_route_goodbyes(forwarding, released_routes);
9222 send_module_goodbye(&spec.module_id, forwarding, target);
9223
9224 info!(
9230 module_id = %spec.module_id,
9231 ?reason,
9232 routes_notified,
9233 route_goodbyes = route_goodbye_count,
9234 abandoned_reservations = target.abandoned_bindings.len(),
9235 excluded_subscriptions = target.excluded_subscriptions,
9236 drained,
9237 "module drain complete; consumers notified via route.closing/route.closed pushes and per-route GOODBYE frames"
9238 );
9239 }
9240
9241 Ok(StopNotice::SentOverConnection)
9242}
9243
9244async fn wait_for_registration_after_reload(
9247 registry: &Registry,
9248 module_id: &str,
9249 snapshot: &SharedSnapshot,
9250 child: &mut SupervisedChild,
9251 wait: Duration,
9252) -> Result<RegistrationWaitOutcome, SuperviseError> {
9253 wait_for_slot_registration(
9254 registry,
9255 crate::registry::RegistrationSlot::Active(module_id),
9256 module_id,
9257 snapshot,
9258 child,
9259 wait,
9260 )
9261 .await
9262}
9263
9264async fn wait_for_slot_registration(
9272 registry: &Registry,
9273 slot: crate::registry::RegistrationSlot<'_>,
9274 module_id: &str,
9275 snapshot: &SharedSnapshot,
9276 child: &mut SupervisedChild,
9277 wait: Duration,
9278) -> Result<RegistrationWaitOutcome, SuperviseError> {
9279 let deadline = Instant::now() + wait;
9280 loop {
9281 if registry
9282 .registration(slot)
9283 .map_err(SuperviseError::Registry)?
9284 .is_some()
9285 {
9286 return Ok(RegistrationWaitOutcome::Registered);
9287 }
9288
9289 let now = Instant::now();
9290 if now >= deadline {
9291 return Ok(RegistrationWaitOutcome::TimedOut);
9292 }
9293 let remaining = deadline.saturating_duration_since(now);
9294 let poll = remaining.min(REGISTRY_RELEASE_POLL);
9295
9296 tokio::select! {
9297 wait_result = child.wait() => {
9298 let status = wait_result.map_err(|source| SuperviseError::Wait {
9299 module_id: module_id.to_string(),
9300 source,
9301 })?;
9302 return Ok(RegistrationWaitOutcome::Exited(classify_reaped_child_exit(
9303 snapshot,
9304 child,
9305 &status,
9306 )));
9307 }
9308 _ = sleep(poll) => {}
9309 }
9310 }
9311}
9312
9313fn registration_failure_exit_report(mut exit_report: ExitReport) -> ExitReport {
9314 if exit_report.kind != ExitKind::DeliberateSeverance {
9317 exit_report.kind = ExitKind::Crash;
9318 }
9319 exit_report
9320}
9321
9322async fn handle_reload_child_registration_failure(
9323 spec: &ModuleSpec,
9324 runtime: &SupervisorRuntimeConfig,
9325 registry: &Registry,
9326 process_liveness: &SupervisorProcessLiveness,
9327 snapshot: &SharedSnapshot,
9328 _child: &mut Option<SupervisedChild>,
9329 failure: ReloadRegistrationFailure,
9330) -> Result<(), SuperviseError> {
9331 let ReloadRegistrationFailure {
9332 exit_report,
9333 reason,
9334 } = failure;
9335 match on_child_exit(
9336 spec,
9337 runtime.restart_policy,
9338 registry,
9339 snapshot,
9340 &runtime.terminal_ring,
9341 &runtime.spawn_events,
9342 &runtime.child_roster,
9343 exit_report,
9344 )
9345 .await
9346 {
9347 NextAction::Stop {
9348 registration_released,
9349 } => {
9350 if registration_released {
9351 process_liveness.untrack_if_current(&spec.module_id, snapshot);
9352 }
9353 }
9354 NextAction::Restart { schedule } => {
9355 let delay = schedule.map_or(runtime.restart_policy.delay_for_restart(0), |schedule| {
9356 schedule.delay
9357 });
9358 if let Some(schedule) = schedule {
9359 log_crash_respawn(&spec.module_id, schedule);
9360 }
9361 schedule_respawn(
9362 runtime,
9363 snapshot,
9364 &spec.module_id,
9365 delay,
9366 RespawnKind::Spawn,
9367 )?;
9368 }
9369 }
9370 Err(SuperviseError::ReloadFailed {
9371 module_id: spec.module_id.clone(),
9372 reason,
9373 })
9374}
9375
9376async fn handle_reload_spawn_failure(
9377 spec: &ModuleSpec,
9378 runtime: &SupervisorRuntimeConfig,
9379 process_liveness: &SupervisorProcessLiveness,
9380 snapshot: &SharedSnapshot,
9381 _child: &mut Option<SupervisedChild>,
9382 reason: String,
9383) -> Result<(), SuperviseError> {
9384 let now = Instant::now();
9385 let mut schedule = None;
9386 update_snapshot(snapshot, Some(&spec.module_id), |state| {
9387 clear_current_process_facts(state);
9388 if state.enabled {
9389 schedule = state.next_crash_restart(&runtime.restart_policy, now);
9390 state.state = if schedule.is_some() {
9391 ModuleState::Restarting
9392 } else {
9393 ModuleState::Failed
9394 };
9395 } else {
9396 state.state = ModuleState::Disabled;
9397 }
9398 })?;
9399 if let Some(schedule) = schedule {
9400 schedule_respawn(
9401 runtime,
9402 snapshot,
9403 &spec.module_id,
9404 schedule.delay,
9405 RespawnKind::Spawn,
9406 )?;
9407 } else {
9408 process_liveness.untrack_if_current(&spec.module_id, snapshot);
9409 }
9410 Err(SuperviseError::ReloadFailed {
9411 module_id: spec.module_id.clone(),
9412 reason,
9413 })
9414}
9415
9416fn control_flags() -> Flags {
9417 Flags::new(false, Priority::Passive, false)
9418}
9419
9420#[allow(clippy::too_many_arguments)]
9421async fn drain_optional_child(
9422 module_id: &str,
9423 protocol: ModuleProtocol,
9424 stop_notice: StopNotice,
9425 registry: &Registry,
9426 forwarding: Option<&ForwardingTable>,
9427 snapshot: &SharedSnapshot,
9428 terminal_ring: &Arc<Mutex<TerminalRing>>,
9429 spawn_events: &SpawnEventFeed,
9430 child: &mut Option<SupervisedChild>,
9431 drain_timeout: Duration,
9432 final_state: ModuleState,
9433 enabled: Option<bool>,
9434) -> Result<(), SuperviseError> {
9435 if let Some(child) = child.take() {
9436 drain_child_to_state(
9437 module_id,
9438 protocol,
9439 stop_notice,
9440 registry,
9441 forwarding,
9442 snapshot,
9443 terminal_ring,
9444 spawn_events,
9445 child,
9446 drain_timeout,
9447 final_state,
9448 enabled,
9449 )
9450 .await
9451 } else {
9452 update_snapshot(snapshot, Some(module_id), |state| {
9453 state.state = final_state;
9454 if let Some(enabled) = enabled {
9455 state.enabled = enabled;
9456 }
9457 clear_current_process_facts(state);
9458 })?;
9459 release_dead_registration(registry, forwarding, snapshot, module_id).await
9460 }
9461}
9462
9463#[allow(clippy::too_many_arguments)]
9464async fn drain_child_to_state(
9465 module_id: &str,
9466 _protocol: ModuleProtocol,
9467 stop_notice: StopNotice,
9468 registry: &Registry,
9469 forwarding: Option<&ForwardingTable>,
9470 snapshot: &SharedSnapshot,
9471 terminal_ring: &Arc<Mutex<TerminalRing>>,
9472 spawn_events: &SpawnEventFeed,
9473 mut child: SupervisedChild,
9474 drain_timeout: Duration,
9475 final_state: ModuleState,
9476 enabled: Option<bool>,
9477) -> Result<(), SuperviseError> {
9478 let protocol = child.protocol;
9479 update_snapshot(snapshot, Some(module_id), |state| {
9480 state.state = ModuleState::Draining;
9481 state.draining_to_replace = final_state == ModuleState::Restarting;
9482 if let Some(enabled) = enabled {
9483 state.enabled = enabled;
9484 }
9485 })?;
9486
9487 if stop_notice != StopNotice::SentOverConnection {
9498 if protocol == ModuleProtocol::Subc && stop_notice == StopNotice::NoConnection {
9499 info!(
9500 module_id,
9501 pid = child.pid,
9502 budget_ms = u64::try_from(drain_timeout.as_millis()).unwrap_or(u64::MAX),
9503 "module has no connection yet; requesting stop by signal"
9504 );
9505 }
9506 request_graceful_stop(module_id, &child);
9507 }
9508
9509 let exit_report = match timeout(drain_timeout, child.wait()).await {
9510 Ok(Ok(status)) => classify_reaped_child_exit(snapshot, &child, &status),
9511 Ok(Err(source)) => {
9512 fail_after_wait_error(
9513 module_id,
9514 snapshot,
9515 terminal_ring,
9516 spawn_events,
9517 format!("failed to wait during drain: {source}"),
9518 );
9519 return Err(SuperviseError::Wait {
9520 module_id: module_id.to_string(),
9521 source,
9522 });
9523 }
9524 Err(_) => {
9525 warn!(
9538 module_id,
9539 pid = child.pid,
9540 budget_ms = u64::try_from(drain_timeout.as_millis()).unwrap_or(u64::MAX),
9541 reason = ?final_state,
9542 ?stop_notice,
9543 "drain budget expired before the module exited; killing it"
9544 );
9545 child.start_kill().map_err(|source| {
9546 fail_after_wait_error(
9547 module_id,
9548 snapshot,
9549 terminal_ring,
9550 spawn_events,
9551 format!("failed to kill during drain: {source}"),
9552 );
9553 SuperviseError::Kill {
9554 module_id: module_id.to_string(),
9555 source,
9556 }
9557 })?;
9558 let status = child.wait().await.map_err(|source| {
9559 fail_after_wait_error(
9560 module_id,
9561 snapshot,
9562 terminal_ring,
9563 spawn_events,
9564 format!("failed to wait after drain kill: {source}"),
9565 );
9566 SuperviseError::Wait {
9567 module_id: module_id.to_string(),
9568 source,
9569 }
9570 })?;
9571 classify_reaped_child_exit(snapshot, &child, &status)
9572 }
9573 };
9574
9575 let detail = lock_snapshot(snapshot)?.drain_disposition_detail.take();
9576 publish_after_terminal_record(
9577 module_id,
9578 || {
9579 record_terminal_with_detail(
9580 module_id,
9581 terminal_ring,
9582 spawn_events,
9583 &exit_report,
9584 terminal_disposition(final_state),
9585 detail,
9586 )
9587 },
9588 || {
9589 update_snapshot(snapshot, Some(module_id), |state| {
9590 state.state = final_state;
9591 if let Some(enabled) = enabled {
9592 state.enabled = enabled;
9593 }
9594 clear_current_process_facts(state);
9595 state.last_exit = Some(exit_report.clone());
9596 if exit_report.kind == ExitKind::DeliberateSeverance {
9597 state.lifetime_restarts += 1;
9598 }
9599 })
9600 },
9601 )?;
9602 child.drain_stderr(module_id).await;
9603
9604 release_dead_registration(registry, forwarding, snapshot, module_id).await
9605}
9606
9607#[cfg(unix)]
9627fn request_graceful_stop(module_id: &str, child: &SupervisedChild) {
9628 let Some(pid) = child
9629 .id()
9630 .and_then(|pid| i32::try_from(pid).ok())
9631 .and_then(rustix::process::Pid::from_raw)
9632 else {
9633 debug!(
9634 module_id,
9635 "no pid to signal for teardown; falling through to the drain wait"
9636 );
9637 return;
9638 };
9639 match rustix::process::kill_process(pid, rustix::process::Signal::TERM) {
9640 Ok(()) => debug!(
9641 module_id,
9642 "sent SIGTERM to a module nothing else asked to stop"
9643 ),
9644 Err(err) => debug!(
9645 module_id,
9646 error = %err,
9647 "SIGTERM to module failed; the drain wait and kill still apply"
9648 ),
9649 }
9650}
9651
9652#[cfg(not(unix))]
9660fn request_graceful_stop(module_id: &str, _child: &SupervisedChild) {
9661 debug!(
9662 module_id,
9663 "no graceful stop signal exists on this platform; teardown of a module nothing asked to stop waits, then kills"
9664 );
9665}
9666
9667fn terminal_disposition(final_state: ModuleState) -> TerminalDisposition {
9668 match final_state {
9669 ModuleState::Stopped => TerminalDisposition::Stopped,
9670 ModuleState::Disabled => TerminalDisposition::Disabled,
9671 ModuleState::Restarting => TerminalDisposition::Restarting,
9672 ModuleState::Failed => TerminalDisposition::Failed,
9673 ModuleState::Starting
9674 | ModuleState::Running
9675 | ModuleState::Unresponsive
9676 | ModuleState::Draining => {
9677 unreachable!("terminal exits only finish in terminal or restarting states")
9678 }
9679 }
9680}
9681
9682async fn release_dead_registration(
9690 registry: &Registry,
9691 forwarding: Option<&ForwardingTable>,
9692 snapshot: &SharedSnapshot,
9693 module_id: &str,
9694) -> Result<(), SuperviseError> {
9695 let result = async {
9696 let registration = registry
9697 .get_module(module_id)
9698 .map_err(SuperviseError::Registry)?;
9699 match wait_for_registration_release(registry, module_id, REGISTRY_RELEASE_TIMEOUT).await {
9700 Ok(()) => return Ok(()),
9701 Err(SuperviseError::RegistrationStillActive { .. }) => {}
9702 Err(err) => return Err(err),
9703 }
9704 let pid = lock_snapshot(snapshot)?.reaped_pid;
9705 if let (Some(registration), Some(forwarding), Some(pid)) = (registration, forwarding, pid) {
9706 warn!(
9707 module_id,
9708 pid,
9709 connection_id = registration.connection_id.get(),
9710 "reaped module registration outlived release grace; closing dead connection"
9711 );
9712 forwarding.request_connection_close(
9713 registration.connection_id,
9714 CloseReason::new(
9715 "supervised_process_reaped",
9716 format!("module '{module_id}' pid {pid} exited"),
9717 ),
9718 );
9719 wait_for_slot_registration_release(
9720 registry,
9721 crate::registry::RegistrationSlot::Connection(registration.connection_id),
9722 REGISTRY_RELEASE_TIMEOUT,
9723 )
9724 .await?;
9725 }
9726 wait_for_registration_release(registry, module_id, Duration::ZERO).await
9727 }
9728 .await;
9729 if let Err(err) = &result {
9730 fail_snapshot(snapshot, Some(module_id), None);
9731 error!(module_id, error = %err, "registration release failed after child exit; module is failed and start can retry");
9732 }
9733 result
9734}
9735
9736async fn wait_for_registration_release(
9739 registry: &Registry,
9740 module_id: &str,
9741 wait: Duration,
9742) -> Result<(), SuperviseError> {
9743 wait_for_slot_registration_release(
9744 registry,
9745 crate::registry::RegistrationSlot::Active(module_id),
9746 wait,
9747 )
9748 .await
9749}
9750
9751async fn wait_for_slot_registration_release(
9759 registry: &Registry,
9760 slot: crate::registry::RegistrationSlot<'_>,
9761 wait: Duration,
9762) -> Result<(), SuperviseError> {
9763 let deadline = Instant::now() + wait;
9764 let mut release_events = registration_release_events().subscribe();
9765 let still_active = |registration: &crate::registry::ModuleRegistration| {
9766 SuperviseError::RegistrationStillActive {
9767 module_id: registration.manifest.module_id.clone(),
9768 waited: wait,
9769 }
9770 };
9771 loop {
9772 let _observed_generation = *release_events.borrow_and_update();
9773 let Some(registration) = registry
9774 .registration(slot)
9775 .map_err(SuperviseError::Registry)?
9776 else {
9777 return Ok(());
9778 };
9779
9780 let now = Instant::now();
9781 if now >= deadline {
9782 return Err(still_active(®istration));
9783 }
9784
9785 let remaining = deadline.saturating_duration_since(now);
9786 match timeout(remaining, release_events.changed()).await {
9787 Ok(Ok(())) | Ok(Err(_)) => {}
9788 Err(_) => return Err(still_active(®istration)),
9789 }
9790 }
9791}
9792
9793#[cfg(test)]
9794mod slot_registration_wait_tests {
9795 use super::*;
9796 use crate::registry::{ConnectionId, RegistrationSlot};
9797 use subc_protocol::manifest::ModuleManifest;
9798
9799 #[tokio::test]
9800 async fn enable_release_failure_is_failed_and_a_second_enable_retries() {
9801 let registry = Arc::new(Registry::default());
9802 let supervisor = Supervisor::new_for_test(Arc::clone(®istry), RestartPolicy::default());
9803 let runtime = supervisor.runtime_config();
9804 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::disabled()));
9805 let spec = ModuleSpec {
9806 module_id: "enable-stale-registration".to_string(),
9807 program: PathBuf::from("/missing/enable-retry-test"),
9808 args: Vec::new(),
9809 env: Vec::new(),
9810 reserved: false,
9811 reserved_prefixes: Vec::new(),
9812 protocol: ModuleProtocol::Subc,
9813 overlap: Default::default(),
9814 };
9815 let connection = ConnectionId::new(90);
9816 registry
9817 .register_with_control_ops(
9818 ModuleManifest::builder(&spec.module_id, "0.1.0").build(),
9819 1,
9820 connection,
9821 Vec::new(),
9822 )
9823 .unwrap();
9824 let mut child = None;
9825 let err = set_child_enabled(
9826 &spec,
9827 &runtime,
9828 ®istry,
9829 &supervisor.process_liveness,
9830 &snapshot,
9831 &mut child,
9832 true,
9833 )
9834 .await
9835 .unwrap_err();
9836 assert!(matches!(
9837 err,
9838 SuperviseError::RegistrationStillActive { .. }
9839 ));
9840 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
9841 assert!(child.is_none());
9842 registry.deregister_connection(connection).unwrap();
9843 let err = set_child_enabled(
9844 &spec,
9845 &runtime,
9846 ®istry,
9847 &supervisor.process_liveness,
9848 &snapshot,
9849 &mut child,
9850 true,
9851 )
9852 .await
9853 .unwrap_err();
9854 assert!(
9855 matches!(err, SuperviseError::Spawn { .. }),
9856 "second enable must attempt a spawn: {err}"
9857 );
9858 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
9859 }
9860
9861 const INCUMBENT: u64 = 1;
9862 const CANDIDATE: u64 = 2;
9863
9864 fn swapped_registry() -> Arc<Registry> {
9865 let registry = Arc::new(Registry::default());
9866 let manifest = ModuleManifest::builder("m", "0.1.0").build();
9867 registry
9868 .register_with_control_ops(
9869 manifest.clone(),
9870 1,
9871 ConnectionId::new(INCUMBENT),
9872 Vec::new(),
9873 )
9874 .unwrap();
9875 registry
9876 .register_candidate_with_control_ops(
9877 manifest,
9878 1,
9879 ConnectionId::new(CANDIDATE),
9880 Vec::new(),
9881 )
9882 .unwrap();
9883 registry
9884 }
9885
9886 #[tokio::test]
9890 async fn incumbent_release_is_awaited_by_connection_not_by_module_id() {
9891 let registry = swapped_registry();
9892 registry.promote_candidate("m").unwrap().unwrap();
9893
9894 assert!(matches!(
9895 wait_for_registration_release(®istry, "m", Duration::from_millis(50)).await,
9896 Err(SuperviseError::RegistrationStillActive { .. })
9897 ));
9898
9899 assert!(matches!(
9901 wait_for_slot_registration_release(
9902 ®istry,
9903 RegistrationSlot::Connection(ConnectionId::new(INCUMBENT)),
9904 Duration::from_millis(50),
9905 )
9906 .await,
9907 Err(SuperviseError::RegistrationStillActive { .. })
9908 ));
9909
9910 let releaser = Arc::clone(®istry);
9911 let release = tokio::spawn(async move {
9912 sleep(Duration::from_millis(20)).await;
9913 releaser
9914 .deregister_connection(ConnectionId::new(INCUMBENT))
9915 .unwrap();
9916 notify_registration_release();
9917 });
9918 wait_for_slot_registration_release(
9919 ®istry,
9920 RegistrationSlot::Connection(ConnectionId::new(INCUMBENT)),
9921 Duration::from_secs(5),
9922 )
9923 .await
9924 .expect("the incumbent's own registration is released");
9925 release.await.unwrap();
9926 assert!(registry.get_module("m").unwrap().is_some());
9927 }
9928
9929 #[tokio::test]
9932 async fn candidate_slot_wait_ignores_the_incumbents_registration() {
9933 let registry = swapped_registry();
9934 assert!(matches!(
9935 wait_for_slot_registration_release(
9936 ®istry,
9937 RegistrationSlot::Candidate("m"),
9938 Duration::from_millis(50),
9939 )
9940 .await,
9941 Err(SuperviseError::RegistrationStillActive { .. })
9942 ));
9943 registry
9944 .deregister_connection(ConnectionId::new(CANDIDATE))
9945 .unwrap();
9946 wait_for_slot_registration_release(
9947 ®istry,
9948 RegistrationSlot::Candidate("m"),
9949 Duration::from_millis(50),
9950 )
9951 .await
9952 .expect("a candidate slot with no candidate is released");
9953 assert!(registry
9954 .registration(RegistrationSlot::Active("m"))
9955 .unwrap()
9956 .is_some());
9957 }
9958}
9959
9960fn classify_exit(status: &ExitStatus) -> ExitReport {
9961 ExitReport {
9962 kind: if status.success() {
9963 ExitKind::Clean
9964 } else {
9965 ExitKind::Crash
9966 },
9967 code: status.code(),
9968 signal: exit_signal(status),
9969 at_ms: unix_ms_now(),
9970 }
9971}
9972
9973fn wait_error_exit_report() -> ExitReport {
9979 ExitReport {
9980 kind: ExitKind::Crash,
9981 code: None,
9982 signal: None,
9983 at_ms: unix_ms_now(),
9984 }
9985}
9986
9987#[cfg(unix)]
9988fn exit_signal(status: &ExitStatus) -> Option<i32> {
9989 use std::os::unix::process::ExitStatusExt;
9990
9991 status.signal()
9992}
9993
9994#[cfg(not(unix))]
9995fn exit_signal(_status: &ExitStatus) -> Option<i32> {
9996 None
9997}
9998
9999fn reset_restart_count(snapshot: &SharedSnapshot, module_id: &str) -> Result<(), SuperviseError> {
10005 update_snapshot(snapshot, Some(module_id), |state| {
10006 state.clear_crash_restarts();
10007 })
10008}
10009
10010fn set_running(
10011 snapshot: &SharedSnapshot,
10012 child: &SupervisedChild,
10013 module_id: &str,
10014 spawn_events: &SpawnEventFeed,
10015) -> Result<(), SuperviseError> {
10016 let mut state = snapshot.lock().map_err(|_| SuperviseError::StatePoisoned {
10017 module_id: Some(module_id.to_string()),
10018 })?;
10019 state.spawn_generation = spawn_events.emit_spawned(module_id, child.pid, child.spawned_at_ms);
10020 if std::mem::take(&mut state.coalesced_restart_pending) {
10021 let generation = state.spawn_generation;
10022 info!(module_id, "restart already satisfied by generation {generation}; coalesced pending request completed");
10023 }
10024 state.drain_disposition_detail = None;
10025 state.spawn_failure = None;
10026 state.waiting_for_previous_pid = None;
10027 state.in_alternate_slot = false;
10030 state.configuration_updated_since_spawn = false;
10031 state.spawned_protocol = Some(child.protocol);
10032 state.state = ModuleState::Running;
10033 state.enabled = true;
10034 state.process_alive = true;
10035 state.pid = child.id();
10036 #[cfg(windows)]
10037 {
10038 state.pipe_consumed = child
10039 .nonce_delivery
10040 .as_ref()
10041 .map(|delivery| delivery.consumed());
10042 }
10043 #[cfg(target_os = "macos")]
10044 {
10045 state.report_ready = Some(Arc::clone(&child.report_ready));
10046 }
10047 state.spawned_at_ms = Some(child.spawned_at_ms);
10048 state.spawned_from = Some(child.spawned_from.clone());
10049 state.spawned_file_identity = child.spawned_file_identity.clone();
10050 state.process_start_time = child.process_start_time;
10051 Ok(())
10052}
10053
10054fn clear_current_process_facts(state: &mut SupervisorSnapshot) {
10055 state.waiting_for_previous_pid = None;
10056 state.process_alive = false;
10057 state.spawned_protocol = None;
10058 state.pid = None;
10059 #[cfg(windows)]
10060 {
10061 state.pipe_consumed = None;
10062 }
10063 #[cfg(target_os = "macos")]
10064 {
10065 state.report_ready = None;
10066 }
10067 state.spawned_at_ms = None;
10068 state.spawned_from = None;
10069 state.spawned_file_identity = None;
10070 state.process_start_time = None;
10071 state.deliberate_severance = None;
10072}
10073
10074#[cfg(test)]
10075fn record_deliberate_severance(
10076 snapshot: &SharedSnapshot,
10077 identity: ProcessIdentity,
10078) -> Result<(), SuperviseError> {
10079 update_snapshot(snapshot, None, |state| {
10080 state.deliberate_severance = Some(identity);
10081 })
10082}
10083
10084fn apply_deliberate_severance_marker(
10085 snapshot: &SharedSnapshot,
10086 exited_identity: Option<ProcessIdentity>,
10087 mut exit_report: ExitReport,
10088) -> ExitReport {
10089 let marker = lock_snapshot(snapshot)
10090 .ok()
10091 .and_then(|mut state| state.deliberate_severance.take());
10092 if marker.is_some() && marker == exited_identity {
10093 exit_report.kind = ExitKind::DeliberateSeverance;
10094 }
10095 exit_report
10096}
10097
10098fn classify_reaped_child_exit(
10099 snapshot: &SharedSnapshot,
10100 child: &SupervisedChild,
10101 status: &ExitStatus,
10102) -> ExitReport {
10103 let _ = update_snapshot(snapshot, None, |state| {
10104 state.reaped_pid = Some(child.pid);
10105 state.spawn_failure = child.spawn_failure.clone();
10106 });
10107 apply_deliberate_severance_marker(snapshot, child.process_identity(), classify_exit(status))
10108}
10109
10110fn fail_snapshot(
10111 snapshot: &SharedSnapshot,
10112 module_id: Option<&str>,
10113 last_exit: Option<ExitReport>,
10114) {
10115 if let Err(err) = update_snapshot(snapshot, module_id, |state| {
10116 state.state = ModuleState::Failed;
10117 clear_current_process_facts(state);
10118 if let Some(last_exit) = last_exit {
10119 state.last_exit = Some(last_exit);
10120 }
10121 }) {
10122 error!(error = %err, "failed to mark supervisor state failed");
10123 }
10124}
10125
10126fn update_snapshot(
10127 snapshot: &SharedSnapshot,
10128 module_id: Option<&str>,
10129 update: impl FnOnce(&mut SupervisorSnapshot),
10130) -> Result<(), SuperviseError> {
10131 let mut state = snapshot.lock().map_err(|_| SuperviseError::StatePoisoned {
10132 module_id: module_id.map(ToOwned::to_owned),
10133 })?;
10134 update(&mut state);
10135 Ok(())
10136}
10137
10138const SLOW_SNAPSHOT_LOCK_THRESHOLD: Duration = Duration::from_millis(250);
10139
10140fn lock_snapshot_for_control<'a>(
10141 snapshot: &'a SharedSnapshot,
10142 module_id: &str,
10143 caller: &'static str,
10144) -> Result<std::sync::MutexGuard<'a, SupervisorSnapshot>, SuperviseError> {
10145 let started_at = Instant::now();
10146 let guard = lock_snapshot(snapshot)?;
10147 let waited = started_at.elapsed();
10148 if waited >= SLOW_SNAPSHOT_LOCK_THRESHOLD {
10149 warn!(
10150 module_id = %module_id,
10151 waited_ms = waited.as_millis() as u64,
10152 caller = %caller,
10153 "slow snapshot lock"
10154 );
10155 }
10156 Ok(guard)
10157}
10158
10159fn lock_snapshot(
10160 snapshot: &SharedSnapshot,
10161) -> Result<std::sync::MutexGuard<'_, SupervisorSnapshot>, SuperviseError> {
10162 snapshot
10163 .lock()
10164 .map_err(|_| SuperviseError::StatePoisoned { module_id: None })
10165}
10166
10167#[cfg(test)]
10168mod terminal_history_tests {
10169 use std::{
10170 path::PathBuf,
10171 sync::Arc,
10172 time::{Duration, Instant},
10173 };
10174
10175 use tokio::{sync::mpsc, time::sleep};
10176
10177 use super::{
10178 apply_deliberate_severance_marker, daemon_will_restart, drain_child_to_state,
10179 drained_after_quiescence_wait, handle_reload_spawn_failure, health_restart_child,
10180 lock_snapshot, on_child_exit, record_deliberate_severance, record_wait_error_terminal,
10181 reset_restart_count, spawn_and_mark_running, update_snapshot, wait_error_exit_report,
10182 ExitKind, ExitReport, ModuleProtocol, ModuleSpec, ModuleState, NextAction, ProcessIdentity,
10183 RestartPolicy, SpawnEventKind, StopNotice, SuperviseError, SupervisedConfiguration,
10184 SupervisedModule, SupervisedModuleInner, Supervisor, SupervisorHandle,
10185 SupervisorHealthStatus, SupervisorSnapshot,
10186 };
10187 use super::Instant as ClockInstant;
10192 use crate::{
10193 registry::Registry,
10194 terminal_ring::{TerminalRing, TerminalRingConfig},
10195 };
10196 use std::sync::Mutex;
10197 use subc_control::TerminalDisposition;
10198
10199 pub(super) fn fake_aft_stub_path() -> PathBuf {
10204 let mut path = std::env::current_exe().expect("current_exe available in tests");
10205 path.pop();
10206 path.pop();
10207 path.push(if cfg!(windows) {
10208 "fake-aft-stub.exe"
10209 } else {
10210 "fake-aft-stub"
10211 });
10212 assert!(
10213 path.exists(),
10214 "fake-aft-stub not built at {}: run `cargo test -p subc-core` (which builds \
10215 [[bin]] targets) rather than `cargo test -p subc-core --lib` (which does not)",
10216 path.display()
10217 );
10218 path
10219 }
10220
10221 #[test]
10222 fn reserved_never_spawned_refuses_every_hello() {
10223 let supervisor = SupervisorHandle::default();
10228 supervisor.apply_identity_configuration(&ModuleSpec {
10229 module_id: "never-spawned".to_string(),
10230 program: PathBuf::from("/usr/bin/false"),
10231 args: Vec::new(),
10232 env: Vec::new(),
10233 reserved: true,
10234 reserved_prefixes: Vec::new(),
10235 protocol: ModuleProtocol::Subc,
10236 overlap: Default::default(),
10237 });
10238 assert!(
10239 supervisor
10240 .reserved_hello_rejection("never-spawned", Some("any-forged-nonce"))
10241 .is_some(),
10242 "forged nonce must refuse on a reserved never-spawned id"
10243 );
10244 assert!(
10245 supervisor
10246 .reserved_hello_rejection("never-spawned", None)
10247 .is_some(),
10248 "absent nonce must refuse on a reserved never-spawned id"
10249 );
10250 supervisor.set_spawn_nonce("never-spawned", "minted".to_string());
10252 supervisor.apply_identity_configuration(&ModuleSpec {
10253 module_id: "never-spawned".to_string(),
10254 program: PathBuf::from("/usr/bin/false"),
10255 args: Vec::new(),
10256 env: Vec::new(),
10257 reserved: true,
10258 reserved_prefixes: Vec::new(),
10259 protocol: ModuleProtocol::Subc,
10260 overlap: Default::default(),
10261 });
10262 assert!(supervisor
10263 .reserved_hello_rejection("never-spawned", Some("minted"))
10264 .is_none());
10265 assert!(supervisor
10266 .reserved_hello_rejection("never-spawned", Some("forged"))
10267 .is_some());
10268 }
10269
10270 fn seed_crash_restarts(state: &mut SupervisorSnapshot, count: u32) {
10273 let now = ClockInstant::now();
10274 for _ in 0..count {
10275 state.crash_restarts.push_back(now);
10276 }
10277 }
10278
10279 fn age_oldest_crash_restart_out_of_window(state: &mut SupervisorSnapshot, window: Duration) {
10283 let aged = state
10284 .crash_restarts
10285 .front()
10286 .expect("a crash restart must be recorded before it can be aged")
10287 .checked_sub(window + Duration::from_secs(1))
10288 .expect("the test clock is far enough from its origin to age an instant");
10289 state.crash_restarts[0] = aged;
10290 }
10291
10292 fn snapshot_with_restarts(enabled: bool, count: u32) -> SupervisorSnapshot {
10293 let mut state = SupervisorSnapshot::new(ModuleState::Running, enabled);
10294 seed_crash_restarts(&mut state, count);
10295 state
10296 }
10297
10298 #[test]
10299 fn daemon_owned_recovery_predicate_uses_the_pre_increment_budget() {
10300 let policy = RestartPolicy::new(3, Duration::ZERO);
10301 let now = ClockInstant::now();
10302 assert!(daemon_will_restart(
10303 &mut snapshot_with_restarts(true, 2),
10304 &policy,
10305 now
10306 ));
10307 assert!(!daemon_will_restart(
10308 &mut snapshot_with_restarts(true, 3),
10309 &policy,
10310 now
10311 ));
10312 assert!(!daemon_will_restart(
10313 &mut snapshot_with_restarts(false, 0),
10314 &policy,
10315 now
10316 ));
10317 }
10318
10319 #[test]
10320 fn crash_restart_backoff_escalates_with_in_window_count() {
10321 let policy = RestartPolicy::new(4, Duration::from_millis(100))
10322 .with_max_backoff(Duration::from_secs(30));
10323 let now = ClockInstant::now();
10324 let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
10325 let schedules = (0..4)
10326 .map(|_| {
10327 state
10328 .next_crash_restart(&policy, now)
10329 .expect("the test policy allows four crash restarts")
10330 })
10331 .collect::<Vec<_>>();
10332
10333 assert_eq!(
10334 schedules
10335 .iter()
10336 .map(|schedule| schedule.restart_in_window)
10337 .collect::<Vec<_>>(),
10338 vec![0, 1, 2, 3]
10339 );
10340 assert_eq!(
10341 schedules
10342 .iter()
10343 .map(|schedule| schedule.delay)
10344 .collect::<Vec<_>>(),
10345 vec![
10346 Duration::from_millis(100),
10347 Duration::from_secs(1),
10348 Duration::from_secs(10),
10349 Duration::from_secs(30),
10350 ]
10351 );
10352 }
10353
10354 #[test]
10355 fn crash_restart_backoff_resets_after_ring_clear() {
10356 let policy = RestartPolicy::new(3, Duration::from_millis(100));
10357 let now = ClockInstant::now();
10358 let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
10359 assert_eq!(
10360 state.next_crash_restart(&policy, now).unwrap().delay,
10361 Duration::from_millis(100)
10362 );
10363 assert_eq!(
10364 state.next_crash_restart(&policy, now).unwrap().delay,
10365 Duration::from_secs(1)
10366 );
10367
10368 state.clear_crash_restarts();
10369 let schedule = state
10370 .next_crash_restart(&policy, now)
10371 .expect("a cleared ring must allow another restart");
10372 assert_eq!(schedule.restart_in_window, 0);
10373 assert_eq!(schedule.delay, Duration::from_millis(100));
10374 }
10375
10376 #[test]
10377 fn crash_restart_backoff_ignores_aged_restarts() {
10378 let policy = RestartPolicy::new(3, Duration::from_millis(100));
10379 let now = ClockInstant::now();
10380 let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
10381 state
10382 .next_crash_restart(&policy, now)
10383 .expect("the first restart is allowed");
10384 state
10385 .next_crash_restart(&policy, now)
10386 .expect("the second restart is allowed");
10387 state.crash_restarts[0] = now
10388 .checked_sub(policy.window + Duration::from_secs(1))
10389 .expect("the fake clock can age a restart past the window");
10390
10391 let schedule = state
10392 .next_crash_restart(&policy, now)
10393 .expect("an aged restart must release its slot");
10394 assert_eq!(schedule.restart_in_window, 1);
10395 assert_eq!(schedule.delay, Duration::from_secs(1));
10396 assert_eq!(state.crash_restarts.len(), 2);
10397 }
10398
10399 #[test]
10403 fn a_budget_spent_before_the_window_no_longer_refuses() {
10404 let policy = RestartPolicy::new(3, Duration::ZERO);
10405 let mut state = snapshot_with_restarts(true, 3);
10406 let now = ClockInstant::now();
10407 assert!(!daemon_will_restart(&mut state, &policy, now));
10408
10409 assert!(daemon_will_restart(
10410 &mut state,
10411 &policy,
10412 now + policy.window + Duration::from_secs(1)
10413 ));
10414 assert!(
10415 state.crash_restarts.is_empty(),
10416 "reading the budget must drop the instants that left the window"
10417 );
10418 }
10419
10420 fn module_with_recovery_snapshot(
10421 state: ModuleState,
10422 enabled: bool,
10423 restart_count: u32,
10424 ) -> SupervisedModule {
10425 let registry = Arc::new(Registry::default());
10426 let supervisor =
10427 Supervisor::new_for_test(Arc::clone(®istry), RestartPolicy::new(3, Duration::ZERO));
10428 let runtime = supervisor.runtime_config();
10429 let spec = ModuleSpec {
10430 module_id: "recovery-snapshot".to_string(),
10431 program: fake_aft_stub_path(),
10432 args: Vec::new(),
10433 env: Vec::new(),
10434 reserved: false,
10435 reserved_prefixes: Vec::new(),
10436 protocol: ModuleProtocol::Subc,
10437 overlap: Default::default(),
10438 };
10439 let mut snapshot = SupervisorSnapshot::new(state, enabled);
10440 seed_crash_restarts(&mut snapshot, restart_count);
10441 let (commands, _rx) = mpsc::channel(4);
10444 SupervisedModule {
10445 inner: Arc::new(SupervisedModuleInner {
10446 module_id: spec.module_id.clone(),
10447 registry,
10448 snapshot: Arc::new(Mutex::new(snapshot)),
10449 configuration: Arc::new(Mutex::new(SupervisedConfiguration {
10450 spec,
10451 health: runtime.health,
10452 })),
10453 stderr_ring: runtime.stderr_ring,
10454 terminal_ring: runtime.terminal_ring,
10455 commands,
10456 monitor: Mutex::new(None),
10457 restart_policy: runtime.restart_policy,
10458 effective_drain_timeout: runtime.effective_drain_timeout,
10459 provenance_probe: supervisor.provenance_probe.clone(),
10460 }),
10461 }
10462 }
10463
10464 #[cfg(target_os = "linux")]
10465 #[tokio::test]
10466 async fn no_cgroup_placement_does_not_block_fake_aft_stub_spawn() {
10467 let supervisor =
10468 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
10469 .with_cgroup_placement(None);
10470 let result = supervisor.spawn(ModuleSpec {
10471 module_id: "no-cgroup-placement".to_string(),
10472 program: fake_aft_stub_path(),
10473 args: Vec::new(),
10474 env: Vec::new(),
10475 reserved: false,
10476 reserved_prefixes: Vec::new(),
10477 protocol: ModuleProtocol::Subc,
10478 overlap: Default::default(),
10479 });
10480
10481 assert!(
10482 result.is_ok(),
10483 "no delegation must not turn an otherwise valid spawn into a failure: {result:?}"
10484 );
10485 }
10486
10487 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10488 async fn undecided_snapshot_uses_shared_restart_predicate() {
10489 assert!(module_with_recovery_snapshot(ModuleState::Running, true, 2)
10490 .will_recover_after_connection_loss()
10491 .unwrap());
10492 assert!(
10493 !module_with_recovery_snapshot(ModuleState::Running, true, 3)
10494 .will_recover_after_connection_loss()
10495 .unwrap()
10496 );
10497 }
10498
10499 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10500 async fn restarting_snapshot_at_exhausted_budget_is_non_terminal() {
10501 assert!(
10502 module_with_recovery_snapshot(ModuleState::Restarting, true, 3)
10503 .will_recover_after_connection_loss()
10504 .unwrap()
10505 );
10506 }
10507
10508 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10509 async fn terminal_phase_snapshots_are_terminal_before_budget_exhaustion() {
10510 assert!(!module_with_recovery_snapshot(ModuleState::Failed, true, 0)
10511 .will_recover_after_connection_loss()
10512 .unwrap());
10513 assert!(
10514 !module_with_recovery_snapshot(ModuleState::Disabled, true, 0)
10515 .will_recover_after_connection_loss()
10516 .unwrap()
10517 );
10518 }
10519
10520 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10521 async fn warming_snapshot_is_limited_to_startup_phases() {
10522 for state in [
10523 ModuleState::Starting,
10524 ModuleState::Running,
10525 ModuleState::Restarting,
10526 ] {
10527 assert!(
10528 module_with_recovery_snapshot(state, true, 0)
10529 .is_warming()
10530 .unwrap(),
10531 "{state:?} should be warming"
10532 );
10533 }
10534 for state in [
10535 ModuleState::Unresponsive,
10536 ModuleState::Draining,
10537 ModuleState::Stopped,
10538 ModuleState::Failed,
10539 ModuleState::Disabled,
10540 ] {
10541 assert!(
10542 !module_with_recovery_snapshot(state, true, 0)
10543 .is_warming()
10544 .unwrap(),
10545 "{state:?} should not be warming"
10546 );
10547 }
10548 }
10549
10550 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10551 async fn terminal_history_survives_respawn_and_keeps_both_crashes_in_order() {
10552 let registry = Arc::new(Registry::default());
10553 let supervisor =
10554 Supervisor::new_for_test(Arc::clone(®istry), RestartPolicy::new(1, Duration::ZERO));
10555 let module = supervisor
10556 .spawn(ModuleSpec {
10557 module_id: "terminal-history".to_string(),
10558 program: fake_aft_stub_path(),
10559 args: Vec::new(),
10560 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
10561 reserved: false,
10562 reserved_prefixes: Vec::new(),
10563 protocol: ModuleProtocol::Subc,
10564 overlap: Default::default(),
10565 })
10566 .unwrap();
10567
10568 let deadline = Instant::now() + Duration::from_secs(5);
10569 loop {
10570 let history = module.terminal_history();
10571 if history.entries.len() == 2 {
10572 assert_eq!(module.status().unwrap().state, ModuleState::Failed);
10573 assert_eq!(history.dropped, 0);
10574 assert_eq!(
10575 history
10576 .entries
10577 .iter()
10578 .map(|entry| entry.exit_code)
10579 .collect::<Vec<_>>(),
10580 vec![Some(23), Some(23)]
10581 );
10582 assert!(history.entries[0].at_ms <= history.entries[1].at_ms);
10583 return;
10584 }
10585 assert!(
10586 Instant::now() < deadline,
10587 "module did not retain two terminal exits: {history:?}"
10588 );
10589 sleep(Duration::from_millis(10)).await;
10590 }
10591 }
10592
10593 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10597 async fn disable_during_crash_backoff_cancels_pending_respawn() {
10598 let backoff = Duration::from_secs(2);
10599 let supervisor = Supervisor::new_for_test(
10600 Arc::new(Registry::default()),
10601 RestartPolicy::new(10, backoff),
10602 );
10603 let module = supervisor
10604 .spawn(ModuleSpec {
10605 module_id: "disable-during-backoff".to_string(),
10606 program: fake_aft_stub_path(),
10607 args: Vec::new(),
10608 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
10609 reserved: false,
10610 reserved_prefixes: Vec::new(),
10611 protocol: ModuleProtocol::Subc,
10612 overlap: Default::default(),
10613 })
10614 .unwrap();
10615
10616 let deadline = Instant::now() + Duration::from_secs(5);
10618 loop {
10619 if module.status().unwrap().state == ModuleState::Restarting {
10620 break;
10621 }
10622 assert!(
10623 Instant::now() < deadline,
10624 "module never entered the crash backoff"
10625 );
10626 sleep(Duration::from_millis(10)).await;
10627 }
10628
10629 let started = Instant::now();
10630 module.set_enabled(false).await.unwrap();
10631 let waited = started.elapsed();
10632
10633 assert!(
10634 waited < backoff / 2,
10635 "disable waited {waited:?} behind the {backoff:?} crash backoff; the operator command must preempt the pending respawn"
10636 );
10637 assert_eq!(module.status().unwrap().state, ModuleState::Disabled);
10638
10639 sleep(backoff + Duration::from_millis(500)).await;
10641 let status = module.status().unwrap();
10642 assert_eq!(status.state, ModuleState::Disabled);
10643 assert_eq!(
10644 status.spawn_generation, 1,
10645 "module respawned after the operator disabled it"
10646 );
10647 }
10648
10649 #[cfg(unix)]
10652 fn protocol_none_sigterm_exits_clean_spec(
10653 module_id: &str,
10654 dir: &std::path::Path,
10655 ) -> (ModuleSpec, PathBuf, PathBuf) {
10656 let ready = dir.join("ready");
10657 let marker = dir.join("sigterm");
10658 let spec = ModuleSpec {
10659 module_id: module_id.to_string(),
10660 program: fake_aft_stub_path(),
10661 args: Vec::new(),
10662 env: vec![
10663 ("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string()),
10664 (
10665 "FAKE_AFT_SIGTERM_MARKER_PATH".to_string(),
10666 marker.display().to_string(),
10667 ),
10668 (
10669 "FAKE_AFT_NEVER_CONNECT_READY_PATH".to_string(),
10670 ready.display().to_string(),
10671 ),
10672 ],
10673 reserved: false,
10674 reserved_prefixes: Vec::new(),
10675 protocol: ModuleProtocol::None,
10676 overlap: Default::default(),
10677 };
10678 (spec, ready, marker)
10679 }
10680
10681 #[cfg(unix)]
10685 async fn wait_for_file(path: &std::path::Path) {
10686 let deadline = Instant::now() + Duration::from_secs(10);
10687 while !path.exists() {
10688 assert!(
10689 Instant::now() < deadline,
10690 "{} never appeared",
10691 path.display()
10692 );
10693 sleep(Duration::from_millis(10)).await;
10694 }
10695 }
10696
10697 #[cfg(unix)]
10701 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10702 async fn protocol_none_unrequested_clean_exit_restarts_as_a_crash() {
10703 let dir = cortexkit_test_support::ScratchDir::new("none-unrequested-clean-exit");
10704 let (spec, ready, marker) =
10705 protocol_none_sigterm_exits_clean_spec("none-unrequested-clean-exit", dir.path());
10706 let supervisor = Supervisor::new_for_test(
10707 Arc::new(Registry::default()),
10708 RestartPolicy::new(3, Duration::ZERO),
10709 );
10710 let module = supervisor.spawn(spec).unwrap();
10711 wait_for_file(&ready).await;
10712 let deadline = Instant::now() + Duration::from_secs(10);
10716 let first_pid = loop {
10717 let status = module.status().unwrap();
10718 if status.state == ModuleState::Running {
10719 if let Some(pid) = status.pid {
10720 break pid;
10721 }
10722 }
10723 assert!(
10724 Instant::now() < deadline,
10725 "a running module must report its pid after exec confirmation: {status:?}"
10726 );
10727 sleep(Duration::from_millis(10)).await;
10728 };
10729
10730 rustix::process::kill_process(
10731 rustix::process::Pid::from_raw(i32::try_from(first_pid).unwrap()).unwrap(),
10732 rustix::process::Signal::TERM,
10733 )
10734 .unwrap();
10735
10736 let deadline = Instant::now() + Duration::from_secs(10);
10737 let respawned = loop {
10738 let status = module.status().unwrap();
10739 if status.state == ModuleState::Running
10740 && status.pid.is_some_and(|pid| pid != first_pid)
10741 {
10742 break status;
10743 }
10744 assert!(
10745 Instant::now() < deadline,
10746 "protocol-none module was not respawned after an unrequested clean exit: {status:?}"
10747 );
10748 sleep(Duration::from_millis(10)).await;
10749 };
10750 assert_eq!(respawned.spawn_generation, 2);
10751 assert!(
10752 marker.exists(),
10753 "the child must have exited through its SIGTERM handler (exit 0), or this proves nothing about clean exits"
10754 );
10755
10756 let history = module.terminal_history();
10757 assert_eq!(history.entries.len(), 1, "{history:?}");
10758 let entry = &history.entries[0];
10759 assert_eq!(entry.exit_code, Some(0));
10760 assert_eq!(entry.exit_kind, subc_control::TerminalExitKind::Clean);
10761 assert_eq!(entry.disposition, TerminalDisposition::Restarting);
10762
10763 module.stop().await.unwrap();
10764 }
10765
10766 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10770 async fn protocol_none_repeated_clean_exits_exhaust_the_restart_budget() {
10771 let supervisor = Supervisor::new_for_test(
10772 Arc::new(Registry::default()),
10773 RestartPolicy::new(1, Duration::ZERO),
10774 );
10775 let module = supervisor
10776 .spawn(ModuleSpec {
10777 module_id: "none-clean-exit-budget".to_string(),
10778 program: fake_aft_stub_path(),
10779 args: Vec::new(),
10780 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "0".to_string())],
10781 reserved: false,
10782 reserved_prefixes: Vec::new(),
10783 protocol: ModuleProtocol::None,
10784 overlap: Default::default(),
10785 })
10786 .unwrap();
10787
10788 let deadline = Instant::now() + Duration::from_secs(10);
10791 loop {
10792 let status = module.status().unwrap();
10793 if status.state == ModuleState::Failed {
10794 break;
10795 }
10796 assert!(
10797 Instant::now() < deadline,
10798 "module never exhausted its budget: {status:?} {:?}",
10799 module.terminal_history()
10800 );
10801 sleep(Duration::from_millis(10)).await;
10802 }
10803 let history = module.terminal_history();
10804 assert_eq!(
10805 history
10806 .entries
10807 .iter()
10808 .map(|entry| (entry.exit_code, entry.disposition.clone()))
10809 .collect::<Vec<_>>(),
10810 vec![
10811 (Some(0), TerminalDisposition::Restarting),
10812 (Some(0), TerminalDisposition::Failed),
10813 ]
10814 );
10815 let detail = history.entries[1]
10816 .disposition_detail
10817 .as_deref()
10818 .expect("a budget failure names the budget");
10819 assert!(detail.contains("max_restarts=1"), "{detail}");
10820 assert_eq!(module.status().unwrap().spawn_generation, 2);
10821 }
10822
10823 #[test]
10824 fn restart_budget_failure_is_published_after_its_terminal_record() {
10825 use std::io::{Read, Write};
10826 let supervisor = Supervisor::new_for_test(
10827 Arc::new(Registry::default()),
10828 RestartPolicy::new(0, Duration::ZERO),
10829 );
10830 let runtime = supervisor.runtime_config();
10831 let spec = ModuleSpec {
10832 protocol: ModuleProtocol::None,
10833 ..windowed_crash_spec("budget-publication-order")
10834 };
10835 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(
10836 ModuleState::Running,
10837 true,
10838 )));
10839 lock_snapshot(&snapshot).unwrap().process_alive = true;
10840 let reaped_snapshot = snapshot.clone();
10841 let mut barrier = terminal_record_barrier(&runtime.spawn_events);
10842 let history = runtime.terminal_ring.clone();
10843 let reap = std::thread::spawn(move || {
10844 tokio::runtime::Builder::new_current_thread()
10845 .enable_all()
10846 .build()
10847 .unwrap()
10848 .block_on(on_child_exit(
10849 &spec,
10850 runtime.restart_policy,
10851 &supervisor.registry,
10852 &reaped_snapshot,
10853 &runtime.terminal_ring,
10854 &runtime.spawn_events,
10855 &runtime.child_roster,
10856 ExitReport {
10857 kind: ExitKind::Clean,
10858 code: Some(0),
10859 signal: None,
10860 at_ms: 1,
10861 },
10862 ))
10863 });
10864 let mut reached = [0];
10865 barrier.read_exact(&mut reached).unwrap();
10866 assert_eq!(&reached, b"R");
10867 let (before_state, before_alive) = {
10868 let state = lock_snapshot(&snapshot).unwrap();
10869 (state.state, state.process_alive)
10870 };
10871 let before_history = history.lock().unwrap().snapshot();
10872 barrier.write_all(b"X").unwrap();
10873 assert!(matches!(reap.join().unwrap(), NextAction::Stop { .. }));
10874 assert!(before_history.entries.is_empty());
10875 assert_ne!(
10876 before_state,
10877 ModuleState::Failed,
10878 "Failed was visible before its terminal record could be written"
10879 );
10880 assert!(before_alive);
10881 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
10882 let history = history.lock().unwrap().snapshot();
10883 assert_eq!(history.entries.len(), 1);
10884 assert_eq!(history.entries[0].exit_code, Some(0));
10885 assert_eq!(history.entries[0].disposition, TerminalDisposition::Failed);
10886 }
10887
10888 fn terminal_record_barrier(events: &super::SpawnEventFeed) -> std::net::TcpStream {
10889 use std::net::{TcpListener, TcpStream};
10890 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
10891 let writer = TcpStream::connect(listener.local_addr().unwrap()).unwrap();
10892 let (barrier, _) = listener.accept().unwrap();
10893 for stream in [&writer, &barrier] {
10894 stream
10895 .set_read_timeout(Some(Duration::from_secs(10)))
10896 .unwrap();
10897 stream
10898 .set_write_timeout(Some(Duration::from_secs(10)))
10899 .unwrap();
10900 }
10901 *events.1.lock().unwrap() = Some(writer);
10902 barrier
10903 }
10904
10905 pub(super) fn before_terminal_record(events: &super::SpawnEventFeed) {
10906 use std::io::{Read, Write};
10907 let barrier = events.1.lock().unwrap().take();
10908 if let Some(mut barrier) = barrier {
10909 barrier.write_all(b"R").unwrap();
10910 let mut release = [0];
10911 barrier.read_exact(&mut release).unwrap();
10912 assert_eq!(&release, b"X");
10913 }
10914 }
10915
10916 #[test]
10917 fn health_restart_budget_failure_is_published_after_its_terminal_record() {
10918 use std::io::{Read, Write};
10919
10920 let supervisor = Supervisor::new_for_test(
10921 Arc::new(Registry::default()),
10922 RestartPolicy::new(0, Duration::ZERO),
10923 );
10924 let mut runtime = supervisor.runtime_config();
10925 let module = module_with_recovery_snapshot(ModuleState::Running, true, 0);
10926 let snapshot = module.inner.snapshot.clone();
10927 runtime.terminal_ring = module.inner.terminal_ring.clone();
10928 let spec = ModuleSpec {
10929 module_id: module.module_id().into(),
10930 program: fake_aft_stub_path(),
10931 protocol: ModuleProtocol::None,
10932 env: vec![("FAKE_AFT_EXIT_CODE".into(), "17".into())],
10933 ..windowed_crash_spec(module.module_id())
10934 };
10935 let mut barrier = terminal_record_barrier(&runtime.spawn_events);
10936 let reap = std::thread::spawn(move || {
10937 tokio::runtime::Builder::new_current_thread()
10938 .enable_all()
10939 .build()
10940 .unwrap()
10941 .block_on(async {
10942 let mut child =
10943 Some(spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap());
10944 health_restart_child(
10945 &spec,
10946 &runtime,
10947 &supervisor.registry,
10948 &supervisor.process_liveness,
10949 &snapshot,
10950 &mut child,
10951 SupervisorHealthStatus::Failing,
10952 Some("health probe did not answer"),
10953 1,
10954 )
10955 .await
10956 .unwrap();
10957 assert!(child.is_none());
10958 });
10959 });
10960 let mut reached = [0];
10965 barrier.read_exact(&mut reached).unwrap();
10966 assert_eq!(&reached, b"R");
10967 let before_status = module.status().unwrap();
10968 let before_history = module.terminal_history();
10969 barrier.write_all(b"X").unwrap();
10970 reap.join().unwrap();
10971 assert!(before_history.entries.is_empty());
10972 assert_ne!(
10973 before_status.state,
10974 ModuleState::Failed,
10975 "Failed was visible before its terminal record could be written"
10976 );
10977 assert!(
10978 before_status.process_alive,
10979 "process death was visible before its terminal record could be written"
10980 );
10981 let failed = module.status().unwrap();
10982 assert_eq!(failed.state, ModuleState::Failed);
10983 assert!(!failed.process_alive);
10984 let history = module.terminal_history();
10985 assert_eq!(history.entries.len(), 1);
10986 assert_eq!(history.entries[0].disposition, TerminalDisposition::Failed);
10987 assert_eq!(
10988 history.entries[0].disposition_detail.as_deref(),
10989 Some("crash budget exhausted: max_restarts=0 within window_secs=600")
10990 );
10991 }
10992
10993 #[test]
10994 fn observed_exits_publish_process_death_after_terminal_history() {
10995 use std::io::{Read, Write};
10996 for (kind, enabled, shutdown, wait_error, expected_state) in [
10997 (ExitKind::Clean, true, false, false, ModuleState::Stopped),
10998 (ExitKind::Crash, true, false, false, ModuleState::Restarting),
10999 (ExitKind::Crash, false, false, false, ModuleState::Disabled),
11000 (
11001 ExitKind::DeliberateSeverance,
11002 true,
11003 false,
11004 false,
11005 ModuleState::Restarting,
11006 ),
11007 #[cfg(unix)]
11008 (ExitKind::Crash, true, true, false, ModuleState::Stopped),
11009 (ExitKind::Crash, true, false, true, ModuleState::Failed),
11010 ] {
11011 let supervisor = Supervisor::new_for_test(
11012 Arc::new(Registry::default()),
11013 RestartPolicy::new(1, Duration::ZERO),
11014 );
11015 let runtime = supervisor.runtime_config();
11016 #[cfg(unix)]
11017 if shutdown {
11018 runtime.child_roster.close();
11019 }
11020 #[cfg(not(unix))]
11021 let _ = shutdown;
11022 let spec = windowed_crash_spec("exit-publication-order");
11023 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(
11024 ModuleState::Running,
11025 enabled,
11026 )));
11027 lock_snapshot(&snapshot).unwrap().process_alive = true;
11028 let reaped_snapshot = snapshot.clone();
11029 let history = runtime.terminal_ring.clone();
11030 let mut barrier = terminal_record_barrier(&runtime.spawn_events);
11031 let reap = std::thread::spawn(move || {
11032 if wait_error {
11033 super::fail_after_wait_error(
11034 &spec.module_id,
11035 &reaped_snapshot,
11036 &runtime.terminal_ring,
11037 &runtime.spawn_events,
11038 "wait failed".into(),
11039 );
11040 } else {
11041 tokio::runtime::Builder::new_current_thread()
11042 .enable_all()
11043 .build()
11044 .unwrap()
11045 .block_on(on_child_exit(
11046 &spec,
11047 runtime.restart_policy,
11048 &supervisor.registry,
11049 &reaped_snapshot,
11050 &runtime.terminal_ring,
11051 &runtime.spawn_events,
11052 &runtime.child_roster,
11053 ExitReport {
11054 kind,
11055 code: Some(0),
11056 signal: None,
11057 at_ms: 1,
11058 },
11059 ));
11060 }
11061 });
11062 let mut reached = [0];
11063 barrier.read_exact(&mut reached).unwrap();
11064 assert_eq!(&reached, b"R");
11065 let before_alive = lock_snapshot(&snapshot).unwrap().process_alive;
11066 let before_history = history.lock().unwrap().snapshot();
11067 barrier.write_all(b"X").unwrap();
11068 reap.join().unwrap();
11069 assert!(
11070 before_alive,
11071 "{kind:?}: death was published before recording"
11072 );
11073 assert!(before_history.entries.is_empty());
11074 assert_eq!(lock_snapshot(&snapshot).unwrap().state, expected_state);
11075 assert!(!lock_snapshot(&snapshot).unwrap().process_alive);
11076 assert_eq!(history.lock().unwrap().snapshot().entries.len(), 1);
11077 }
11078 }
11079
11080 #[tokio::test]
11081 async fn health_restart_budget_failure_remains_failed_when_journal_append_fails() {
11082 let dir = cortexkit_test_support::ScratchDir::new("health-budget-journal-failure");
11083 let path = dir.join("terminals.jsonl");
11084 std::fs::create_dir(&path).unwrap();
11085 let supervisor = Supervisor::new_for_test(
11086 Arc::new(Registry::default()),
11087 RestartPolicy::new(0, Duration::ZERO),
11088 )
11089 .with_terminal_journal(path, "health-budget-journal-failure".into());
11090 let runtime = supervisor.runtime_config();
11091 let spec = ModuleSpec {
11092 program: fake_aft_stub_path(),
11093 protocol: ModuleProtocol::None,
11094 env: vec![("FAKE_AFT_EXIT_CODE".into(), "17".into())],
11095 ..windowed_crash_spec("health-budget-journal-failure")
11096 };
11097 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11098 let mut child = Some(spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap());
11099 health_restart_child(
11100 &spec,
11101 &runtime,
11102 &supervisor.registry,
11103 &supervisor.process_liveness,
11104 &snapshot,
11105 &mut child,
11106 SupervisorHealthStatus::Failing,
11107 None,
11108 1,
11109 )
11110 .await
11111 .unwrap();
11112 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
11113 assert!(!lock_snapshot(&snapshot).unwrap().process_alive);
11114 let history = runtime
11115 .terminal_ring
11116 .lock()
11117 .unwrap()
11118 .durable_history(&spec.module_id);
11119 assert!(history.journal_write_failures > 0);
11120 assert_eq!(history.entries.len(), 1);
11121 assert_eq!(history.entries[0].disposition, TerminalDisposition::Failed);
11122 }
11123
11124 #[test]
11125 fn health_restart_budget_failure_remains_failed_when_exit_recording_panics() {
11126 let supervisor = Supervisor::new_for_test(
11127 Arc::new(Registry::default()),
11128 RestartPolicy::new(0, Duration::ZERO),
11129 );
11130 let runtime = supervisor.runtime_config();
11131 let spec = ModuleSpec {
11132 program: fake_aft_stub_path(),
11133 protocol: ModuleProtocol::None,
11134 env: vec![("FAKE_AFT_EXIT_CODE".into(), "17".into())],
11135 ..windowed_crash_spec("health-budget-recording-panic")
11136 };
11137 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11138 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
11139 tokio::runtime::Builder::new_current_thread()
11140 .enable_all()
11141 .build()
11142 .unwrap()
11143 .block_on(async {
11144 let mut child =
11145 Some(spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap());
11146 runtime.spawn_events.0.lock().unwrap().seq = u64::MAX;
11147 health_restart_child(
11148 &spec,
11149 &runtime,
11150 &supervisor.registry,
11151 &supervisor.process_liveness,
11152 &snapshot,
11153 &mut child,
11154 SupervisorHealthStatus::Failing,
11155 None,
11156 1,
11157 )
11158 .await
11159 .unwrap();
11160 });
11161 }));
11162 let panic = result.expect_err("recording must still unwind");
11163 assert_eq!(
11164 panic.downcast_ref::<String>().map(String::as_str),
11165 Some("spawn event sequence exhausted")
11166 );
11167 let state = lock_snapshot(&snapshot).unwrap();
11168 assert_eq!(state.state, ModuleState::Failed);
11169 assert!(!state.process_alive);
11170 assert!(state.last_exit.is_some());
11171 }
11172
11173 #[tokio::test]
11174 async fn restart_budget_failure_remains_failed_when_journal_append_fails() {
11175 let dir = cortexkit_test_support::ScratchDir::new("budget-journal-failure");
11176 let path = dir.join("terminals.jsonl");
11177 std::fs::create_dir(&path).unwrap();
11178 let supervisor = Supervisor::new_for_test(
11179 Arc::new(Registry::default()),
11180 RestartPolicy::new(0, Duration::ZERO),
11181 )
11182 .with_terminal_journal(path, "budget-journal-failure".into());
11183 let runtime = supervisor.runtime_config();
11184 let spec = windowed_crash_spec("budget-journal-failure");
11185 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11186 assert!(matches!(
11187 on_child_exit(
11188 &spec,
11189 runtime.restart_policy,
11190 &supervisor.registry,
11191 &snapshot,
11192 &runtime.terminal_ring,
11193 &runtime.spawn_events,
11194 &runtime.child_roster,
11195 crash_exit_report(1),
11196 )
11197 .await,
11198 NextAction::Stop { .. }
11199 ));
11200 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
11201 let history = runtime
11202 .terminal_ring
11203 .lock()
11204 .unwrap()
11205 .durable_history(&spec.module_id);
11206 assert!(history.journal_write_failures > 0);
11207 assert_eq!(history.entries.len(), 1);
11208 assert_eq!(history.entries[0].disposition, TerminalDisposition::Failed);
11209 }
11210
11211 #[test]
11212 fn restart_budget_failure_remains_failed_when_exit_recording_panics() {
11213 let supervisor = Supervisor::new_for_test(
11214 Arc::new(Registry::default()),
11215 RestartPolicy::new(0, Duration::ZERO),
11216 );
11217 let runtime = supervisor.runtime_config();
11218 let spec = windowed_crash_spec("budget-recording-panic");
11219 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11220 runtime.spawn_events.emit_spawned(&spec.module_id, 1, 1);
11221 runtime.spawn_events.0.lock().unwrap().seq = u64::MAX;
11224 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
11225 tokio::runtime::Builder::new_current_thread()
11226 .enable_all()
11227 .build()
11228 .unwrap()
11229 .block_on(on_child_exit(
11230 &spec,
11231 runtime.restart_policy,
11232 &supervisor.registry,
11233 &snapshot,
11234 &runtime.terminal_ring,
11235 &runtime.spawn_events,
11236 &runtime.child_roster,
11237 crash_exit_report(1),
11238 ))
11239 }));
11240 let panic = result.err().expect("recording must still unwind");
11241 assert_eq!(
11242 panic.downcast_ref::<String>().map(String::as_str),
11243 Some("spawn event sequence exhausted")
11244 );
11245 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
11246 }
11247
11248 #[cfg(unix)]
11251 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11252 async fn protocol_none_requested_stop_and_disable_do_not_respawn() {
11253 for disable in [false, true] {
11254 let label = if disable {
11255 "none-requested-disable"
11256 } else {
11257 "none-requested-stop"
11258 };
11259 let dir = cortexkit_test_support::ScratchDir::new(label);
11260 let (spec, ready, marker) = protocol_none_sigterm_exits_clean_spec(label, dir.path());
11261 let supervisor = Supervisor::new_for_test(
11262 Arc::new(Registry::default()),
11263 RestartPolicy::new(3, Duration::ZERO),
11264 );
11265 let module = supervisor.spawn(spec).unwrap();
11266 wait_for_file(&ready).await;
11267
11268 if disable {
11269 module.set_enabled(false).await.unwrap();
11270 } else {
11271 module.stop().await.unwrap();
11272 }
11273 assert!(
11274 marker.exists(),
11275 "{label}: the child must have left through its SIGTERM handler with exit 0"
11276 );
11277
11278 sleep(Duration::from_millis(500)).await;
11281 let status = module.status().unwrap();
11282 let expected = if disable {
11283 ModuleState::Disabled
11284 } else {
11285 ModuleState::Stopped
11286 };
11287 assert_eq!(status.state, expected, "{label}");
11288 assert_eq!(
11289 status.spawn_generation, 1,
11290 "{label}: respawned after a requested stop"
11291 );
11292 let history = module.terminal_history();
11293 assert_eq!(history.entries.len(), 1, "{label}: {history:?}");
11294 assert_eq!(history.entries[0].exit_code, Some(0), "{label}");
11295 assert_ne!(
11296 history.entries[0].disposition,
11297 TerminalDisposition::Restarting,
11298 "{label}"
11299 );
11300 }
11301 }
11302
11303 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11306 async fn subc_wire_clean_exit_is_still_a_stop() {
11307 let supervisor = Supervisor::new_for_test(
11308 Arc::new(Registry::default()),
11309 RestartPolicy::new(3, Duration::ZERO),
11310 );
11311 let module = supervisor
11312 .spawn(ModuleSpec {
11313 module_id: "wire-clean-exit".to_string(),
11314 program: fake_aft_stub_path(),
11315 args: Vec::new(),
11316 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "0".to_string())],
11317 reserved: false,
11318 reserved_prefixes: Vec::new(),
11319 protocol: ModuleProtocol::Subc,
11320 overlap: Default::default(),
11321 })
11322 .unwrap();
11323
11324 let deadline = Instant::now() + Duration::from_secs(10);
11325 while module.terminal_history().entries.is_empty() {
11326 assert!(Instant::now() < deadline, "module never exited");
11327 sleep(Duration::from_millis(10)).await;
11328 }
11329 sleep(Duration::from_millis(500)).await;
11331 let status = module.status().unwrap();
11332 assert_eq!(status.state, ModuleState::Stopped);
11333 assert_eq!(status.spawn_generation, 1);
11334 let history = module.terminal_history();
11335 assert_eq!(history.entries.len(), 1, "{history:?}");
11336 assert_eq!(history.entries[0].exit_code, Some(0));
11337 assert_eq!(history.entries[0].disposition, TerminalDisposition::Stopped);
11338 }
11339
11340 #[cfg(unix)]
11341 #[tokio::test]
11342 async fn reload_exit_keeps_roster_until_terminal_is_recorded_during_shutdown() {
11343 let dir = cortexkit_test_support::ScratchDir::new("reload-roster-terminal-order");
11344 let record = dir.join("live-children.json");
11345 let supervisor = Supervisor::new_for_test(
11346 Arc::new(Registry::default()),
11347 RestartPolicy::new(0, Duration::ZERO),
11348 );
11349 let mut runtime = supervisor.runtime_config();
11350 runtime.child_roster.record_to(record.clone());
11351 let gate = Arc::new(super::ReloadExitRecordGate::default());
11352 runtime.test_reload_exit_record_gate = Some(Arc::clone(&gate));
11353 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11354 let spec = ModuleSpec {
11355 module_id: "reload-exit-roster".into(),
11356 program: fake_aft_stub_path(),
11357 args: Vec::new(),
11358 env: vec![("FAKE_AFT_EXIT_CODE".into(), "23".into())],
11359 reserved: false,
11360 reserved_prefixes: Vec::new(),
11361 protocol: ModuleProtocol::Subc,
11362 overlap: Default::default(),
11363 };
11364 let mut child = None;
11365 let reload = super::finish_reload_child(
11366 &spec,
11367 &runtime,
11368 &supervisor.registry,
11369 &supervisor.process_liveness,
11370 &snapshot,
11371 &mut child,
11372 );
11373 tokio::pin!(reload);
11374 tokio::select! {
11375 result = &mut reload => panic!("reload missed the before-record gate: {result:?}"),
11376 _ = gate.reached.notified() => {}
11377 }
11378 assert!(runtime
11379 .terminal_ring
11380 .lock()
11381 .unwrap()
11382 .snapshot()
11383 .entries
11384 .is_empty());
11385 assert_eq!(
11386 crate::live_children::read_record(&record).unwrap().len(),
11387 1,
11388 "shutdown must still wait for the reaped child until its terminal record exists"
11389 );
11390 runtime.child_roster.close();
11391 gate.resume.notify_one();
11392 assert!(reload.await.is_err());
11393 assert!(crate::live_children::read_record(&record)
11394 .unwrap()
11395 .is_empty());
11396 let history = runtime.terminal_ring.lock().unwrap().snapshot();
11397 assert_eq!(history.entries.len(), 1);
11398 assert_eq!(
11399 history.entries[0].disposition,
11400 TerminalDisposition::DaemonShutdown
11401 );
11402 }
11403
11404 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11408 async fn every_restart_increment_path_advances_lifetime_count() {
11409 let supervisor = Supervisor::new_for_test(
11410 Arc::new(Registry::default()),
11411 RestartPolicy::new(1, Duration::ZERO),
11412 );
11413 let runtime = supervisor.runtime_config();
11414 let spec = ModuleSpec {
11415 module_id: "lifetime-increment-path".to_string(),
11416 program: PathBuf::from("/unused/lifetime-increment-path"),
11417 args: Vec::new(),
11418 env: Vec::new(),
11419 reserved: false,
11420 reserved_prefixes: Vec::new(),
11421 protocol: ModuleProtocol::Subc,
11422 overlap: Default::default(),
11423 };
11424
11425 let crash_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11426 assert!(matches!(
11427 on_child_exit(
11428 &spec,
11429 runtime.restart_policy,
11430 &supervisor.registry,
11431 &crash_snapshot,
11432 &runtime.terminal_ring,
11433 &runtime.spawn_events,
11434 &runtime.child_roster,
11435 ExitReport {
11436 kind: ExitKind::Crash,
11437 code: Some(1),
11438 signal: None,
11439 at_ms: 1,
11440 },
11441 )
11442 .await,
11443 NextAction::Restart { schedule: _ }
11444 ));
11445 let (crash_restarts, crash_lifetime) = {
11446 let state = lock_snapshot(&crash_snapshot).unwrap();
11447 (state.crash_restarts.len(), state.lifetime_restarts)
11448 };
11449 assert_eq!(crash_restarts, 1);
11450 assert_eq!(crash_lifetime, 1);
11451
11452 let health_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11453 let mut health_child = None;
11454 assert!(matches!(
11455 health_restart_child(
11456 &spec,
11457 &runtime,
11458 &supervisor.registry,
11459 &supervisor.process_liveness,
11460 &health_snapshot,
11461 &mut health_child,
11462 SupervisorHealthStatus::Failing,
11463 None,
11464 2,
11465 )
11466 .await,
11467 Ok(())
11468 ));
11469 assert!(health_child.is_none());
11470 assert!(lock_snapshot(&health_snapshot).unwrap().respawn_pending);
11471 assert!(runtime.scheduled_respawn.lock().unwrap().take().is_some());
11472 let (health_restarts, health_lifetime) = {
11473 let state = lock_snapshot(&health_snapshot).unwrap();
11474 (state.crash_restarts.len(), state.lifetime_restarts)
11475 };
11476 assert_eq!(health_restarts, 1);
11477 assert_eq!(health_lifetime, 1);
11478
11479 let reload_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11480 let mut reload_child = None;
11481 assert!(matches!(
11482 handle_reload_spawn_failure(
11483 &spec,
11484 &runtime,
11485 &supervisor.process_liveness,
11486 &reload_snapshot,
11487 &mut reload_child,
11488 "forced reload spawn failure".to_string(),
11489 )
11490 .await,
11491 Err(SuperviseError::ReloadFailed { .. })
11492 ));
11493 let (reload_restarts, reload_lifetime) = {
11494 let state = lock_snapshot(&reload_snapshot).unwrap();
11495 (state.crash_restarts.len(), state.lifetime_restarts)
11496 };
11497 assert_eq!(reload_restarts, 1);
11498 assert_eq!(reload_lifetime, 1);
11499 }
11500
11501 #[tokio::test]
11502 async fn deliberately_severed_live_child_records_lifetime_without_spending_restart_budget() {
11503 let supervisor = Supervisor::new_for_test(
11504 Arc::new(Registry::default()),
11505 RestartPolicy::new(3, Duration::ZERO),
11506 );
11507 let runtime = supervisor.runtime_config();
11508 let spec = ModuleSpec {
11509 module_id: "deliberately-severed".to_string(),
11510 program: PathBuf::from("/unused/deliberately-severed"),
11511 args: Vec::new(),
11512 env: Vec::new(),
11513 reserved: false,
11514 reserved_prefixes: Vec::new(),
11515 protocol: ModuleProtocol::Subc,
11516 overlap: Default::default(),
11517 };
11518 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11519 let process = ProcessIdentity {
11520 pid: 41,
11521 start_time: 101,
11522 };
11523 record_deliberate_severance(&snapshot, process).unwrap();
11524 let exit_report = apply_deliberate_severance_marker(
11525 &snapshot,
11526 Some(process),
11527 ExitReport {
11528 kind: ExitKind::Crash,
11529 code: Some(1),
11530 signal: None,
11531 at_ms: 1,
11532 },
11533 );
11534 assert_eq!(exit_report.kind, ExitKind::DeliberateSeverance);
11535
11536 assert!(matches!(
11537 on_child_exit(
11538 &spec,
11539 runtime.restart_policy,
11540 &supervisor.registry,
11541 &snapshot,
11542 &runtime.terminal_ring,
11543 &runtime.spawn_events,
11544 &runtime.child_roster,
11545 exit_report,
11546 )
11547 .await,
11548 NextAction::Restart { schedule: _ }
11549 ));
11550 let state = lock_snapshot(&snapshot).unwrap();
11551 assert_eq!(state.lifetime_restarts, 1);
11552 assert_eq!(state.crash_restarts.len(), 0);
11553 }
11554
11555 #[tokio::test]
11556 async fn genuine_crash_spends_restart_budget_and_records_lifetime() {
11557 let supervisor = Supervisor::new_for_test(
11558 Arc::new(Registry::default()),
11559 RestartPolicy::new(3, Duration::ZERO),
11560 );
11561 let runtime = supervisor.runtime_config();
11562 let spec = ModuleSpec {
11563 module_id: "genuine-crash".to_string(),
11564 program: PathBuf::from("/unused/genuine-crash"),
11565 args: Vec::new(),
11566 env: Vec::new(),
11567 reserved: false,
11568 reserved_prefixes: Vec::new(),
11569 protocol: ModuleProtocol::Subc,
11570 overlap: Default::default(),
11571 };
11572 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11573
11574 assert!(matches!(
11575 on_child_exit(
11576 &spec,
11577 runtime.restart_policy,
11578 &supervisor.registry,
11579 &snapshot,
11580 &runtime.terminal_ring,
11581 &runtime.spawn_events,
11582 &runtime.child_roster,
11583 ExitReport {
11584 kind: ExitKind::Crash,
11585 code: Some(1),
11586 signal: None,
11587 at_ms: 1,
11588 },
11589 )
11590 .await,
11591 NextAction::Restart { schedule: _ }
11592 ));
11593 let state = lock_snapshot(&snapshot).unwrap();
11594 assert_eq!(state.lifetime_restarts, 1);
11595 assert_eq!(state.crash_restarts.len(), 1);
11596 }
11597
11598 fn crash_exit_report(at_ms: u64) -> ExitReport {
11599 ExitReport {
11600 kind: ExitKind::Crash,
11601 code: Some(1),
11602 signal: None,
11603 at_ms,
11604 }
11605 }
11606
11607 fn windowed_crash_spec(module_id: &str) -> ModuleSpec {
11608 ModuleSpec {
11609 module_id: module_id.to_string(),
11610 program: PathBuf::from("/unused").join(module_id),
11611 args: Vec::new(),
11612 env: Vec::new(),
11613 reserved: false,
11614 reserved_prefixes: Vec::new(),
11615 protocol: ModuleProtocol::Subc,
11616 overlap: Default::default(),
11617 }
11618 }
11619
11620 #[tokio::test]
11626 async fn three_crashes_inside_the_window_stop_the_module_and_name_the_window() {
11627 let (logs, _guard) = crate::router::test_log::log_capture(tracing::Level::ERROR);
11628 let supervisor = Supervisor::new_for_test(
11629 Arc::new(Registry::default()),
11630 RestartPolicy::new(2, Duration::ZERO),
11631 );
11632 let runtime = supervisor.runtime_config();
11633 let spec = windowed_crash_spec("crash-loop-in-window");
11634 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11635
11636 for attempt in 1..=2 {
11637 assert!(
11638 matches!(
11639 on_child_exit(
11640 &spec,
11641 runtime.restart_policy,
11642 &supervisor.registry,
11643 &snapshot,
11644 &runtime.terminal_ring,
11645 &runtime.spawn_events,
11646 &runtime.child_roster,
11647 crash_exit_report(attempt),
11648 )
11649 .await,
11650 NextAction::Restart { schedule: _ }
11651 ),
11652 "crash {attempt} is inside the budget and must respawn"
11653 );
11654 }
11655
11656 assert!(matches!(
11657 on_child_exit(
11658 &spec,
11659 runtime.restart_policy,
11660 &supervisor.registry,
11661 &snapshot,
11662 &runtime.terminal_ring,
11663 &runtime.spawn_events,
11664 &runtime.child_roster,
11665 crash_exit_report(3),
11666 )
11667 .await,
11668 NextAction::Stop { .. }
11669 ));
11670
11671 {
11672 let state = lock_snapshot(&snapshot).unwrap();
11673 assert_eq!(state.state, ModuleState::Failed);
11674 assert_eq!(state.crash_restarts.len(), 2);
11675 assert_eq!(state.lifetime_restarts, 2);
11676 }
11677
11678 let history = runtime
11679 .terminal_ring
11680 .lock()
11681 .expect("terminal ring is not poisoned")
11682 .snapshot();
11683 let last = history
11684 .entries
11685 .last()
11686 .expect("the refused crash is retained");
11687 assert_eq!(last.disposition, TerminalDisposition::Failed);
11688 assert_eq!(
11689 last.disposition_detail.as_deref(),
11690 Some("crash budget exhausted: max_restarts=2 within window_secs=600")
11691 );
11692
11693 let captured = crate::router::test_log::captured_logs(&logs);
11694 assert!(
11695 captured.contains("crash budget exhausted: max_restarts=2 within window_secs=600"),
11696 "the stop must be logged with its window: {captured}"
11697 );
11698 }
11699
11700 #[tokio::test]
11708 async fn a_crash_older_than_the_window_frees_its_slot_for_a_later_crash() {
11709 let supervisor = Supervisor::new_for_test(
11710 Arc::new(Registry::default()),
11711 RestartPolicy::new(2, Duration::ZERO),
11712 );
11713 let runtime = supervisor.runtime_config();
11714 let spec = windowed_crash_spec("crash-across-windows");
11715 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11716
11717 for attempt in 1..=2 {
11718 assert!(matches!(
11719 on_child_exit(
11720 &spec,
11721 runtime.restart_policy,
11722 &supervisor.registry,
11723 &snapshot,
11724 &runtime.terminal_ring,
11725 &runtime.spawn_events,
11726 &runtime.child_roster,
11727 crash_exit_report(attempt),
11728 )
11729 .await,
11730 NextAction::Restart { schedule: _ }
11731 ));
11732 }
11733
11734 update_snapshot(&snapshot, Some(&spec.module_id), |state| {
11737 age_oldest_crash_restart_out_of_window(state, runtime.restart_policy.window);
11738 })
11739 .unwrap();
11740
11741 assert!(
11742 matches!(
11743 on_child_exit(
11744 &spec,
11745 runtime.restart_policy,
11746 &supervisor.registry,
11747 &snapshot,
11748 &runtime.terminal_ring,
11749 &runtime.spawn_events,
11750 &runtime.child_roster,
11751 crash_exit_report(3),
11752 )
11753 .await,
11754 NextAction::Restart { schedule: _ }
11755 ),
11756 "a crash older than the window must not hold a budget slot"
11757 );
11758
11759 let state = lock_snapshot(&snapshot).unwrap();
11760 assert_eq!(state.state, ModuleState::Restarting);
11761 assert_eq!(
11762 state.crash_restarts.len(),
11763 2,
11764 "the aged instant is dropped and the new one takes its place"
11765 );
11766 assert_eq!(
11767 state.lifetime_restarts, 3,
11768 "the ledger counts every restart, including the ones the window forgot"
11769 );
11770 }
11771
11772 #[tokio::test]
11777 async fn an_operator_restart_clears_the_ring_and_leaves_the_ledger_alone() {
11778 let supervisor = Supervisor::new_for_test(
11779 Arc::new(Registry::default()),
11780 RestartPolicy::new(2, Duration::ZERO),
11781 );
11782 let runtime = supervisor.runtime_config();
11783 let spec = windowed_crash_spec("operator-cleared-budget");
11784 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11785
11786 for attempt in 1..=2 {
11787 assert!(matches!(
11788 on_child_exit(
11789 &spec,
11790 runtime.restart_policy,
11791 &supervisor.registry,
11792 &snapshot,
11793 &runtime.terminal_ring,
11794 &runtime.spawn_events,
11795 &runtime.child_roster,
11796 crash_exit_report(attempt),
11797 )
11798 .await,
11799 NextAction::Restart { schedule: _ }
11800 ));
11801 }
11802
11803 reset_restart_count(&snapshot, &spec.module_id).unwrap();
11804 {
11805 let state = lock_snapshot(&snapshot).unwrap();
11806 assert!(
11807 state.crash_restarts.is_empty(),
11808 "an operator restart returns the full budget"
11809 );
11810 assert_eq!(
11811 state.lifetime_restarts, 2,
11812 "clearing the budget must not unmake the crashes"
11813 );
11814 }
11815
11816 assert!(
11817 matches!(
11818 on_child_exit(
11819 &spec,
11820 runtime.restart_policy,
11821 &supervisor.registry,
11822 &snapshot,
11823 &runtime.terminal_ring,
11824 &runtime.spawn_events,
11825 &runtime.child_roster,
11826 crash_exit_report(3),
11827 )
11828 .await,
11829 NextAction::Restart { schedule: _ }
11830 ),
11831 "the cleared budget must be spendable again"
11832 );
11833 let state = lock_snapshot(&snapshot).unwrap();
11834 assert_eq!(state.crash_restarts.len(), 1);
11835 assert_eq!(state.lifetime_restarts, 3);
11836 }
11837
11838 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11839 async fn severance_marker_for_a_dead_child_does_not_label_its_successor() {
11840 let severed = ProcessIdentity {
11841 pid: 41,
11842 start_time: 101,
11843 };
11844 let successor = ProcessIdentity {
11845 pid: 41,
11846 start_time: 202,
11847 };
11848 let module = module_with_recovery_snapshot(ModuleState::Running, true, 0);
11849 update_snapshot(&module.inner.snapshot, Some("recovery-snapshot"), |state| {
11850 state.pid = Some(successor.pid);
11851 state.process_start_time = Some(successor.start_time);
11852 })
11853 .unwrap();
11854 assert!(!module.record_deliberate_severance(severed).unwrap());
11855
11856 let exit_report = apply_deliberate_severance_marker(
11857 &module.inner.snapshot,
11858 Some(successor),
11859 ExitReport {
11860 kind: ExitKind::Crash,
11861 code: Some(1),
11862 signal: None,
11863 at_ms: 1,
11864 },
11865 );
11866
11867 assert_eq!(exit_report.kind, ExitKind::Crash);
11868 }
11869
11870 #[tokio::test]
11871 async fn drain_reap_marks_deliberate_severance_and_records_lifetime_without_budget() {
11872 let registry = Registry::default();
11873 let supervisor = Supervisor::new_for_test(
11874 Arc::new(Registry::default()),
11875 RestartPolicy::new(3, Duration::ZERO),
11876 );
11877 let runtime = supervisor.runtime_config();
11878 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11879 let spec = ModuleSpec {
11880 module_id: "drain-deliberate-severance".to_string(),
11881 program: fake_aft_stub_path(),
11882 args: Vec::new(),
11883 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
11884 reserved: false,
11885 reserved_prefixes: Vec::new(),
11886 protocol: ModuleProtocol::Subc,
11887 overlap: Default::default(),
11888 };
11889 let mut child = spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap();
11890 let process = ProcessIdentity {
11891 pid: 41,
11892 start_time: 101,
11893 };
11894 child.process_identity = Some(process);
11895 update_snapshot(&snapshot, Some(&spec.module_id), |state| {
11896 state.pid = Some(process.pid);
11897 state.process_start_time = Some(process.start_time);
11898 })
11899 .unwrap();
11900 record_deliberate_severance(&snapshot, process).unwrap();
11901
11902 drain_child_to_state(
11903 &spec.module_id,
11904 spec.protocol,
11905 StopNotice::SentOverConnection,
11908 ®istry,
11909 None,
11910 &snapshot,
11911 &runtime.terminal_ring,
11912 &runtime.spawn_events,
11913 child,
11914 Duration::from_secs(1),
11915 ModuleState::Stopped,
11916 Some(false),
11917 )
11918 .await
11919 .unwrap();
11920
11921 let state = lock_snapshot(&snapshot).unwrap();
11922 assert_eq!(
11923 state.last_exit.as_ref().map(|exit| exit.kind),
11924 Some(ExitKind::DeliberateSeverance)
11925 );
11926 assert_eq!(state.lifetime_restarts, 1);
11927 assert_eq!(state.crash_restarts.len(), 0);
11928 drop(state);
11929 let history = runtime.terminal_ring.lock().unwrap().snapshot();
11930 assert_eq!(
11931 history.entries[0].exit_kind,
11932 subc_control::TerminalExitKind::DeliberateSeverance
11933 );
11934 }
11935
11936 #[tokio::test]
11937 async fn ordinary_drain_reap_does_not_record_a_lifetime_restart() {
11938 let registry = Registry::default();
11939 let supervisor = Supervisor::new_for_test(
11940 Arc::new(Registry::default()),
11941 RestartPolicy::new(3, Duration::ZERO),
11942 );
11943 let runtime = supervisor.runtime_config();
11944 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
11945 let spec = ModuleSpec {
11946 module_id: "ordinary-drain".to_string(),
11947 program: fake_aft_stub_path(),
11948 args: Vec::new(),
11949 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
11950 reserved: false,
11951 reserved_prefixes: Vec::new(),
11952 protocol: ModuleProtocol::Subc,
11953 overlap: Default::default(),
11954 };
11955 let child = spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap();
11956
11957 drain_child_to_state(
11958 &spec.module_id,
11959 spec.protocol,
11960 StopNotice::SentOverConnection,
11963 ®istry,
11964 None,
11965 &snapshot,
11966 &runtime.terminal_ring,
11967 &runtime.spawn_events,
11968 child,
11969 Duration::from_secs(1),
11970 ModuleState::Stopped,
11971 Some(false),
11972 )
11973 .await
11974 .unwrap();
11975
11976 let state = lock_snapshot(&snapshot).unwrap();
11977 assert_eq!(
11978 state.last_exit.as_ref().map(|exit| exit.kind),
11979 Some(ExitKind::Crash)
11980 );
11981 assert_eq!(state.lifetime_restarts, 0);
11982 assert_eq!(state.crash_restarts.len(), 0);
11983 }
11984
11985 #[test]
11986 fn fatal_connection_teardown_cannot_arm_a_marker_for_a_surviving_process() {
11987 assert!(!include_str!("server.rs")
11993 .contains("router.record_deliberate_connection_severance(ctx.connection_id)"));
11994 }
11995
11996 #[test]
12003 fn drained_after_quiescence_wait_passes_ok_through_and_forces_false_on_err() {
12004 assert!(drained_after_quiescence_wait(&Ok(true)));
12005 assert!(!drained_after_quiescence_wait(&Ok(false)));
12006 assert!(!drained_after_quiescence_wait(&Err(
12007 SuperviseError::StatePoisoned { module_id: None }
12008 )));
12009 }
12010
12011 #[test]
12020 fn wait_error_exit_report_records_a_failed_terminal_with_no_code_or_signal() {
12021 let ring = Arc::new(Mutex::new(TerminalRing::new(
12022 TerminalRingConfig::default(),
12023 0,
12024 )));
12025 record_wait_error_terminal("wait-error", &ring, &super::SpawnEventFeed::default());
12026
12027 let snapshot = ring.lock().unwrap().snapshot();
12028 assert_eq!(snapshot.entries.len(), 1);
12029 let entry = &snapshot.entries[0];
12030 assert_eq!(entry.exit_code, None);
12031 assert_eq!(entry.exit_signal, None);
12032 assert_eq!(entry.disposition, TerminalDisposition::Failed);
12033 }
12034
12035 #[test]
12036 fn wait_error_exit_path_preserves_spawn_event_density() {
12037 let feed = super::SpawnEventFeed::default();
12038 feed.configure_incarnation("wait-error-density".to_string());
12039 feed.emit_spawned("wait-error", 41, 1);
12040 let ring = Arc::new(Mutex::new(TerminalRing::new(
12041 TerminalRingConfig::default(),
12042 0,
12043 )));
12044
12045 record_wait_error_terminal("wait-error", &ring, &feed);
12046 feed.emit_spawned("after-wait-error", 42, 2);
12047
12048 let state = feed.0.lock().unwrap();
12049 let sequences = state
12050 .events
12051 .iter()
12052 .map(|event| event.cursor.seq)
12053 .collect::<Vec<_>>();
12054 assert_eq!(sequences, vec![1, 2, 3]);
12055 assert_eq!(state.events[1].kind, SpawnEventKind::Exited);
12056 assert_eq!(state.events[1].exit_code, None);
12057 assert_eq!(state.events[1].exit_signal, None);
12058 }
12059
12060 #[test]
12064 fn wait_error_exit_report_is_classified_as_a_crash() {
12065 assert_eq!(wait_error_exit_report().kind, ExitKind::Crash);
12066 }
12067}
12068
12069#[cfg(all(test, unix))]
12070mod health_event_tests {
12071 use super::*;
12072 use cortexkit_test_support::ScratchDir;
12073 use subc_protocol::{manifest::Concurrency, session::ModuleControlResponse};
12074
12075 struct Actor {
12076 module: SupervisedModule,
12077 registry: Arc<Registry>,
12078 forwarding: Arc<ForwardingTable>,
12079 spec: ModuleSpec,
12080 health: HealthConfig,
12081 rx: mpsc::Receiver<crate::router::OutboundFrame>,
12082 _home: ScratchDir,
12083 }
12084
12085 async fn wait_for<T>(reason: &str, mut observe: impl FnMut() -> Option<T>) -> T {
12086 let deadline = std::time::Instant::now() + Duration::from_secs(10);
12090 let tick = Instant::now();
12091 loop {
12092 if let Some(value) = observe() {
12093 assert_eq!(Instant::now(), tick, "{reason} must not wait for a timer");
12094 return value;
12095 }
12096 assert!(
12097 std::time::Instant::now() < deadline,
12098 "timed out waiting for {reason}"
12099 );
12100 tokio::task::yield_now().await;
12101 }
12102 }
12103
12104 impl Actor {
12105 async fn start(protocol: ModuleProtocol, cadence: Duration) -> Self {
12106 let home = ScratchDir::new("health-events");
12107 let registry = Arc::new(Registry::default());
12108 let forwarding = Arc::new(ForwardingTable::default());
12109 let health = HealthConfig {
12110 cadence,
12111 deadline: Duration::from_secs(3600),
12112 ..HealthConfig::default()
12113 };
12114 let supervisor =
12115 Supervisor::new_for_test(registry.clone(), RestartPolicy::new(3, Duration::ZERO))
12116 .with_forwarding(forwarding.clone())
12117 .with_health_config(health.clone());
12118 let spec = ModuleSpec {
12119 module_id: "event-health-child".into(),
12120 program: PathBuf::from("/bin/sh"),
12121 args: vec!["-c".into(), "exec sleep 600".into()],
12124 env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
12125 .into_iter()
12126 .map(|key| (key.into(), home.path().display().to_string()))
12127 .collect(),
12128 reserved: false,
12129 reserved_prefixes: vec![],
12130 protocol,
12131 overlap: Default::default(),
12132 };
12133 let module = supervisor.spawn(spec.clone()).unwrap();
12134 let (_, rx) = mpsc::channel(8);
12135 let actor = Self {
12136 module,
12137 registry,
12138 forwarding,
12139 spec,
12140 health,
12141 rx,
12142 _home: home,
12143 };
12144 actor
12145 .wait_for_select(|checkpoint| checkpoint.generation > 0)
12146 .await;
12147 actor
12148 }
12149
12150 fn turns(&self) -> u64 {
12151 lock_snapshot(&self.module.inner.snapshot)
12152 .unwrap()
12153 .actor_turns
12154 }
12155
12156 async fn wait_for_select(
12157 &self,
12158 expected: impl Fn(&ActorSelectCheckpoint) -> bool,
12159 ) -> ActorSelectCheckpoint {
12160 wait_for("actor select checkpoint", || {
12161 let state = lock_snapshot(&self.module.inner.snapshot).unwrap();
12162 state.actor_select.filter(|checkpoint| {
12163 checkpoint.generation == state.spawn_generation
12164 && state.state == ModuleState::Running
12165 && state.process_alive
12166 && state.reported_pid().is_some()
12167 && expected(checkpoint)
12168 })
12169 })
12170 .await
12171 }
12172
12173 fn hello(&mut self, connection: u64, health: bool) {
12174 let manifest =
12175 subc_protocol::manifest::ModuleManifest::builder(&self.spec.module_id, "1.0.0")
12176 .protocol_ver(subc_protocol::PROTOCOL_VERSION)
12177 .build();
12178 let connection = ConnectionId::new(connection);
12179 let (tx, rx) = mpsc::channel(8);
12180 self.rx = rx;
12181 self.forwarding
12182 .register_module_connection(
12183 connection,
12184 self.spec.module_id.clone(),
12185 subc_protocol::PROTOCOL_VERSION,
12186 Concurrency::ModuleManaged,
12187 FrameSink::new(tx),
12188 )
12189 .unwrap();
12190 self.registry
12191 .register_with_control_ops(
12192 manifest,
12193 subc_protocol::PROTOCOL_VERSION,
12194 connection,
12195 if health {
12196 vec![MODULE_CONTROL_OP_HEALTH_CHECK.into()]
12197 } else {
12198 vec![]
12199 },
12200 )
12201 .unwrap();
12202 }
12203
12204 async fn probe(&mut self) -> crate::router::OutboundFrame {
12205 let frame = wait_for("outbound health probe", || match self.rx.try_recv() {
12206 Ok(frame) => Some(frame),
12207 Err(mpsc::error::TryRecvError::Empty) => None,
12208 Err(mpsc::error::TryRecvError::Disconnected) => panic!("health peer disconnected"),
12209 })
12210 .await;
12211 let body: Value = serde_json::from_slice(&frame.body).unwrap();
12212 assert_eq!(body["op"], MODULE_CONTROL_OP_HEALTH_CHECK);
12213 frame
12214 }
12215
12216 fn no_probe(&mut self) {
12217 assert!(matches!(
12218 self.rx.try_recv(),
12219 Err(mpsc::error::TryRecvError::Empty)
12220 ));
12221 }
12222
12223 fn answer(&self, connection: u64, frame: crate::router::OutboundFrame) {
12224 self.forwarding
12225 .complete_module_control_rpc(
12226 ConnectionId::new(connection),
12227 frame.header.corr,
12228 Some(MODULE_CONTROL_OP_HEALTH_CHECK),
12229 ModuleControlRpcOutcome::Response(ModuleControlResponse::HealthCheck {
12230 status: HealthStatus::Ok,
12231 detail: None,
12232 metrics: None,
12233 }),
12234 )
12235 .unwrap();
12236 }
12237 }
12238
12239 #[tokio::test(start_paused = true)]
12240 async fn no_health_children_park_for_sixty_seconds() {
12241 let none = Actor::start(ModuleProtocol::None, Duration::from_secs(30)).await;
12242 let unregistered = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12243 let mut unadvertised = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12244 unadvertised.hello(1, false);
12245 for _ in 0..6000 {
12250 tokio::time::advance(Duration::from_millis(10)).await;
12251 tokio::task::yield_now().await;
12252 }
12253 for actor in [&none, &unregistered, &unadvertised] {
12254 assert!(
12255 actor.turns() <= 5,
12256 "no-health actor woke {} times",
12257 actor.turns()
12258 );
12259 assert_eq!(actor.module.state().unwrap(), ModuleState::Running);
12260 }
12261 }
12262
12263 #[tokio::test(start_paused = true)]
12264 async fn late_hello_starts_a_due_probe_in_the_notification_tick() {
12265 let mut actor = Actor::start(ModuleProtocol::Subc, Duration::ZERO).await;
12269 let tick = Instant::now();
12270 actor.hello(1, true);
12271 actor.probe().await;
12272 assert_eq!(Instant::now(), tick, "HELLO must not wait for a poll");
12273 }
12274
12275 #[tokio::test(start_paused = true)]
12276 async fn hello_keeps_the_jittered_cadence_and_catalog_updates_do_not_reset_it() {
12277 let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12278 let hello_tick = Instant::now();
12279 actor.hello(1, true);
12280 let armed = actor
12281 .wait_for_select(|checkpoint| {
12282 checkpoint.registered_connection == Some(ConnectionId::new(1))
12283 && checkpoint.next_probe_at.is_some()
12284 })
12285 .await;
12286 let delay = jittered_health_delay(&actor.spec.module_id, 0, actor.health.cadence);
12287 assert!((Duration::from_secs(30)..Duration::from_secs(33)).contains(&delay));
12288 let first_deadline = hello_tick + delay;
12289 assert_eq!(armed.next_probe_at, Some(first_deadline));
12290 tokio::time::advance(Duration::from_secs(29)).await;
12291 let turns = actor.turns();
12292 actor
12293 .registry
12294 .replace_catalog_for_connection(ConnectionId::new(1), vec![], None, Some(true))
12295 .unwrap();
12296 let updated = actor
12297 .wait_for_select(|checkpoint| checkpoint.turn > turns)
12298 .await;
12299 assert_eq!(
12300 updated.next_probe_at,
12301 Some(first_deadline),
12302 "catalog update must not reset cadence"
12303 );
12304 actor.no_probe();
12305 tokio::time::advance(first_deadline - Instant::now()).await;
12306 let frame = actor.probe().await;
12307 actor.answer(1, frame);
12308 let next = jittered_health_delay(&actor.spec.module_id, 1, actor.health.cadence);
12309 let next_deadline = Instant::now() + next;
12310 let rearmed = actor
12311 .wait_for_select(|checkpoint| {
12312 checkpoint
12313 .next_probe_at
12314 .is_some_and(|deadline| deadline > first_deadline)
12315 })
12316 .await;
12317 assert_eq!(rearmed.next_probe_at, Some(next_deadline));
12318 tokio::time::advance(next - Duration::from_nanos(1)).await;
12319 actor.no_probe();
12320 tokio::time::advance(Duration::from_nanos(1)).await;
12321 actor.probe().await;
12322 }
12323
12324 #[tokio::test(start_paused = true)]
12325 async fn disconnect_disarms_and_reconnect_rearms_health() {
12326 let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12327 actor.hello(1, true);
12328 actor
12329 .wait_for_select(|checkpoint| {
12330 checkpoint.registered_connection == Some(ConnectionId::new(1))
12331 && checkpoint.next_probe_at.is_some()
12332 })
12333 .await;
12334 let turns = actor.turns();
12335 let tick = Instant::now();
12336 actor
12337 .registry
12338 .deregister_connection(ConnectionId::new(1))
12339 .unwrap();
12340 let disarmed = actor
12341 .wait_for_select(|checkpoint| {
12342 checkpoint.turn > turns && checkpoint.registered_connection.is_none()
12343 })
12344 .await;
12345 assert_eq!(disarmed.next_probe_at, None);
12346 assert_eq!(disarmed.wake_after, None);
12347 assert_eq!(
12348 Instant::now(),
12349 tick,
12350 "disconnect must disarm in the notification tick"
12351 );
12352 tokio::time::advance(Duration::from_secs(60)).await;
12353 actor.no_probe();
12354 let reconnect_tick = Instant::now();
12355 actor.hello(2, true);
12356 let rearmed = actor
12357 .wait_for_select(|checkpoint| {
12358 checkpoint.registered_connection == Some(ConnectionId::new(2))
12359 && checkpoint.next_probe_at.is_some()
12360 })
12361 .await;
12362 let delay = jittered_health_delay(&actor.spec.module_id, 0, actor.health.cadence);
12363 assert_eq!(rearmed.next_probe_at, Some(reconnect_tick + delay));
12364 tokio::time::advance(delay).await;
12365 actor.probe().await;
12366 }
12367
12368 #[tokio::test(start_paused = true)]
12369 async fn reregistration_without_health_disarms_the_old_schedule() {
12370 let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12371 actor.hello(1, true);
12372 actor
12373 .wait_for_select(|checkpoint| {
12374 checkpoint.registered_connection == Some(ConnectionId::new(1))
12375 && checkpoint.next_probe_at.is_some()
12376 })
12377 .await;
12378 actor
12379 .registry
12380 .deregister_connection(ConnectionId::new(1))
12381 .unwrap();
12382 actor.hello(2, false);
12383 let disarmed = actor
12384 .wait_for_select(|checkpoint| {
12385 checkpoint.registered_connection == Some(ConnectionId::new(2))
12386 })
12387 .await;
12388 assert_eq!(disarmed.next_probe_at, None);
12389 assert_eq!(disarmed.wake_after, None);
12390 tokio::time::advance(Duration::from_secs(60)).await;
12391 actor.no_probe();
12392 }
12393
12394 #[tokio::test(start_paused = true)]
12395 async fn restart_waits_for_its_own_hello_before_rearming_health() {
12396 let mut actor = Actor::start(ModuleProtocol::Subc, Duration::from_secs(30)).await;
12397 actor.hello(1, true);
12398 let armed = actor
12399 .wait_for_select(|checkpoint| {
12400 checkpoint.registered_connection == Some(ConnectionId::new(1))
12401 && checkpoint.next_probe_at.is_some()
12402 })
12403 .await;
12404 actor
12408 .registry
12409 .deregister_connection(ConnectionId::new(1))
12410 .unwrap();
12411 actor.module.restart(Some(0)).await.unwrap();
12412 let parked = actor
12413 .wait_for_select(|checkpoint| {
12414 checkpoint.generation > armed.generation
12415 && checkpoint.registered_connection.is_none()
12416 && checkpoint.next_probe_at.is_none()
12417 })
12418 .await;
12419 assert_eq!(parked.wake_after, None);
12420 actor.rx.close();
12421 while actor.rx.try_recv().is_ok() {}
12424 let turns = actor.turns();
12425 tokio::time::advance(Duration::from_secs(60)).await;
12426 let after = actor.turns();
12427 eprintln!(
12428 "restart park turns: before={turns} after={after} delta={}",
12429 after - turns
12430 );
12431 assert_eq!(after, turns, "replacement must park before HELLO");
12432 let hello_tick = Instant::now();
12433 actor.hello(2, true);
12434 let rearmed = actor
12435 .wait_for_select(|checkpoint| {
12436 checkpoint.registered_connection == Some(ConnectionId::new(2))
12437 && checkpoint.next_probe_at.is_some()
12438 })
12439 .await;
12440 let delay = jittered_health_delay(&actor.spec.module_id, 0, actor.health.cadence);
12441 assert_eq!(rearmed.next_probe_at, Some(hello_tick + delay));
12442 tokio::time::advance(delay).await;
12443 actor.probe().await;
12444 }
12445
12446 #[tokio::test(start_paused = true)]
12447 async fn rescan_adds_and_removes_http_health_without_polling() {
12448 use tokio::{
12449 io::{AsyncReadExt, AsyncWriteExt},
12450 net::TcpListener,
12451 };
12452 let mut actor = Actor::start(ModuleProtocol::None, Duration::from_secs(30)).await;
12453 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
12454 actor.health.http = Some(format!("http://{}/health", listener.local_addr().unwrap()));
12455 actor.health.cadence = Duration::from_millis(1);
12457 let (started_tx, mut started_rx) = mpsc::channel(8);
12458 let peer = tokio::spawn(async move {
12459 loop {
12460 let (mut socket, _) = listener.accept().await.unwrap();
12461 let mut request = [0; 1024];
12462 assert!(socket.read(&mut request).await.unwrap() > 0);
12463 started_tx.send(()).await.unwrap();
12464 socket
12465 .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")
12466 .await
12467 .unwrap();
12468 }
12469 });
12470 let tick = Instant::now();
12471 let turns = actor.turns();
12472 actor
12473 .module
12474 .update_configuration(actor.spec.clone(), actor.health.clone(), None)
12475 .await
12476 .unwrap();
12477 let armed = actor
12478 .wait_for_select(|checkpoint| {
12479 checkpoint.turn > turns && checkpoint.next_probe_at.is_some()
12480 })
12481 .await;
12482 assert_eq!(armed.next_probe_at, Some(tick + Duration::from_millis(1)));
12483 assert_eq!(Instant::now(), tick);
12484 tokio::time::advance(Duration::from_millis(1)).await;
12485 let mut started = false;
12486 wait_for("successful HTTP probe", || {
12487 started |= started_rx.try_recv().is_ok();
12488 (started && actor.module.status().unwrap().health.status == SupervisorHealthStatus::Ok)
12489 .then_some(())
12490 })
12491 .await;
12492 assert!(started, "added HTTP check must start at its first deadline");
12493 assert_eq!(
12494 actor.module.status().unwrap().health.status,
12495 SupervisorHealthStatus::Ok
12496 );
12497 assert_eq!(Instant::now(), tick + Duration::from_millis(1));
12498 actor.health.http = None;
12499 let turns = actor.turns();
12500 actor
12501 .module
12502 .update_configuration(actor.spec.clone(), actor.health.clone(), None)
12503 .await
12504 .unwrap();
12505 let parked = actor
12506 .wait_for_select(|checkpoint| {
12507 checkpoint.turn > turns && checkpoint.next_probe_at.is_none()
12508 })
12509 .await;
12510 assert_eq!(parked.wake_after, None);
12511 let turns = actor.turns();
12512 tokio::time::advance(Duration::from_secs(60)).await;
12513 assert_eq!(
12514 actor.turns(),
12515 turns,
12516 "removed HTTP check must park the actor"
12517 );
12518 assert!(
12519 started_rx.try_recv().is_err(),
12520 "removed HTTP check must stay disarmed"
12521 );
12522 assert_eq!(
12523 actor.module.status().unwrap().health,
12524 ModuleHealthStatus::default()
12525 );
12526 peer.abort();
12527 }
12528}
12529
12530#[cfg(test)]
12531mod health_evidence_tests {
12532 use super::{HealthProbeError, HealthProbeEvidence};
12533 use std::collections::HashSet;
12534
12535 #[test]
12543 fn only_a_dead_lane_is_proof_of_death() {
12544 assert!(HealthProbeError::lane_dead("gone").is_proof_of_death());
12545 assert!(!HealthProbeError::no_answer("timed out").is_proof_of_death());
12549 assert!(!HealthProbeError::bad_answer("garbage").is_proof_of_death());
12550 assert!(!HealthProbeError::misconfigured("no table").is_proof_of_death());
12551 }
12552
12553 #[test]
12559 fn every_evidence_class_has_a_distinct_label() {
12560 let labels = [
12561 HealthProbeError::lane_dead("").label(),
12562 HealthProbeError::no_answer("").label(),
12563 HealthProbeError::bad_answer("").label(),
12564 HealthProbeError::misconfigured("").label(),
12565 ];
12566 let unique: HashSet<_> = labels.iter().collect();
12567 assert_eq!(unique.len(), labels.len(), "labels collided: {labels:?}");
12568 }
12569
12570 #[test]
12576 fn classification_preserves_the_original_message() {
12577 let err = HealthProbeError::no_answer("module did not answer within 5s");
12578 assert_eq!(err.to_string(), "module did not answer within 5s");
12579 assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
12580 }
12581}
12582
12583#[cfg(test)]
12584mod health_tombstone_tests {
12585 use std::{path::PathBuf, sync::Arc, time::Duration};
12586
12587 use subc_protocol::{
12588 manifest::Concurrency,
12589 session::{HealthStatus, ModuleControlResponse},
12590 };
12591 use tokio::sync::mpsc;
12592
12593 use super::{
12594 probe_module_health, HealthAction, HealthConfig, HealthProbeEvidence, ModuleProtocol,
12595 ModuleSpec, RestartPolicy, Supervisor, SupervisorRuntimeConfig,
12596 };
12597 use crate::{
12598 control::ControlHandler,
12599 forwarding::{ForwardingTable, ModuleControlRpcCompletion, ModuleControlRpcOutcome},
12600 registry::{ConnectionId, Registry},
12601 router::FrameSink,
12602 };
12603
12604 struct ProbeHarness {
12605 spec: ModuleSpec,
12606 runtime: SupervisorRuntimeConfig,
12607 forwarding: Arc<ForwardingTable>,
12608 module_connection: ConnectionId,
12609 module_rx: mpsc::Receiver<crate::router::OutboundFrame>,
12610 handler: ControlHandler,
12611 module: super::SupervisedModule,
12612 }
12613
12614 fn probe_harness() -> ProbeHarness {
12615 let registry = Arc::new(Registry::default());
12616 let forwarding = Arc::new(ForwardingTable::default());
12617 let supervisor_handle = super::SupervisorHandle::new();
12618 let health = HealthConfig {
12619 http: None,
12620 cadence: Duration::from_secs(30),
12621 deadline: Duration::from_secs(5),
12622 failure_threshold: 3,
12623 on_degraded: HealthAction::Report,
12624 on_failing: HealthAction::Report,
12625 critical: false,
12626 };
12627 let supervisor = Supervisor::new_for_test(Arc::clone(®istry), RestartPolicy::default())
12628 .with_forwarding(Arc::clone(&forwarding))
12629 .with_handle(supervisor_handle.clone())
12630 .with_health_config(health);
12631 let spec = ModuleSpec {
12632 module_id: "late-health-module".to_string(),
12633 program: PathBuf::from("disabled-module"),
12634 args: Vec::new(),
12635 env: Vec::new(),
12636 reserved: false,
12637 reserved_prefixes: Vec::new(),
12638 protocol: ModuleProtocol::Subc,
12639 overlap: Default::default(),
12640 };
12641 let module = supervisor
12642 .supervise_configured(spec.clone(), false)
12643 .unwrap();
12644 let runtime = supervisor.runtime_config();
12645 let handler = ControlHandler::with_forwarding(registry, Arc::clone(&forwarding))
12646 .with_supervisor(supervisor_handle);
12647 let module_connection = ConnectionId::new(700);
12648 let (module_tx, module_rx) = mpsc::channel(8);
12649 forwarding
12650 .register_module_connection(
12651 module_connection,
12652 spec.module_id.clone(),
12653 subc_protocol::PROTOCOL_VERSION,
12654 Concurrency::ModuleManaged,
12655 FrameSink::new(module_tx),
12656 )
12657 .unwrap();
12658
12659 ProbeHarness {
12660 spec,
12661 runtime,
12662 forwarding,
12663 module_connection,
12664 module_rx,
12665 handler,
12666 module,
12667 }
12668 }
12669
12670 async fn finish_after(
12671 harness: &mut ProbeHarness,
12672 stall: Duration,
12673 ) -> ModuleControlRpcCompletion {
12674 assert!(stall > harness.runtime.health.deadline);
12675 let deadline = harness.runtime.health.deadline;
12676 let probe = probe_module_health(&harness.spec.module_id, &harness.runtime, None);
12677 let answer = async {
12678 let frame = harness.module_rx.recv().await.expect("health.check frame");
12679 tokio::time::advance(deadline).await;
12680 tokio::task::yield_now().await;
12681 tokio::time::advance(stall - deadline).await;
12682 harness
12683 .forwarding
12684 .complete_module_control_rpc(
12685 harness.module_connection,
12686 frame.header.corr,
12687 Some("health.check"),
12688 ModuleControlRpcOutcome::Response(ModuleControlResponse::HealthCheck {
12689 status: HealthStatus::Ok,
12690 detail: None,
12691 metrics: None,
12692 }),
12693 )
12694 .unwrap()
12695 };
12696 let (probe_result, completion) = tokio::join!(probe, answer);
12697 let err = probe_result.expect_err("probe must miss its deadline");
12698 assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
12699 completion
12700 }
12701
12702 async fn time_out_without_answer(harness: &mut ProbeHarness) {
12703 let deadline = harness.runtime.health.deadline;
12704 let probe = probe_module_health(&harness.spec.module_id, &harness.runtime, None);
12705 let exhaust_deadline = async {
12706 let _frame = harness.module_rx.recv().await.expect("health.check frame");
12707 tokio::time::advance(deadline).await;
12708 tokio::task::yield_now().await;
12709 };
12710 let (probe_result, ()) = tokio::join!(probe, exhaust_deadline);
12711 let err = probe_result.expect_err("probe must miss its deadline");
12712 assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
12713 }
12714
12715 async fn run_probe_cycle(harness: &mut ProbeHarness, answer: bool) {
12716 let registry = Arc::clone(&harness.module.inner.registry);
12717 let snapshot = Arc::clone(&harness.module.inner.snapshot);
12718 let process_liveness = super::SupervisorProcessLiveness::default();
12719 let mut child = None;
12720 let cycle = super::run_health_probe_cycle(
12721 &harness.spec,
12722 &harness.runtime,
12723 ®istry,
12724 &process_liveness,
12725 &snapshot,
12726 &mut child,
12727 );
12728 let peer = async {
12729 let frame = harness.module_rx.recv().await.expect("health.check frame");
12730 if answer {
12731 harness
12732 .forwarding
12733 .complete_module_control_rpc(
12734 harness.module_connection,
12735 frame.header.corr,
12736 Some("health.check"),
12737 ModuleControlRpcOutcome::Response(ModuleControlResponse::HealthCheck {
12738 status: HealthStatus::Ok,
12739 detail: None,
12740 metrics: Some(serde_json::json!({"ready": true})),
12741 }),
12742 )
12743 .unwrap();
12744 } else {
12745 tokio::time::advance(harness.runtime.health.deadline).await;
12746 tokio::task::yield_now().await;
12747 }
12748 };
12749 tokio::join!(cycle, peer);
12750 }
12751
12752 #[tokio::test(start_paused = true)]
12753 async fn unanswered_probe_is_unknown_until_threshold_and_ok_report_recovers() {
12754 let mut harness = probe_harness();
12755 let monitor = harness.module.inner.monitor.lock().unwrap().take().unwrap();
12759 monitor.abort();
12760 let _ = monitor.await;
12761 super::update_snapshot(&harness.module.inner.snapshot, None, |state| {
12762 state.enabled = true;
12763 state.state = super::ModuleState::Running;
12764 state.process_alive = true;
12765 })
12766 .unwrap();
12767
12768 run_probe_cycle(&mut harness, true).await;
12769 assert_eq!(
12770 harness.module.status().unwrap().health.status,
12771 super::SupervisorHealthStatus::Ok
12772 );
12773
12774 for failures in 1..harness.runtime.health.failure_threshold {
12775 run_probe_cycle(&mut harness, false).await;
12776 let status = harness.module.status().unwrap();
12777 assert_eq!(status.health.status, super::SupervisorHealthStatus::Unknown);
12778 assert_eq!(status.health.consecutive_failures, failures);
12779 assert!(status.health.last_probe_ms.is_some());
12780 assert!(status.health.detail.unwrap().starts_with("[no-answer]"));
12781 assert!(status.health.metrics.is_none());
12782 assert_eq!(status.state, super::ModuleState::Running);
12783 assert!(status.process_alive);
12784 assert_eq!(status.restart_count, 0);
12785 assert_eq!(status.lifetime_restarts, 0);
12786 assert!(status.health.last_action.is_none());
12787 assert!(harness.runtime.scheduled_respawn.lock().unwrap().is_none());
12788 }
12789
12790 run_probe_cycle(&mut harness, true).await;
12791 let recovered = harness.module.status().unwrap();
12792 assert_eq!(recovered.health.status, super::SupervisorHealthStatus::Ok);
12793 assert_eq!(recovered.health.consecutive_failures, 0);
12794 assert!(recovered.health.detail.is_none());
12795 assert_eq!(
12796 recovered.health.metrics,
12797 Some(serde_json::json!({"ready": true}))
12798 );
12799 assert_eq!(recovered.lifetime_restarts, 0);
12800
12801 for failures in 1..=harness.runtime.health.failure_threshold {
12802 run_probe_cycle(&mut harness, false).await;
12803 let status = harness.module.status().unwrap();
12804 assert_eq!(status.health.consecutive_failures, failures);
12805 if failures < harness.runtime.health.failure_threshold {
12806 assert_eq!(status.health.status, super::SupervisorHealthStatus::Unknown);
12807 assert_eq!(status.state, super::ModuleState::Running);
12808 assert_eq!(status.lifetime_restarts, 0);
12809 assert!(harness.runtime.scheduled_respawn.lock().unwrap().is_none());
12810 } else {
12811 assert_eq!(
12812 status.health.status,
12813 super::SupervisorHealthStatus::Unresponsive
12814 );
12815 assert_eq!(status.state, super::ModuleState::Restarting);
12816 assert_eq!(status.restart_count, 1);
12817 assert_eq!(status.lifetime_restarts, 1);
12818 assert_eq!(status.health.last_action.as_deref(), Some("restart"));
12819 assert!(harness.runtime.scheduled_respawn.lock().unwrap().is_some());
12820 }
12821 }
12822 }
12823
12824 #[tokio::test(start_paused = true)]
12825 async fn late_health_answers_record_start_anchored_latency_for_two_stalls() {
12826 let mut harness = probe_harness();
12827
12828 let first = finish_after(&mut harness, Duration::from_secs(8)).await;
12829 let first_latency = match &first {
12830 ModuleControlRpcCompletion::LateHealthAnswer { latency, .. } => *latency,
12831 other => panic!("late answer was not retained: {other:?}"),
12832 };
12833 assert!(harness.handler.observe_module_control_completion(first));
12834
12835 let second = finish_after(&mut harness, Duration::from_secs(11)).await;
12836 let second_latency = match &second {
12837 ModuleControlRpcCompletion::LateHealthAnswer { latency, .. } => *latency,
12838 other => panic!("late answer was not retained: {other:?}"),
12839 };
12840 assert!(harness.handler.observe_module_control_completion(second));
12841
12842 assert_eq!(first_latency, Duration::from_secs(8));
12843 assert_eq!(
12844 second_latency - first_latency,
12845 Duration::from_secs(3),
12846 "latency must grow linearly with the additional stall"
12847 );
12848 let health = harness.module.status().unwrap().health;
12849 assert_eq!(health.late_answer_count, 2);
12850 assert_eq!(health.last_late_answer_latency_ms, Some(11_000));
12851 }
12852
12853 #[tokio::test(start_paused = true)]
12861 async fn late_answer_clears_the_consecutive_failure_streak() {
12862 let mut harness = probe_harness();
12863
12864 time_out_without_answer(&mut harness).await;
12866 harness
12867 .module
12868 .record_health_probe_failure_for_test("[no-answer] test miss")
12869 .unwrap();
12870 assert_eq!(
12871 harness.module.status().unwrap().health.consecutive_failures,
12872 1,
12873 "precondition: the miss must be on the streak before the late answer"
12874 );
12875
12876 let late = finish_after(&mut harness, Duration::from_secs(9)).await;
12878 assert!(matches!(
12879 late,
12880 ModuleControlRpcCompletion::LateHealthAnswer { .. }
12881 ));
12882 assert!(harness.handler.observe_module_control_completion(late));
12883
12884 let health = harness.module.status().unwrap().health;
12885 assert_eq!(
12886 health.consecutive_failures, 0,
12887 "a late answer is an answer: the streak must reset"
12888 );
12889 assert_eq!(health.late_answer_count, 1);
12890 }
12891
12892 #[tokio::test(start_paused = true)]
12893 async fn repeated_serial_probe_cycles_keep_one_tombstone_per_endpoint() {
12894 let mut harness = probe_harness();
12895
12896 for _ in 0..20 {
12897 time_out_without_answer(&mut harness).await;
12898 assert_eq!(
12899 harness.forwarding.health_probe_tombstone_count().unwrap(),
12900 1
12901 );
12902 }
12903 }
12904}
12905
12906#[cfg(test)]
12907mod child_env_tests {
12908 #[cfg(windows)]
12909 use super::{
12910 clear_current_process_facts, observed_launch_nonce_source, ModuleState, SupervisorSnapshot,
12911 };
12912 #[cfg(windows)]
12913 use std::sync::{Arc, OnceLock};
12914 #[cfg(windows)]
12915 #[test]
12916 fn windows_wire_spawn_prepares_pipe_and_keeps_phase_one_env() {
12917 let spec = spec(Vec::new());
12918 let mut command = Command::new("unused");
12919 let handoff = apply_wire_spawn_args(&mut command, &spec, None, None)
12920 .unwrap()
12921 .unwrap();
12922 let name = command
12923 .as_std()
12924 .get_envs()
12925 .find(|(key, _)| *key == OsStr::new(subc_os::launch_nonce::LAUNCH_NONCE_PIPE_ENV))
12926 .and_then(|(_, value)| value)
12927 .unwrap();
12928 assert_eq!(name, OsStr::new(handoff.name()));
12929 assert!(handoff.name().starts_with(r"\\.\pipe\subc-launch-"));
12930 assert!(command.as_std().get_envs().any(|(key, value)| key
12931 == OsStr::new(subc_os::LAUNCH_NONCE_PIPE_FALLBACK_ENV)
12932 && value == Some(OsStr::new("env"))));
12933 assert!(command
12934 .as_std()
12935 .get_envs()
12936 .any(|(key, value)| key == OsStr::new(SUBC_LAUNCH_NONCE_ENV) && value.is_some()));
12937 }
12938
12939 #[cfg(windows)]
12940 #[test]
12941 fn windows_source_reporting_tracks_consumption_and_resets_per_spawn() {
12942 let mut snapshot = SupervisorSnapshot::new(ModuleState::Running, true);
12943 snapshot.process_alive = true;
12944 snapshot.spawned_protocol = Some(ModuleProtocol::Subc);
12945 assert_eq!(
12946 observed_launch_nonce_source(&snapshot).as_deref(),
12947 Some("env")
12948 );
12949 let consumed = Arc::new(OnceLock::new());
12950 snapshot.pipe_consumed = Some(consumed.clone());
12951 assert_eq!(
12952 observed_launch_nonce_source(&snapshot).as_deref(),
12953 Some("env")
12954 );
12955 consumed.set(()).unwrap();
12956 assert_eq!(
12957 observed_launch_nonce_source(&snapshot).as_deref(),
12958 Some("pipe")
12959 );
12960 snapshot.pipe_consumed = Some(Arc::new(OnceLock::new()));
12961 assert_eq!(
12962 observed_launch_nonce_source(&snapshot).as_deref(),
12963 Some("env")
12964 );
12965 clear_current_process_facts(&mut snapshot);
12966 assert_eq!(observed_launch_nonce_source(&snapshot), None);
12967 }
12968 use super::{
12969 apply_child_env, apply_spawn_role, apply_wire_spawn_args, ModuleProtocol, ModuleSpec,
12970 SpawnRole, SupervisorHandle, SPAWN_ROLE_SWAP_CANDIDATE, SUBC_ARG, SUBC_LAUNCH_NONCE_ENV,
12971 SUBC_MODULE_ID_ENV, SUBC_SPAWN_ROLE_ENV,
12972 };
12973 use std::{ffi::OsStr, path::PathBuf};
12974 use tokio::process::Command;
12975
12976 fn spec(env: Vec<(String, String)>) -> ModuleSpec {
12977 ModuleSpec {
12978 module_id: "env-plan".to_string(),
12979 program: PathBuf::from("/nonexistent"),
12980 args: Vec::new(),
12981 env,
12982 reserved: false,
12983 reserved_prefixes: Vec::new(),
12984 protocol: ModuleProtocol::Subc,
12985 overlap: Default::default(),
12986 }
12987 }
12988
12989 #[test]
13003 fn ambient_ck_log_is_removed_and_a_configured_one_survives() {
13004 let mut command = Command::new("/nonexistent");
13005 apply_child_env(&mut command, &spec(Vec::new()));
13006 let removed = command
13007 .as_std()
13008 .get_envs()
13009 .any(|(key, value)| key == OsStr::new("CK_LOG") && value.is_none());
13010 assert!(
13011 removed,
13012 "ambient CK_LOG must be explicitly removed for an unconfigured module"
13013 );
13014
13015 let mut configured = Command::new("/nonexistent");
13016 apply_child_env(
13017 &mut configured,
13018 &spec(vec![("CK_LOG".to_string(), "debug".to_string())]),
13019 );
13020 let effective = configured
13021 .as_std()
13022 .get_envs()
13023 .filter(|(key, _)| *key == OsStr::new("CK_LOG"))
13024 .last()
13025 .map(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()));
13026 assert_eq!(
13027 effective,
13028 Some(Some("debug".to_string())),
13029 "a module's configured CK_LOG must survive the ambient removal"
13030 );
13031 }
13032
13033 #[test]
13042 fn protocol_none_spawn_carries_no_subc_argument_and_no_nonce() {
13043 let connection_file = std::path::Path::new("/run/subc-connection.json");
13044 let handle = SupervisorHandle::new();
13045
13046 let mut none_spec = spec(Vec::new());
13047 none_spec.protocol = ModuleProtocol::None;
13048 let mut none = Command::new("/nonexistent");
13049 let none_handoff =
13050 apply_wire_spawn_args(&mut none, &none_spec, Some(connection_file), Some(&handle))
13051 .expect("protocol-none spawn args apply");
13052 assert!(
13053 none_handoff.is_none(),
13054 "protocol:none spawn must not receive a nonce descriptor"
13055 );
13056 #[cfg(windows)]
13057 assert!(!none.as_std().get_envs().any(|(key, value)| key
13058 == OsStr::new(subc_os::LAUNCH_NONCE_PIPE_FALLBACK_ENV)
13059 && value.is_some()));
13060 assert!(
13061 !none.as_std().get_envs().any(|(key, value)| key
13062 == OsStr::new(subc_os::LAUNCH_NONCE_FD_ENV)
13063 && value.is_some()),
13064 "protocol:none spawn must not name a nonce descriptor"
13065 );
13066 let none_args: Vec<String> = none
13067 .as_std()
13068 .get_args()
13069 .map(|a| a.to_string_lossy().into_owned())
13070 .collect();
13071 assert!(
13072 !none_args.iter().any(|a| a == SUBC_ARG),
13073 "protocol:none argv must not carry --subc; got {none_args:?}"
13074 );
13075 let none_has_nonce = none
13076 .as_std()
13077 .get_envs()
13078 .any(|(key, value)| key == OsStr::new(SUBC_LAUNCH_NONCE_ENV) && value.is_some());
13079 assert!(
13080 !none_has_nonce,
13081 "protocol:none spawn must not receive a launch nonce"
13082 );
13083 let none_has_module_id = none
13084 .as_std()
13085 .get_envs()
13086 .any(|(key, value)| key == OsStr::new(SUBC_MODULE_ID_ENV) && value.is_some());
13087 assert!(
13088 none_has_module_id,
13089 "SUBC_MODULE_ID is inert and stays on every path"
13090 );
13091 assert!(
13092 handle.spawn_nonce(&none_spec.module_id).is_none(),
13093 "no nonce record for a process that will never present one"
13094 );
13095
13096 let wire_spec = spec(Vec::new());
13098 let mut wire = Command::new("/nonexistent");
13099 let wire_handoff =
13100 apply_wire_spawn_args(&mut wire, &wire_spec, Some(connection_file), Some(&handle))
13101 .expect("subc-wire spawn args apply");
13102 let wire_fd_env = wire
13103 .as_std()
13104 .get_envs()
13105 .find(|(key, _)| *key == OsStr::new(subc_os::LAUNCH_NONCE_FD_ENV))
13106 .and_then(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()));
13107 #[cfg(unix)]
13108 assert_eq!(
13109 wire_fd_env,
13110 Some(wire_handoff.expect("a descriptor handoff").fd_env_value()),
13111 "a subc-wire spawn names the pipe it will receive at descriptor 3"
13112 );
13113 #[cfg(windows)]
13114 assert!(wire_handoff.is_some() && wire_fd_env.is_none());
13115 #[cfg(not(any(unix, windows)))]
13116 assert!(wire_handoff.is_none() && wire_fd_env.is_none());
13117 let wire_args: Vec<String> = wire
13118 .as_std()
13119 .get_args()
13120 .map(|a| a.to_string_lossy().into_owned())
13121 .collect();
13122 assert_eq!(
13123 wire_args,
13124 vec![
13125 SUBC_ARG.to_string(),
13126 connection_file.to_string_lossy().into_owned()
13127 ],
13128 "a subc-wire spawn still carries --subc <path>"
13129 );
13130 assert_eq!(
13131 wire.as_std()
13132 .get_envs()
13133 .any(|(key, value)| key == OsStr::new(SUBC_LAUNCH_NONCE_ENV) && value.is_some()),
13134 !cfg!(unix),
13135 "only Windows supplies the environment nonce"
13136 );
13137 assert!(handle.spawn_nonce(&wire_spec.module_id).is_some());
13138 }
13139
13140 #[test]
13150 fn plain_spawn_removes_the_spawn_role_even_when_the_spec_sets_it() {
13151 let role = |command: &Command| {
13152 command
13153 .as_std()
13154 .get_envs()
13155 .filter(|(key, _)| *key == OsStr::new(SUBC_SPAWN_ROLE_ENV))
13156 .last()
13157 .map(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()))
13158 };
13159 let forged = spec(vec![(
13160 SUBC_SPAWN_ROLE_ENV.to_string(),
13161 SPAWN_ROLE_SWAP_CANDIDATE.to_string(),
13162 )]);
13163
13164 let mut plain = Command::new("/nonexistent");
13165 apply_child_env(&mut plain, &forged);
13166 apply_spawn_role(&mut plain, SpawnRole::Plain);
13167 assert_eq!(
13168 role(&plain),
13169 Some(None),
13170 "a plain spawn must remove SUBC_SPAWN_ROLE, whatever the spec says"
13171 );
13172
13173 let mut candidate = Command::new("/nonexistent");
13174 apply_child_env(&mut candidate, &spec(Vec::new()));
13175 apply_spawn_role(&mut candidate, SpawnRole::SwapCandidate);
13176 assert_eq!(
13177 role(&candidate),
13178 Some(Some(SPAWN_ROLE_SWAP_CANDIDATE.to_string()))
13179 );
13180 }
13181
13182 #[test]
13188 fn daemon_private_capture_keys_are_not_passed_to_the_child() {
13189 let mut command = Command::new("/nonexistent");
13190 apply_child_env(
13191 &mut command,
13192 &spec(vec![
13193 (super::CAPTURE_KEEP_ENV.to_string(), "5".to_string()),
13194 ("KEPT".to_string(), "yes".to_string()),
13195 ]),
13196 );
13197 let keys: Vec<String> = command
13198 .as_std()
13199 .get_envs()
13200 .filter(|(_, value)| value.is_some())
13201 .map(|(key, _)| key.to_string_lossy().into_owned())
13202 .collect();
13203 assert!(keys.contains(&"KEPT".to_string()), "got {keys:?}");
13204 assert!(
13205 !keys.contains(&super::CAPTURE_KEEP_ENV.to_string()),
13206 "daemon-private capture key leaked to the child: {keys:?}"
13207 );
13208 }
13209}
13210
13211#[cfg(test)]
13212mod jitter_tests {
13213 use super::jittered_health_delay;
13214 use std::{collections::HashSet, time::Duration};
13215
13216 const FLEET: [&str; 14] = [
13225 "aft",
13226 "alfonso-core",
13227 "magic-context",
13228 "broca",
13229 "thalamus",
13230 "quota",
13231 "engram",
13232 "plexus",
13233 "cerebellum",
13234 "astrocyte",
13235 "synapse",
13236 "subc-mcp",
13237 "cortexkit-credentials",
13238 "subc-federation",
13239 ];
13240
13241 #[test]
13249 fn probe_delays_disperse_across_the_fleet() {
13250 let cadence = Duration::from_secs(30);
13251 let delays: HashSet<Duration> = FLEET
13252 .iter()
13253 .map(|id| jittered_health_delay(id, 0, cadence))
13254 .collect();
13255 assert_eq!(
13256 delays.len(),
13257 FLEET.len(),
13258 "every supervised module must land on its own probe offset"
13259 );
13260 }
13261
13262 #[test]
13268 fn jitter_only_delays_and_stays_within_one_tenth_of_cadence() {
13269 let cadence = Duration::from_secs(30);
13270 let span = cadence / 10;
13271 for id in FLEET {
13272 for probe_index in 0..8 {
13273 let delay = jittered_health_delay(id, probe_index, cadence);
13274 assert!(
13275 delay >= cadence,
13276 "{id}#{probe_index}: jitter must not shorten the cadence"
13277 );
13278 assert!(
13279 delay < cadence + span,
13280 "{id}#{probe_index}: jitter must stay inside one tenth of the cadence"
13281 );
13282 }
13283 }
13284 }
13285
13286 #[test]
13292 fn a_module_offset_is_stable_across_restarts() {
13293 let cadence = Duration::from_secs(30);
13294 for id in FLEET {
13295 assert_eq!(
13296 jittered_health_delay(id, 0, cadence),
13297 jittered_health_delay(id, 0, cadence),
13298 "{id}: the same module and probe index must produce the same offset"
13299 );
13300 }
13301 }
13302
13303 #[test]
13305 fn zero_cadence_yields_zero_delay() {
13306 assert_eq!(
13307 jittered_health_delay("aft", 0, Duration::ZERO),
13308 Duration::ZERO
13309 );
13310 }
13311}
13312
13313#[cfg(all(test, target_os = "linux"))]
13314mod cgroup_placement_tests {
13315 use super::{
13316 apply_cgroup_placement, remove_module_cgroup, ModuleProtocol, ModuleSpec, SuperviseError,
13317 SupervisedChild,
13318 };
13319 use crate::stderr_tail::{StderrRing, StderrTailConfig};
13320 use cortexkit_test_support::ScratchDir;
13321 use std::{
13322 fs, io,
13323 path::{Path, PathBuf},
13324 sync::{Arc, Mutex},
13325 };
13326 use tokio::process::Command;
13327
13328 #[tokio::test]
13329 async fn unique_spawn_cgroups_clean_up_across_restarts_and_shutdown() {
13330 use super::*;
13331 let dir = ScratchDir::new("unique-spawn-cgroups");
13332 let root = PathBuf::from(format!(
13333 "/sys/fs/cgroup/subc-unique-test-{}-{}",
13334 std::process::id(),
13335 unix_ms_now()
13336 ));
13337 if let Err(error) = fs::create_dir(&root) {
13338 assert!(
13339 std::env::var_os("SUBC_REQUIRE_CGROUP_TEST").is_none(),
13340 "required cgroup test cannot execute: {error}"
13341 );
13342 eprintln!("SKIP unique_spawn_cgroups_clean_up_across_restarts_and_shutdown: {error}");
13343 return;
13344 }
13345 let placement = subc_cgroup::prepare_at(&root).unwrap().unwrap();
13346 let group_count = || {
13347 fs::read_dir(root.join("subc-modules"))
13348 .unwrap()
13349 .map(|entry| entry.unwrap().file_type().unwrap())
13350 .filter(|kind| kind.is_dir())
13351 .count()
13352 };
13353 let supervisor =
13354 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
13355 .with_cgroup_placement(Some(placement.clone()));
13356 let runtime = supervisor.runtime_config();
13357 let mut spec = ModuleSpec {
13358 module_id: "unique-spawn".into(),
13359 program: PathBuf::from("/bin/sleep"),
13360 args: vec!["60".into()],
13361 env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
13362 .into_iter()
13363 .map(|key| (key.into(), dir.to_string_lossy().into_owned()))
13364 .collect(),
13365 reserved: false,
13366 reserved_prefixes: vec![],
13367 protocol: ModuleProtocol::None,
13368 overlap: Default::default(),
13369 };
13370 let spawn = |spec: &ModuleSpec| {
13371 spawn_child(
13372 spec,
13373 None,
13374 None,
13375 &runtime.stderr_ring,
13376 None,
13377 &runtime.child_roster,
13378 Some(&placement),
13379 )
13380 .unwrap()
13381 };
13382 let mut live = spawn(&spec);
13383 for _ in 0..3 {
13384 let next = spawn(&spec);
13386 assert_ne!(live.module_id, next.module_id);
13387 live.start_kill().unwrap();
13388 live.wait().await.unwrap();
13389 live = next;
13390 assert!(
13391 live.child.try_wait().unwrap().is_none(),
13392 "retiring the old slot must not kill the replacement"
13393 );
13394 assert_eq!(
13395 group_count(),
13396 1,
13397 "only the live spawn's cgroup should remain"
13398 );
13399 }
13400 supervisor.begin_daemon_shutdown();
13401 let reap = tokio::spawn(async move {
13402 live.wait().await.unwrap();
13403 });
13404 supervisor
13405 .end_children_for_daemon_shutdown(false, std::future::pending())
13406 .await;
13407 reap.await.unwrap();
13408 assert_eq!(group_count(), 0);
13409 spec.program = PathBuf::from("/bin/true");
13411 spec.args.clear();
13412 let fresh_roster = ChildRoster::default();
13413 let mut short = spawn_child(
13414 &spec,
13415 None,
13416 None,
13417 &runtime.stderr_ring,
13418 None,
13419 &fresh_roster,
13420 Some(&placement),
13421 )
13422 .unwrap();
13423 short.wait().await.unwrap();
13424 assert_eq!(group_count(), 0);
13425 spec.module_id = "_".repeat(255);
13426 let mut long_id = spawn_child(
13427 &spec,
13428 None,
13429 None,
13430 &runtime.stderr_ring,
13431 None,
13432 &fresh_roster,
13433 Some(&placement),
13434 )
13435 .unwrap();
13436 long_id.wait().await.unwrap();
13437 assert_eq!(
13438 group_count(),
13439 0,
13440 "valid long module IDs must not exceed cgroup NAME_MAX"
13441 );
13442 fs::remove_dir(root.join("subc-modules")).unwrap();
13443 fs::remove_dir(root).unwrap();
13444 eprintln!("EXECUTED unique_spawn_cgroups_clean_up_across_restarts_and_shutdown");
13445 }
13446
13447 #[test]
13448 fn failed_parent_cgroup_open_is_a_cgroup_supervision_error() {
13449 let path = Path::new("/definitely-missing-subc-cgroup");
13450 let mut command = Command::new("true");
13451 let error = apply_cgroup_placement(
13452 &mut command,
13453 &ModuleSpec {
13454 module_id: "broken-cgroup".to_string(),
13455 program: PathBuf::from("true"),
13456 args: Vec::new(),
13457 env: Vec::new(),
13458 reserved: false,
13459 reserved_prefixes: Vec::new(),
13460 protocol: ModuleProtocol::Subc,
13461 overlap: Default::default(),
13462 },
13463 path,
13464 )
13465 .expect_err("a parent cgroup open failure must reject the supervised spawn");
13466 let reason = error.to_string();
13467
13468 assert!(
13469 matches!(error, SuperviseError::Cgroup { .. }),
13470 "parent cgroup open must be reported as a cgroup supervision error: {reason}"
13471 );
13472 assert!(
13473 reason.contains("/definitely-missing-subc-cgroup/cgroup.procs"),
13474 "parent cgroup open failure must name cgroup.procs: {reason}"
13475 );
13476 }
13477
13478 #[tokio::test]
13479 async fn reaping_a_child_removes_its_empty_module_cgroup() {
13480 let root = ScratchDir::new("supervisor-reap-cgroup");
13481 fs::write(root.join("cgroup.procs"), b"").expect("write scratch cgroup marker");
13482 let placement = subc_cgroup::prepare_at(&root)
13483 .expect("prepare scratch cgroup root")
13484 .expect("scratch root has a cgroup.procs marker");
13485 let module_id = "reaped-module";
13486 let module = placement
13487 .module_path(module_id)
13488 .expect("create scratch module cgroup");
13489 let child = Command::new("true")
13490 .env("XDG_DATA_HOME", root.path())
13491 .env("XDG_RUNTIME_DIR", root.path())
13492 .env("XDG_CONFIG_HOME", root.path())
13493 .spawn()
13494 .expect("spawn short-lived child");
13495 let pid = child.id().expect("spawned child has pid");
13496 let mut child = SupervisedChild {
13497 child,
13498 protocol: ModuleProtocol::Subc,
13499 module_id: module_id.to_string(),
13500 cgroup_placement: Some(placement),
13501 stdout_pump: None,
13502 stderr_pump: None,
13503 stderr_ring: Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default()))),
13504 spawned_at_ms: 0,
13505 spawned_from: PathBuf::from("true"),
13506 spawned_file_identity: None,
13507 process_start_time: None,
13508 process_identity: None,
13509 pid,
13510 roster_guard: None,
13511 #[cfg(target_os = "macos")]
13512 privacy_exec: None,
13513 spawn_failure: None,
13514 };
13515
13516 child.wait().await.expect("reap short-lived child");
13517
13518 assert!(
13519 !module.exists(),
13520 "reaping the supervised child must remove its empty cgroup"
13521 );
13522 }
13523
13524 #[test]
13525 fn non_empty_cgroup_removal_is_reported_without_blocking_teardown() {
13526 let root = ScratchDir::new("supervisor-non-empty-cgroup");
13527 fs::write(root.join("cgroup.procs"), b"").expect("write scratch cgroup marker");
13528 let placement = subc_cgroup::prepare_at(&root)
13529 .expect("prepare scratch cgroup root")
13530 .expect("scratch root has a cgroup.procs marker");
13531 let module = placement
13532 .module_path("surviving-module")
13533 .expect("create scratch module cgroup");
13534 fs::write(module.join("surviving-process"), b"still present")
13535 .expect("make scratch cgroup non-empty");
13536 let (logs, _guard) = crate::router::test_log::log_capture(tracing::Level::WARN);
13537
13538 remove_module_cgroup(&placement, "surviving-module");
13539
13540 let logs = crate::router::test_log::captured_logs(&logs);
13541 assert!(
13542 module.exists(),
13543 "failed removal must leave the cgroup intact"
13544 );
13545 assert!(
13546 logs.contains("could not remove module cgroup after process exit; continuing teardown")
13547 && logs.contains("surviving-module"),
13548 "best-effort removal must report the failure without returning it: {logs}"
13549 );
13550 }
13551
13552 #[test]
13553 fn cgroup_pre_exec_spawn_failure_names_the_cgroup_path() {
13554 let cgroup_path = PathBuf::from("/sys/fs/cgroup/subc-modules/broken-module");
13555 let reason = SuperviseError::Spawn {
13556 program: PathBuf::from("/bin/true"),
13557 source: io::Error::from_raw_os_error(13),
13558 cgroup_path: Some(cgroup_path.clone()),
13559 }
13560 .to_string();
13561
13562 assert!(
13563 reason.contains(&cgroup_path.display().to_string()),
13564 "a pre_exec spawn failure must name the cgroup path: {reason}"
13565 );
13566 }
13567}
13568
13569#[cfg(test)]
13570mod spawn_subscriber_lag_tests {
13571 use super::*;
13572
13573 #[tokio::test]
13578 async fn lagged_spawn_subscriber_receives_a_terminal_lagged_error_after_its_queued_frames() {
13579 let feed = SpawnEventFeed::default();
13580 feed.configure_incarnation("lag-incarnation".to_string());
13581 let (tx, mut rx) = mpsc::channel(1);
13584 feed.subscribe(ConnectionId::new(1), 7, 1, None, FrameSink::new(tx))
13585 .expect("subscribe");
13586 let emitted = SPAWN_SUBSCRIBER_BUFFER + 16;
13587 for index in 0..emitted {
13588 feed.emit_spawned(&format!("lag-module-{index}"), 1000, 0);
13589 tokio::task::yield_now().await;
13592 }
13593 assert_eq!(
13594 feed.subscriber_count(),
13595 0,
13596 "the lagged subscriber must be removed"
13597 );
13598
13599 let mut data = Vec::new();
13600 let mut last = None;
13601 loop {
13602 let next = tokio::time::timeout(Duration::from_secs(5), rx.recv())
13603 .await
13604 .expect("the forwarder must finish once the subscriber is dropped");
13605 let Some(outbound) = next else { break };
13606 let frame = outbound.frame;
13607 if frame.header.ty == FrameType::StreamData {
13608 assert!(last.is_none(), "no data may follow the terminal frame");
13609 let event: SpawnEvent = serde_json::from_slice(&frame.body).unwrap();
13610 data.push(event.cursor.seq);
13611 } else {
13612 assert!(last.is_none(), "exactly one terminal frame");
13613 last = Some(frame);
13614 }
13615 }
13616 assert!(!data.is_empty(), "queued frames drain before the terminal");
13617 for pair in data.windows(2) {
13618 assert_eq!(
13619 pair[1],
13620 pair[0] + 1,
13621 "queued frames arrive dense and in order"
13622 );
13623 }
13624 let terminal = last.expect("a lagged subscriber must receive a terminal frame");
13625 assert_eq!(terminal.header.ty, FrameType::Error);
13626 assert_eq!(terminal.header.corr, 7);
13627 let body: subc_protocol::ErrorBody = serde_json::from_slice(&terminal.body).unwrap();
13628 assert_eq!(body.code, SPAWN_SUBSCRIBER_LAGGED_CODE);
13629 let detail = body.detail.expect("lagged error carries detail");
13630 assert_eq!(
13631 detail["first_undelivered_cursor"]["seq"],
13632 data.last().unwrap() + 1,
13633 "the named cursor is the first event the subscriber did not receive"
13634 );
13635 assert_eq!(
13636 detail["first_undelivered_cursor"]["daemon_incarnation"],
13637 "lag-incarnation"
13638 );
13639 }
13640}
13641
13642#[cfg(test)]
13643mod terminal_history_read_concurrency_tests {
13644 use super::*;
13645 use crate::terminal_journal::read_pause;
13646 use cortexkit_test_support::ScratchDir;
13647 use std::sync::mpsc as std_mpsc;
13648
13649 fn journaled_ring(
13650 journal: &Arc<crate::terminal_journal::TerminalJournal>,
13651 ) -> Arc<Mutex<TerminalRing>> {
13652 Arc::new(Mutex::new(
13653 TerminalRing::new(TerminalRingConfig::default(), 1)
13654 .with_journal(Some(Arc::clone(journal))),
13655 ))
13656 }
13657
13658 fn crash(at_ms: u64) -> ExitReport {
13659 ExitReport {
13660 kind: ExitKind::Crash,
13661 code: Some(1),
13662 signal: None,
13663 at_ms,
13664 }
13665 }
13666
13667 fn record_within(
13670 module_id: &'static str,
13671 ring: &Arc<Mutex<TerminalRing>>,
13672 at_ms: u64,
13673 bound: Duration,
13674 ) -> bool {
13675 let ring = Arc::clone(ring);
13676 let (done, done_rx) = std_mpsc::channel();
13677 std::thread::spawn(move || {
13678 record_terminal(
13679 module_id,
13680 &ring,
13681 &SpawnEventFeed::default(),
13682 &crash(at_ms),
13683 TerminalDisposition::Restarting,
13684 );
13685 let _ = done.send(());
13686 });
13687 done_rx.recv_timeout(bound).is_ok()
13688 }
13689
13690 #[test]
13695 fn exits_recorded_during_a_paused_history_read_are_not_blocked_or_half_merged() {
13696 let dir = ScratchDir::new("terminal-history-concurrent-read");
13697 let path = dir.join("terminals.jsonl");
13698 let journal = Arc::new(crate::terminal_journal::TerminalJournal::open(
13699 path.clone(),
13700 "daemon".into(),
13701 ));
13702 let reader_ring = journaled_ring(&journal);
13703 let other_ring = journaled_ring(&journal);
13704 assert!(record_within(
13705 "reader-module",
13706 &reader_ring,
13707 10,
13708 Duration::from_secs(5)
13709 ));
13710
13711 let (started, release) = read_pause::install(&path);
13712 let reading = {
13713 let ring = Arc::clone(&reader_ring);
13714 std::thread::spawn(move || durable_terminal_history_of(&ring, "reader-module"))
13715 };
13716 started
13717 .recv_timeout(Duration::from_secs(5))
13718 .expect("the history read reached its pause");
13719
13720 let bound = Duration::from_secs(1);
13721 assert!(
13722 record_within("other-module", &other_ring, 20, bound),
13723 "another module's exit waited on a history read (journal writer held)"
13724 );
13725 assert!(
13726 record_within("reader-module", &reader_ring, 30, bound),
13727 "the read module's own exit waited on its history read (ring held)"
13728 );
13729
13730 drop(release);
13731 let paused = reading.join().unwrap();
13732 assert_eq!(
13733 paused.entries.iter().map(|e| e.at_ms).collect::<Vec<_>>(),
13734 vec![10],
13735 "an exit recorded after the read began lands in neither half of it"
13736 );
13737 assert_eq!(paused.journal_skipped_lines, 0);
13738 assert_eq!(paused.journal_read_errors, 0);
13739
13740 let after = durable_terminal_history_of(&reader_ring, "reader-module");
13741 assert_eq!(
13742 after.entries.iter().map(|e| e.at_ms).collect::<Vec<_>>(),
13743 vec![10, 30],
13744 "the next read merges ring and journal with no duplicate"
13745 );
13746 assert_eq!(after.journal_skipped_lines, 0);
13747 }
13748}
13749
13750#[cfg(test)]
13755mod stderr_settle_tests {
13756 use std::{
13757 future::Future,
13758 io,
13759 pin::Pin,
13760 sync::{Arc, Mutex},
13761 task::{Context, Poll},
13762 time::Duration,
13763 };
13764
13765 use tokio::{
13766 io::{AsyncRead, ReadBuf},
13767 sync::oneshot,
13768 time::Instant,
13769 };
13770
13771 use super::{settle_stderr_pump, settle_stdout_pump, StderrPump};
13772 use crate::stderr_tail::{
13773 pump_stderr_to, pump_stdout_to, split_capture_stamp, untimed, CaptureState,
13774 ChildOutputSink, OutputSink, StderrRing, StderrTailConfig, TailEntry,
13775 };
13776
13777 const BOUND: Duration = Duration::from_millis(250);
13778
13779 struct HeldReader {
13783 before: Option<Vec<u8>>,
13784 gate: Option<oneshot::Receiver<()>>,
13785 after: io::Cursor<Vec<u8>>,
13786 }
13787
13788 impl AsyncRead for HeldReader {
13789 fn poll_read(
13790 mut self: Pin<&mut Self>,
13791 cx: &mut Context<'_>,
13792 buf: &mut ReadBuf<'_>,
13793 ) -> Poll<io::Result<()>> {
13794 if let Some(bytes) = self.before.take() {
13795 buf.put_slice(&bytes);
13796 return Poll::Ready(Ok(()));
13797 }
13798 if let Some(gate) = self.gate.as_mut() {
13799 match Pin::new(gate).poll(cx) {
13800 Poll::Pending => return Poll::Pending,
13801 Poll::Ready(_) => self.gate = None,
13802 }
13803 }
13804 Pin::new(&mut self.after).poll_read(cx, buf)
13805 }
13806 }
13807
13808 struct DiscardSink;
13809
13810 impl OutputSink for DiscardSink {
13811 fn write_line(&mut self, _line: &[u8]) {}
13812 }
13813
13814 fn line(text: &str) -> TailEntry {
13815 TailEntry::Line {
13816 text: text.to_string(),
13817 truncated: false,
13818 at_ms: None,
13819 }
13820 }
13821
13822 fn lock(ring: &Arc<Mutex<StderrRing>>) -> std::sync::MutexGuard<'_, StderrRing> {
13823 ring.lock().unwrap()
13824 }
13825
13826 fn held_pump(
13830 ring: &Arc<Mutex<StderrRing>>,
13831 before: &str,
13832 after: &str,
13833 ) -> (StderrPump, oneshot::Sender<()>) {
13834 let generation = lock(ring).begin_process();
13835 let (release, gate) = oneshot::channel();
13836 let reader = HeldReader {
13837 before: Some(before.as_bytes().to_vec()),
13838 gate: Some(gate),
13839 after: io::Cursor::new(after.as_bytes().to_vec()),
13840 };
13841 let task = tokio::spawn(pump_stderr_to(
13842 reader,
13843 Arc::clone(ring),
13844 generation,
13845 DiscardSink,
13846 ));
13847 (StderrPump { task, generation }, release)
13848 }
13849
13850 async fn wait_until(ring: &Arc<Mutex<StderrRing>>, done: impl Fn(&StderrRing) -> bool) {
13851 for _ in 0..1000 {
13852 if done(&lock(ring)) {
13853 return;
13854 }
13855 tokio::time::sleep(Duration::from_millis(1)).await;
13856 }
13857 panic!(
13858 "ring never reached the expected state: {:?}",
13859 lock(ring).snapshot(None, None)
13860 );
13861 }
13862
13863 #[tokio::test(start_paused = true)]
13864 async fn a_crash_line_the_reader_had_not_reached_by_the_bound_is_kept_before_the_restart() {
13865 let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
13866 let (pump, release) = held_pump(&ring, "booting\n", "config error: missing storage\n");
13867
13868 settle_stderr_pump("crasher", &ring, pump, BOUND).await;
13869 let before_release = lock(&ring).snapshot(None, None);
13870 assert!(
13871 matches!(before_release.capture, CaptureState::Incomplete { .. }),
13872 "a reader that has not reached EOF cannot claim a whole tail: {before_release:?}"
13873 );
13874
13875 let next = lock(&ring).begin_process();
13878 lock(&ring).push_line_from(next, "next process booting");
13879 release.send(()).unwrap();
13880 wait_until(&ring, |ring| {
13881 ring.snapshot(None, None).capture == CaptureState::Captured
13882 })
13883 .await;
13884
13885 assert_eq!(
13886 untimed(lock(&ring).snapshot(None, None).entries),
13887 vec![
13888 line("booting"),
13889 line("config error: missing storage"),
13890 TailEntry::ProcessStart,
13891 line("next process booting"),
13892 ],
13893 "the crash's last line must survive a slow reader and stay in the crashed process's section"
13894 );
13895 }
13896
13897 #[tokio::test(start_paused = true)]
13898 async fn a_pipe_held_open_by_a_descendant_reads_incomplete_without_delaying_the_restart_past_the_bound(
13899 ) {
13900 let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
13901 let (pump, _held) = held_pump(&ring, "parent exiting\n", "");
13904
13905 let started = Instant::now();
13906 settle_stderr_pump("orphaning", &ring, pump, BOUND).await;
13907 assert_eq!(
13908 started.elapsed(),
13909 BOUND,
13910 "the restart must wait exactly the bound for a pipe that stays open, no longer"
13911 );
13912
13913 let next = lock(&ring).begin_process();
13914 lock(&ring).push_line_from(next, "next process booting");
13915 tokio::time::sleep(Duration::from_secs(60)).await;
13916
13917 let snapshot = lock(&ring).snapshot(None, None);
13918 match &snapshot.capture {
13919 CaptureState::Incomplete { reason } => assert!(
13920 reason.contains("had not reached EOF") && reason.contains("250ms"),
13921 "the reason must say what is missing and after how long: {reason}"
13922 ),
13923 other => panic!("expected Incomplete while the pipe is held open, got {other:?}"),
13924 }
13925 assert_eq!(
13926 untimed(snapshot.entries),
13927 vec![
13928 line("parent exiting"),
13929 TailEntry::ProcessStart,
13930 line("next process booting"),
13931 ]
13932 );
13933 }
13934
13935 #[tokio::test(start_paused = true)]
13936 async fn a_reader_that_reaches_eof_within_the_bound_leaves_the_tail_captured() {
13937 let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
13938 let (pump, release) = held_pump(&ring, "one\n", "two\n");
13939 release.send(()).unwrap();
13940
13941 settle_stderr_pump("clean", &ring, pump, BOUND).await;
13942
13943 let snapshot = lock(&ring).snapshot(None, None);
13944 assert_eq!(snapshot.capture, CaptureState::Captured);
13945 assert_eq!(untimed(snapshot.entries), vec![line("one"), line("two")]);
13946 }
13947
13948 #[tokio::test(start_paused = true)]
13949 async fn stdout_buffered_past_the_exit_drain_bound_reaches_the_capture_file() {
13950 const BURST: usize = 150;
13951 let capture = cortexkit_test_support::ScratchDir::new("stdout-exit-drain");
13952 let path = capture.join("two-pipe.stderr.log");
13953 let sink = ChildOutputSink::open(&path, cortexkit_log::Retention::default()).unwrap();
13954 let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
13955 let generation = lock(&ring).begin_process();
13956 let stdout = (0..BURST)
13957 .map(|i| format!("out-{i:04}-{}\n", "o".repeat(64)))
13958 .collect::<String>();
13959 let stderr = (0..BURST)
13960 .map(|i| format!("err-{i:04}-{}\n", "e".repeat(64)))
13961 .collect::<String>();
13962 let first_chunk = stdout.find('\n').unwrap() + 1 + "out-0001-".len();
13967 let (release, gate) = oneshot::channel();
13968 let reader = HeldReader {
13969 before: Some(stdout.as_bytes()[..first_chunk].to_vec()),
13970 gate: Some(gate),
13971 after: io::Cursor::new(stdout.as_bytes()[first_chunk..stdout.len() - 1].to_vec()),
13972 };
13973 let (finished, completion) = oneshot::channel();
13974 let stdout_sink = sink.clone();
13975 let pump = tokio::spawn(async move {
13976 pump_stdout_to(reader, stdout_sink).await;
13977 finished.send(()).unwrap();
13978 });
13979 pump_stderr_to(io::Cursor::new(stderr.into_bytes()), ring, generation, sink).await;
13980
13981 let started = Instant::now();
13982 settle_stdout_pump("two-pipe", pump, BOUND).await;
13983 assert_eq!(
13984 started.elapsed(),
13985 BOUND,
13986 "a late reader must not hold up restart"
13987 );
13988 assert_eq!(
13989 std::fs::read_to_string(&path).unwrap().lines().count(),
13990 BURST + 1
13991 );
13992
13993 release
13994 .send(())
13995 .expect("stdout reader must survive the exit drain bound");
13996 tokio::time::timeout(Duration::from_secs(1), completion)
13997 .await
13998 .expect("released stdout reader must reach EOF")
13999 .expect("stdout capture must finish at EOF, not be cancelled");
14000 let contents = std::fs::read_to_string(&path).unwrap();
14001 let mut lines = contents
14002 .lines()
14003 .map(|line| split_capture_stamp(line).expect("capture stamp").1)
14004 .collect::<Vec<_>>();
14005 let mut expected = (0..BURST)
14006 .flat_map(|i| {
14007 [
14008 format!("out-{i:04}-{}", "o".repeat(64)),
14009 format!("err-{i:04}-{}", "e".repeat(64)),
14010 ]
14011 })
14012 .collect::<Vec<_>>();
14013 lines.sort_unstable();
14014 expected.sort_unstable();
14015 assert_eq!(
14016 lines, expected,
14017 "both pipes must retain every complete line exactly once"
14018 );
14019 }
14020}
14021
14022#[cfg(all(test, windows))]
14036mod job_containment_tests {
14037 use super::*;
14038 use cortexkit_test_support::ScratchDir;
14039 use std::{
14040 path::{Path, PathBuf},
14041 sync::{Arc, Mutex},
14042 time::{Duration, Instant},
14043 };
14044
14045 fn stub_path() -> PathBuf {
14051 let mut path = std::env::current_exe().expect("current_exe available in tests");
14052 path.pop();
14053 path.pop();
14054 path.push("fake-aft-stub.exe");
14055 assert!(
14056 path.exists(),
14057 "fake-aft-stub not built at {}: run `cargo test -p subc-core` (which builds \
14058 [[bin]] targets) rather than `cargo test -p subc-core --lib` (which does not)",
14059 path.display()
14060 );
14061 path
14062 }
14063
14064 fn read_grandchild_pid(path: &Path) -> u32 {
14066 let deadline = Instant::now() + Duration::from_secs(10);
14067 loop {
14068 if let Ok(contents) = std::fs::read_to_string(path) {
14069 if let Ok(pid) = contents.trim().parse() {
14070 return pid;
14071 }
14072 }
14073 assert!(
14074 Instant::now() < deadline,
14075 "the stub never recorded a grandchild pid at {}",
14076 path.display()
14077 );
14078 std::thread::sleep(Duration::from_millis(10));
14079 }
14080 }
14081
14082 struct Fixture {
14085 _dir: ScratchDir,
14086 module_id: String,
14087 grandchild: u32,
14088 child: Option<SupervisedChild>,
14089 registry: Arc<Registry>,
14090 snapshot: Arc<Mutex<SupervisorSnapshot>>,
14091 terminal_ring: Arc<Mutex<TerminalRing>>,
14092 spawn_events: SpawnEventFeed,
14093 }
14094
14095 fn fixture(label: &str, module_id: &str) -> Fixture {
14096 let dir = ScratchDir::new(label);
14097 let pid_file = dir.join("grandchild.pid");
14098 let supervisor = Supervisor::new_for_test(
14099 Arc::new(Registry::default()),
14100 RestartPolicy::new(3, Duration::ZERO),
14101 );
14102 let runtime = supervisor.runtime_config();
14103 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
14104 let spec = ModuleSpec {
14105 module_id: module_id.to_string(),
14106 program: stub_path(),
14107 args: Vec::new(),
14111 env: vec![
14112 ("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string()),
14113 (
14114 "FAKE_AFT_GRANDCHILD_PID_FILE".to_string(),
14115 pid_file.display().to_string(),
14116 ),
14117 ],
14118 reserved: false,
14119 reserved_prefixes: Vec::new(),
14120 protocol: ModuleProtocol::Subc,
14121 overlap: Default::default(),
14122 };
14123 let child = spawn_and_mark_running(&spec, &runtime, &snapshot)
14124 .expect("spawn the supervised fixture");
14125 let grandchild = read_grandchild_pid(&pid_file);
14126 Fixture {
14127 _dir: dir,
14128 module_id: module_id.to_string(),
14129 grandchild,
14130 child: Some(child),
14131 registry: Arc::new(Registry::default()),
14132 snapshot,
14133 terminal_ring: Arc::clone(&runtime.terminal_ring),
14134 spawn_events: SpawnEventFeed::default(),
14135 }
14136 }
14137
14138 impl Fixture {
14139 async fn drain(&mut self) {
14141 let child = self
14142 .child
14143 .take()
14144 .expect("the fixture child is still present");
14145 drain_child_to_state(
14146 &self.module_id,
14147 ModuleProtocol::Subc,
14148 StopNotice::NotSent,
14151 &self.registry,
14152 None,
14153 &self.snapshot,
14154 &self.terminal_ring,
14155 &self.spawn_events,
14156 child,
14157 Duration::from_millis(500),
14158 ModuleState::Stopped,
14159 Some(false),
14160 )
14161 .await
14162 .expect("drain the supervised fixture");
14163 }
14164 }
14165
14166 #[tokio::test]
14172 async fn teardown_reaps_the_grandchild() {
14173 let mut fixture = fixture("teardown-grandchild", "tree-teardown");
14174 let grandchild = fixture.grandchild;
14175
14176 assert!(
14177 subc_jobobject::process_exists(grandchild),
14178 "grandchild {grandchild} must be alive before teardown, or this proves nothing"
14179 );
14180
14181 fixture.drain().await;
14182
14183 assert!(
14184 subc_jobobject::wait_for_process_exit(grandchild, Duration::from_secs(10)),
14185 "grandchild {grandchild} outlived module teardown: the tree was not contained"
14186 );
14187 }
14188
14189 #[test]
14202 fn an_uncontained_grandchild_survives_a_direct_child_kill() {
14203 let dir = ScratchDir::new("teardown-uncontained");
14204 let pid_file = dir.join("grandchild.pid");
14205 let mut child = std::process::Command::new(stub_path())
14206 .env("FAKE_AFT_NEVER_CONNECT", "1")
14207 .env(
14208 "FAKE_AFT_GRANDCHILD_PID_FILE",
14209 pid_file.display().to_string(),
14210 )
14211 .stdin(std::process::Stdio::null())
14212 .stdout(std::process::Stdio::null())
14213 .stderr(std::process::Stdio::null())
14214 .spawn()
14215 .expect("spawn the uncontained fixture");
14216 let grandchild = read_grandchild_pid(&pid_file);
14217
14218 child.kill().expect("kill the direct child");
14220 let _ = child.wait();
14221
14222 assert!(
14223 subc_jobobject::process_exists(grandchild),
14224 "grandchild {grandchild} died with the direct child, so this control no longer \
14225 distinguishes contained from uncontained teardown and the regression test is \
14226 passing vacuously"
14227 );
14228
14229 kill_tree(grandchild);
14232 }
14233
14234 #[tokio::test]
14243 async fn dropping_containment_reaps_the_grandchild() {
14244 let mut fixture = fixture("drop-containment", "tree-drop");
14245 let grandchild = fixture.grandchild;
14246
14247 assert!(subc_jobobject::process_exists(grandchild));
14248
14249 fixture.child.as_mut().expect("child present").job = None;
14251
14252 assert!(
14253 subc_jobobject::wait_for_process_exit(grandchild, Duration::from_secs(10)),
14254 "grandchild {grandchild} survived the containment handle closing, so a daemon \
14255 crash would leave the tree behind"
14256 );
14257 }
14258
14259 fn kill_tree(pid: u32) {
14261 let _ = std::process::Command::new("taskkill.exe")
14262 .args(["/PID", &pid.to_string(), "/T", "/F"])
14263 .stdin(std::process::Stdio::null())
14264 .stdout(std::process::Stdio::null())
14265 .stderr(std::process::Stdio::null())
14266 .status();
14267 assert!(
14268 subc_jobobject::wait_for_process_exit(pid, Duration::from_secs(10)),
14269 "could not clean up grandchild {pid}"
14270 );
14271 }
14272}
14273
14274#[cfg(test)]
14275mod privacy_trampoline_configuration_tests {
14276 #[tokio::test]
14277 #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
14278 async fn macos_spawn_without_a_configured_trampoline_refuses_by_name() {
14279 #[cfg(target_os = "macos")]
14280 {
14281 let supervisor = super::Supervisor::new(
14282 std::sync::Arc::new(crate::Registry::default()),
14283 super::RestartPolicy::default(),
14284 );
14285 let error = supervisor.spawn(spec()).unwrap_err();
14286 assert!(
14287 error
14288 .to_string()
14289 .contains("no privacy trampoline configured"),
14290 "{error}"
14291 );
14292 }
14293 }
14294
14295 #[tokio::test]
14296 #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
14297 async fn macos_wrong_trampoline_is_rejected_by_the_startup_probe() {
14298 #[cfg(target_os = "macos")]
14299 {
14300 let supervisor = super::Supervisor::new(
14301 std::sync::Arc::new(crate::Registry::default()),
14302 super::RestartPolicy::default(),
14303 )
14304 .with_privacy_trampoline(std::env::current_exe().unwrap());
14305 let error = supervisor.spawn(spec()).unwrap_err();
14306 assert!(
14307 error
14308 .to_string()
14309 .contains("binary does not implement the privacy trampoline protocol"),
14310 "{error}"
14311 );
14312 }
14313 }
14314
14315 #[cfg(target_os = "macos")]
14316 fn spec() -> super::ModuleSpec {
14317 super::ModuleSpec {
14318 module_id: "privacy-configuration".into(),
14319 program: "/bin/sleep".into(),
14320 args: vec!["30".into()],
14321 env: vec![],
14322 reserved: false,
14323 reserved_prefixes: vec![],
14324 protocol: subc_control::ModuleProtocol::None,
14325 overlap: super::ModuleOverlap::Exclusive,
14326 }
14327 }
14328}
14329
14330#[cfg(test)]
14331mod privacy_exec_boundary_tests {
14332 #[cfg(target_os = "macos")]
14333 use super::*;
14334 #[cfg(target_os = "macos")]
14335 use std::{
14336 io::{Read, Write},
14337 net::{TcpListener, TcpStream},
14338 };
14339
14340 #[cfg(target_os = "macos")]
14343 pub(super) fn before_image_sample(spec: &ModuleSpec, pid: u32) {
14344 if let Some((_, path)) = spec
14345 .env
14346 .iter()
14347 .find(|(key, _)| key == "SUBC_TEST_PRIVACY_SAMPLE_BARRIER")
14348 {
14349 let mut barrier = TcpStream::connect(path).unwrap();
14350 barrier
14351 .set_read_timeout(Some(Duration::from_secs(30)))
14352 .unwrap();
14353 barrier.write_all(&pid.to_ne_bytes()).unwrap();
14354 let mut release = [0];
14355 barrier.read_exact(&mut release).unwrap();
14356 assert_eq!(&release, b"X");
14357 }
14358 }
14359
14360 #[cfg(target_os = "macos")]
14361 fn spec(program: &str, args: &[&str]) -> ModuleSpec {
14362 ModuleSpec {
14363 module_id: "privacy-boundary".into(),
14364 program: program.into(),
14365 args: args.iter().map(|arg| (*arg).into()).collect(),
14366 env: vec![],
14367 reserved: false,
14368 reserved_prefixes: vec![],
14369 protocol: ModuleProtocol::None,
14370 overlap: ModuleOverlap::Exclusive,
14371 }
14372 }
14373
14374 #[cfg(target_os = "macos")]
14375 async fn accept(listener: TcpListener) -> TcpStream {
14376 let listener = tokio::net::TcpListener::from_std({
14378 listener.set_nonblocking(true).unwrap();
14379 listener
14380 })
14381 .unwrap();
14382 let (stream, _) = tokio::time::timeout(Duration::from_secs(30), listener.accept())
14383 .await
14384 .unwrap()
14385 .unwrap();
14386 let stream = stream.into_std().unwrap();
14387 stream.set_nonblocking(false).unwrap();
14388 stream
14389 .set_read_timeout(Some(Duration::from_secs(30)))
14390 .unwrap();
14391 stream
14392 }
14393
14394 #[tokio::test]
14395 #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
14396 async fn macos_roster_withholds_a_nonnull_trampoline_image_until_exec_confirmation() {
14397 #[cfg(target_os = "macos")]
14398 {
14399 let root = cortexkit_test_support::ScratchDir::new("privacy-roster-barrier");
14400 let exec_listener = TcpListener::bind("127.0.0.1:0").unwrap();
14403 let sample_listener = TcpListener::bind("127.0.0.1:0").unwrap();
14404 let record = root.join("live-children.json");
14405 let supervisor =
14406 Supervisor::new_for_test(Arc::new(Registry::default()), RestartPolicy::default())
14407 .with_live_children_record(&record);
14408 let runtime = supervisor.runtime_config();
14409 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
14410 let mut spec = spec("/bin/sleep", &["30"]);
14411 spec.env = vec![
14412 (
14413 "SUBC_TEST_PRIVACY_EXEC_BARRIER".into(),
14414 exec_listener.local_addr().unwrap().to_string(),
14415 ),
14416 (
14417 "SUBC_TEST_PRIVACY_SAMPLE_BARRIER".into(),
14418 sample_listener.local_addr().unwrap().to_string(),
14419 ),
14420 ];
14421 let spawn = tokio::task::spawn_blocking(move || {
14422 spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap()
14423 });
14424 let mut sample = accept(sample_listener).await;
14425 let mut pid = [0; 4];
14426 sample.read_exact(&mut pid).unwrap();
14427 let pid = u32::from_ne_bytes(pid);
14428 let mut exec = accept(exec_listener).await;
14429 let mut ready = [0];
14430 exec.read_exact(&mut ready).unwrap();
14431 assert_eq!(&ready, b"R");
14432 let trampoline = subc_os::file_identity(&test_privacy_trampoline()).unwrap();
14435 assert_eq!(
14436 observe_spawned_image(pid).unwrap().executable,
14437 Some(trampoline)
14438 );
14439 sample.write_all(b"X").unwrap();
14440 let mut child = spawn.await.unwrap();
14441 let early = crate::live_children::read_record(&record).unwrap();
14442 assert_eq!(early.len(), 1);
14443 assert_eq!(early[0].pid, pid);
14444 assert_eq!(
14445 early[0].executable, None,
14446 "unconfirmed trampoline image entered the roster"
14447 );
14448 assert!(child.report_ready.get().is_none());
14449 child.privacy_exec.as_mut().unwrap().deadline =
14452 tokio::time::Instant::now() + Duration::from_secs(30);
14453 exec.write_all(b"X").unwrap();
14454 child.confirm_privacy_exec().await;
14455 assert_eq!(child.spawn_failure, None);
14456 assert!(child.report_ready.get().is_some());
14457 let confirmed = crate::live_children::read_record(&record).unwrap();
14458 let module = subc_os::file_identity(std::path::Path::new("/bin/sleep")).unwrap();
14459 assert_ne!(module, trampoline);
14460 assert_eq!(confirmed[0].executable, Some(module.into()));
14461 child.start_kill().unwrap();
14462 child.wait().await.unwrap();
14463 child.release_roster();
14464 }
14465 }
14466
14467 #[tokio::test]
14468 #[cfg_attr(not(target_os = "macos"), ignore = "requires macOS privacy trampoline")]
14469 async fn macos_already_exited_121_in_try_wait_is_a_module_exit_not_a_trampoline_refusal() {
14470 #[cfg(target_os = "macos")]
14471 {
14472 let registry = Arc::new(Registry::default());
14473 let policy = RestartPolicy::new(0, Duration::ZERO);
14474 let supervisor = Supervisor::new_for_test(Arc::clone(®istry), policy);
14475 let runtime = supervisor.runtime_config();
14476 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
14477 let spec = spec("/bin/sh", &["-c", "exit 121"]);
14478 let mut child = spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap();
14482 let pid = child.pid;
14483 tokio::task::spawn_blocking(move || {
14484 subc_os::fork_exec_test::wait_for_child_exit_without_reaping(pid)
14485 })
14486 .await
14487 .unwrap()
14488 .unwrap();
14489 child.privacy_exec.as_mut().unwrap().deadline =
14490 tokio::time::Instant::now() + Duration::from_secs(30);
14491 let status = child.wait().await.unwrap();
14492 assert_eq!(status.code(), Some(121));
14493 assert!(child.privacy_exec.is_none());
14494 assert!(
14495 child.report_ready.get().is_none(),
14496 "an exited module must not publish a live pid"
14497 );
14498 let report = classify_reaped_child_exit(&snapshot, &child, &status);
14499 on_child_exit(
14500 &spec,
14501 policy,
14502 ®istry,
14503 &snapshot,
14504 &runtime.terminal_ring,
14505 &runtime.spawn_events,
14506 &runtime.child_roster,
14507 report,
14508 )
14509 .await;
14510 let state = lock_snapshot(&snapshot).unwrap();
14511 assert_eq!(state.state, ModuleState::Failed);
14512 assert_eq!(state.last_exit.as_ref().unwrap().code, Some(121));
14513 assert_eq!(state.reported_pid(), None);
14514 drop(state);
14515 let history = runtime.terminal_ring.lock().unwrap().snapshot();
14516 assert_eq!(history.entries.len(), 1);
14517 let terminal = &history.entries[0];
14518 assert_eq!(terminal.exit_code, Some(121));
14519 assert_eq!(terminal.exit_kind, subc_control::TerminalExitKind::Crash);
14520 assert_eq!(terminal.disposition, TerminalDisposition::Failed);
14521 assert_eq!(
14522 terminal.disposition_detail.as_deref(),
14523 Some(policy.budget_exhausted_detail().as_str()),
14524 "module exit 121 was classified as a trampoline refusal: {terminal:?}"
14525 );
14526 assert_eq!(child.spawn_failure, None);
14527 child.release_roster();
14528 }
14529 }
14530}
14531
14532#[cfg(all(test, unix))]
14536mod launch_nonce_descriptor_tests {
14537 use super::{ChildRoster, ModuleProtocol, ModuleSpec, SupervisorHandle};
14538 use crate::stderr_tail::{StderrRing, StderrTailConfig};
14539 use cortexkit_test_support::ScratchDir;
14540 use std::{
14541 path::PathBuf,
14542 sync::{Arc, Mutex},
14543 time::{Duration, Instant},
14544 };
14545
14546 async fn probe(role: super::SpawnRole) {
14547 let scratch = ScratchDir::new("launch-nonce-descriptor");
14548 let fd_copy = scratch.join("from-descriptor");
14549 let env_copy = scratch.join("environment");
14550 let script = format!(
14551 "env > '{env}.tmp' && mv '{env}.tmp' '{env}'; cat <&3 > '{fd}.tmp' && mv '{fd}.tmp' '{fd}'; sleep 30",
14552 fd = fd_copy.display(), env = env_copy.display(),
14553 );
14554 let xdg = |name: &str| (name.to_string(), scratch.join(name).display().to_string());
14555 let spec = ModuleSpec {
14556 module_id: "nonce-descriptor-probe".to_string(),
14557 program: PathBuf::from("/bin/sh"),
14558 args: vec!["-c".to_string(), script],
14559 env: vec![
14560 xdg("XDG_DATA_HOME"),
14561 xdg("XDG_RUNTIME_DIR"),
14562 xdg("XDG_CONFIG_HOME"),
14563 ("SUBC_LAUNCH_NONCE".to_string(), "must-not-leak".to_string()),
14564 ],
14565 reserved: true,
14566 reserved_prefixes: Vec::new(),
14567 protocol: ModuleProtocol::Subc,
14568 overlap: Default::default(),
14569 };
14570 let handle = SupervisorHandle::new();
14571 let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
14572 let roster = ChildRoster::default();
14573 #[cfg(target_os = "macos")]
14574 {
14575 let path = super::test_privacy_trampoline();
14576 roster.set_privacy_trampoline(super::probe_privacy_trampoline(&path).map(|()| path));
14577 }
14578 let child = super::spawn_child_in_slot(
14579 &spec,
14580 None,
14581 Some(&handle),
14582 &ring,
14583 None,
14584 &roster,
14585 #[cfg(target_os = "linux")]
14586 None,
14587 role,
14588 matches!(role, super::SpawnRole::SwapCandidate),
14589 )
14590 .expect("spawn probe");
14591 let deadline = Instant::now() + Duration::from_secs(10);
14592 while !(fd_copy.exists() && env_copy.exists()) {
14593 assert!(Instant::now() < deadline, "probe never wrote its copies");
14594 tokio::time::sleep(Duration::from_millis(20)).await;
14595 }
14596 let nonce = std::fs::read_to_string(fd_copy).unwrap();
14597 assert!(!nonce.is_empty());
14598 let environment = std::fs::read_to_string(env_copy).unwrap();
14599 assert!(environment
14600 .lines()
14601 .any(|line| line.starts_with("SUBC_LAUNCH_NONCE_FD=3:")));
14602 let copy = environment
14603 .lines()
14604 .find_map(|line| line.strip_prefix("SUBC_LAUNCH_NONCE="));
14605 assert_eq!(
14606 copy, None,
14607 "Unix children must never receive the environment nonce"
14608 );
14609 if matches!(role, super::SpawnRole::Plain) {
14610 assert_eq!(
14611 handle.spawn_nonce(&spec.module_id).as_deref(),
14612 Some(nonce.as_str())
14613 );
14614 }
14615 drop(child);
14616 }
14617
14618 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
14619 async fn a_spawned_module_receives_its_nonce_only_on_descriptor_3() {
14620 probe(super::SpawnRole::Plain).await;
14621 }
14622
14623 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
14624 async fn swap_candidate_receives_its_nonce_only_on_descriptor_3() {
14625 probe(super::SpawnRole::SwapCandidate).await;
14626 }
14627}
14628
14629#[cfg(all(test, target_os = "linux"))]
14630mod cgroup_containment_tests {
14631 use super::*;
14632 use cortexkit_test_support::ScratchDir;
14633
14634 fn running(pid: u32) -> bool {
14635 std::fs::read_to_string(format!("/proc/{pid}/stat"))
14637 .ok()
14638 .and_then(|stat| {
14639 stat.rsplit_once(") ")
14640 .map(|(_, rest)| rest.starts_with('Z'))
14641 })
14642 .is_some_and(|zombie| !zombie)
14643 }
14644
14645 #[tokio::test]
14646 async fn linux_teardown_reaps_the_grandchild() {
14647 teardown_tree("linux_teardown_reaps_the_grandchild", false).await;
14648 }
14649
14650 #[tokio::test]
14651 async fn linux_shutdown_straggler_reaps_the_grandchild() {
14652 teardown_tree("linux_shutdown_straggler_reaps_the_grandchild", true).await;
14653 }
14654
14655 async fn teardown_tree(test_name: &str, shutdown: bool) {
14656 let dir = ScratchDir::new(test_name);
14657 let root = PathBuf::from(format!(
14658 "/sys/fs/cgroup/subc-tree-test-{test_name}-{}-{}",
14659 std::process::id(),
14660 unix_ms_now()
14661 ));
14662 if let Err(error) = std::fs::create_dir(&root) {
14663 assert!(
14664 std::env::var_os("SUBC_REQUIRE_CGROUP_TEST").is_none(),
14665 "required cgroup test cannot execute: {error}"
14666 );
14667 eprintln!(
14668 "SKIP {test_name}: no writable delegated cgroup at {}: {error}",
14669 root.display()
14670 );
14671 return;
14672 }
14673 let placement = subc_cgroup::prepare_at(&root)
14674 .expect("prepare isolated kernel cgroup")
14675 .expect("isolated cgroup is delegated");
14676 let module_id = "tree-teardown";
14677 let module = placement
14678 .module_path(module_id)
14679 .expect("create isolated module cgroup");
14680 if !module.join("cgroup.kill").exists() {
14681 std::fs::remove_dir(&module).unwrap();
14682 std::fs::remove_dir(root.join("subc-modules")).unwrap();
14683 std::fs::remove_dir(&root).unwrap();
14684 assert!(
14685 std::env::var_os("SUBC_REQUIRE_CGROUP_TEST").is_none(),
14686 "required cgroup.kill interface unavailable"
14687 );
14688 eprintln!("SKIP {test_name}: cgroup.kill unavailable (kernel < 5.14)");
14689 return;
14690 }
14691 let supervisor = Supervisor::new_for_test(
14692 Arc::new(Registry::default()),
14693 RestartPolicy::new(3, Duration::ZERO),
14694 )
14695 .with_cgroup_placement(Some(placement));
14696 let mut runtime = supervisor.runtime_config();
14697 runtime.child_roster = runtime
14698 .child_roster
14699 .for_module(Arc::new(Mutex::new(Duration::from_millis(100))));
14700 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
14701 let pid_file = dir.join("grandchild.pid");
14702 let spec = ModuleSpec {
14703 module_id: module_id.to_string(),
14704 program: PathBuf::from("/bin/sh"),
14705 args: vec![
14706 "-c".into(),
14707 "trap '' TERM; sleep 600 & echo $! > \"$1\"; wait".into(),
14708 "fixture".into(),
14709 pid_file.display().to_string(),
14710 ],
14711 env: ["XDG_DATA_HOME", "XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"]
14712 .into_iter()
14713 .map(|key| (key.to_string(), dir.display().to_string()))
14714 .collect(),
14715 reserved: false,
14716 reserved_prefixes: Vec::new(),
14717 protocol: ModuleProtocol::None,
14718 overlap: Default::default(),
14719 };
14720 let child =
14721 spawn_and_mark_running(&spec, &runtime, &snapshot).expect("spawn supervised tree");
14722 let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
14723 let grandchild: u32 = loop {
14724 if let Ok(contents) = std::fs::read_to_string(&pid_file) {
14725 if let Ok(pid) = contents.trim().parse() {
14726 break pid;
14727 }
14728 }
14729 assert!(
14730 tokio::time::Instant::now() < deadline,
14731 "grandchild pid was not recorded"
14732 );
14733 tokio::time::sleep(Duration::from_millis(10)).await;
14734 };
14735 assert!(
14736 running(grandchild),
14737 "grandchild must be alive before teardown"
14738 );
14739 if shutdown {
14740 let mut child = child;
14741 crate::child_roster::end_children_for_daemon_shutdown(
14742 &runtime.child_roster,
14743 false,
14744 std::future::pending(),
14745 )
14746 .await;
14747 child.wait().await.expect("reap shutdown straggler");
14748 } else {
14749 drain_child_to_state(
14750 module_id,
14751 ModuleProtocol::None,
14752 StopNotice::NotSent,
14753 &Registry::default(),
14754 None,
14755 &snapshot,
14756 &runtime.terminal_ring,
14757 &SpawnEventFeed::default(),
14758 child,
14759 Duration::from_millis(100),
14760 ModuleState::Stopped,
14761 Some(false),
14762 )
14763 .await
14764 .expect("real supervisor teardown");
14765 }
14766 let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
14767 while running(grandchild) && tokio::time::Instant::now() < deadline {
14768 tokio::time::sleep(Duration::from_millis(10)).await;
14769 }
14770 let survived = running(grandchild);
14771 if survived {
14773 let pid = rustix::process::Pid::from_raw(grandchild as i32).unwrap();
14774 let _ = rustix::process::kill_process(pid, rustix::process::Signal::KILL);
14775 tokio::time::sleep(Duration::from_millis(100)).await;
14776 }
14777 if module.exists() {
14778 std::fs::remove_dir(&module).expect("remove empty module cgroup");
14779 }
14780 std::fs::remove_dir(root.join("subc-modules")).unwrap();
14781 std::fs::remove_dir(&root).unwrap();
14782 assert!(
14783 !survived,
14784 "grandchild {grandchild} outlived module teardown"
14785 );
14786 eprintln!("EXECUTED {test_name}: grandchild {grandchild} killed in isolated cgroup");
14787 }
14788}