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