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