1use std::{
2 collections::{HashMap, 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::{spawned_file_identity, ExecutableIdentityProbe, SpawnedFileIdentity},
43 registry::{ConnectionId, 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#[path = "supervise_swap.rs"]
53mod swap;
54
55pub const SUBC_ARG: &str = "--subc";
61
62const DEFAULT_MAX_RESTARTS: u32 = 3;
63const DEFAULT_BACKOFF: Duration = Duration::from_millis(100);
64const DEFAULT_MAX_BACKOFF: Duration = Duration::from_secs(30);
65const DEFAULT_RESTART_WINDOW: Duration = Duration::from_secs(600);
69pub const DEFAULT_DRAIN_TIMEOUT: Duration = Duration::from_secs(30);
80const REGISTRY_RELEASE_TIMEOUT: Duration = Duration::from_secs(1);
81const REGISTRY_RELEASE_POLL: Duration = Duration::from_millis(10);
82const STDERR_PUMP_DRAIN_TIMEOUT: Duration = Duration::from_millis(250);
104pub const SPAWN_EVENT_RING_CAPACITY: usize = 4096;
106const SPAWN_SUBSCRIBER_BUFFER: usize = SPAWN_EVENT_RING_CAPACITY + 1;
107pub(crate) const SPAWN_SUBSCRIBER_LAGGED_CODE: &str = "spawn_subscriber_lagged";
112
113struct SupervisedChild {
114 child: Child,
115 #[cfg(target_os = "linux")]
118 module_id: String,
119 #[cfg(target_os = "linux")]
120 cgroup_placement: Option<subc_cgroup::Placement>,
121 #[cfg(windows)]
143 job: Option<subc_jobobject::JobObject>,
144 stdout_pump: Option<JoinHandle<()>>,
145 stderr_pump: Option<StderrPump>,
146 stderr_ring: Arc<Mutex<StderrRing>>,
147 spawned_at_ms: u64,
148 spawned_from: PathBuf,
149 spawned_file_identity: Option<SpawnedFileIdentity>,
150 process_start_time: Option<u64>,
151 process_identity: Option<ProcessIdentity>,
152 pid: u32,
153 roster_guard: Option<crate::child_roster::RosterGuard>,
156}
157
158impl SupervisedChild {
159 fn id(&self) -> Option<u32> {
160 Some(self.pid)
161 }
162
163 fn process_identity(&self) -> Option<ProcessIdentity> {
164 self.process_identity
165 }
166
167 async fn wait(&mut self) -> io::Result<ExitStatus> {
168 let result = self.child.wait().await;
176 #[cfg(target_os = "linux")]
177 if result.is_ok() {
178 if let Some(placement) = self.cgroup_placement.take() {
179 remove_module_cgroup(&placement, &self.module_id);
180 }
181 }
182 result
183 }
184
185 fn release_roster(&mut self) {
189 self.roster_guard = None;
190 }
191
192 fn start_kill(&mut self) -> io::Result<()> {
205 #[cfg(windows)]
206 if let Some(job) = &self.job {
207 if let Err(error) = job.terminate() {
208 debug!(
209 error = %error,
210 "job termination failed; the direct-child kill still owns the outcome"
211 );
212 }
213 }
214 self.child.start_kill()
215 }
216
217 async fn drain_stderr(&mut self, module_id: &str) {
218 if let Some(mut pump) = self.stdout_pump.take() {
219 match timeout(STDERR_PUMP_DRAIN_TIMEOUT, &mut pump).await {
220 Ok(Ok(())) => {}
221 Ok(Err(error)) => {
222 warn!(module_id, error = %error, "stdout pump ended unexpectedly");
223 }
224 Err(_) => {
225 pump.abort();
226 warn!(
227 module_id,
228 waited = ?STDERR_PUMP_DRAIN_TIMEOUT,
229 "stdout pump did not drain before restart; stopped it before the next process"
230 );
231 }
232 }
233 }
234
235 let Some(pump) = self.stderr_pump.take() else {
236 return;
237 };
238 settle_stderr_pump(
239 module_id,
240 &self.stderr_ring,
241 pump,
242 STDERR_PUMP_DRAIN_TIMEOUT,
243 )
244 .await;
245 }
246}
247
248struct StderrPump {
251 task: JoinHandle<()>,
252 generation: u64,
253}
254
255async fn settle_stderr_pump(
261 module_id: &str,
262 ring: &Arc<Mutex<StderrRing>>,
263 pump: StderrPump,
264 bound: Duration,
265) {
266 let lock = || ring.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
267 let StderrPump {
268 mut task,
269 generation,
270 } = pump;
271 lock().retire_pump(generation);
272 match timeout(bound, &mut task).await {
273 Ok(Ok(())) => {}
274 Ok(Err(err)) => {
275 let mut ring = lock();
276 ring.mark_incomplete(format!("stderr pump ended unexpectedly: {err}"));
277 ring.finish_pump(generation);
278 warn!(module_id, error = %err, "stderr pump ended before clean EOF");
279 }
280 Err(_) => {
281 drop(task);
283 lock().mark_pump_late(
284 generation,
285 format!(
286 "stderr of the exited process had not reached EOF {bound:?} after it was \
287 retired (a descendant may still hold the pipe open); lines it still \
288 writes are kept in that process's section"
289 ),
290 );
291 warn!(
292 module_id,
293 waited = ?bound,
294 "stderr pipe of the exited process is still open; its reader keeps running without delaying the restart"
295 );
296 }
297 }
298}
299
300fn registration_release_events() -> &'static watch::Sender<u64> {
301 static EVENTS: OnceLock<watch::Sender<u64>> = OnceLock::new();
302 EVENTS.get_or_init(|| {
303 let (sender, _receiver) = watch::channel(0);
304 sender
305 })
306}
307
308pub(crate) fn notify_registration_release() {
309 let events = registration_release_events();
310 let next_generation = (*events.borrow()).wrapping_add(1);
311 events.send_replace(next_generation);
312}
313
314#[derive(Debug, Clone, PartialEq, Eq)]
316pub struct ModuleSpec {
317 pub module_id: String,
318 pub program: PathBuf,
319 pub args: Vec<String>,
320 pub env: Vec<(String, String)>,
321 pub reserved: bool,
326 pub reserved_prefixes: Vec<String>,
331 pub protocol: ModuleProtocol,
350 pub overlap: ModuleOverlap,
355}
356
357#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
364pub enum ModuleOverlap {
365 #[default]
367 Exclusive,
368 Safe,
380}
381
382impl ModuleOverlap {
383 pub fn as_str(self) -> &'static str {
384 match self {
385 Self::Exclusive => "exclusive",
386 Self::Safe => "safe",
387 }
388 }
389}
390
391pub const SUBC_SPAWN_ROLE_ENV: &str = "SUBC_SPAWN_ROLE";
401pub const SPAWN_ROLE_SWAP_CANDIDATE: &str = "swap_candidate";
403pub const DEFAULT_SWAP_READY_TIMEOUT: Duration = Duration::from_secs(100);
408
409#[derive(Debug, Clone, Copy, PartialEq, Eq)]
427pub struct RestartPolicy {
428 pub max_restarts: u32,
429 pub backoff: Duration,
432 pub max_backoff: Duration,
434 pub window: Duration,
438}
439
440impl RestartPolicy {
441 pub fn new(max_restarts: u32, backoff: Duration) -> Self {
445 Self {
446 max_restarts,
447 backoff,
448 max_backoff: DEFAULT_MAX_BACKOFF,
449 window: DEFAULT_RESTART_WINDOW,
450 }
451 }
452
453 pub fn with_max_backoff(mut self, max_backoff: Duration) -> Self {
454 self.max_backoff = max_backoff;
455 self
456 }
457
458 pub fn with_window(mut self, window: Duration) -> Self {
459 self.window = window;
460 self
461 }
462
463 fn delay_for_restart(&self, restart_in_window: u32) -> Duration {
468 if self.backoff.is_zero() || self.max_backoff.is_zero() {
469 return Duration::ZERO;
470 }
471
472 let mut delay = self.backoff;
473 for _ in 0..restart_in_window {
474 if delay >= self.max_backoff {
475 return self.max_backoff;
476 }
477 delay = delay
478 .checked_mul(10)
479 .unwrap_or(self.max_backoff)
480 .min(self.max_backoff);
481 }
482 delay.min(self.max_backoff)
483 }
484
485 fn budget_exhausted_detail(&self) -> String {
490 format!(
491 "crash budget exhausted: max_restarts={} within window_secs={}",
492 self.max_restarts,
493 self.window.as_secs()
494 )
495 }
496}
497
498impl Default for RestartPolicy {
499 fn default() -> Self {
500 Self {
501 max_restarts: DEFAULT_MAX_RESTARTS,
502 backoff: DEFAULT_BACKOFF,
503 max_backoff: DEFAULT_MAX_BACKOFF,
504 window: DEFAULT_RESTART_WINDOW,
505 }
506 }
507}
508
509#[derive(Debug, Clone, Copy, PartialEq, Eq)]
510struct CrashRestartSchedule {
511 restart_in_window: u32,
512 delay: Duration,
513}
514
515fn daemon_will_restart(
522 state: &mut SupervisorSnapshot,
523 policy: &RestartPolicy,
524 now: Instant,
525) -> bool {
526 state.enabled && state.crash_restarts_in_window(policy.window, now) < policy.max_restarts
527}
528
529const DEFAULT_HEALTH_CADENCE: Duration = Duration::from_secs(30);
530const DEFAULT_HEALTH_DEADLINE: Duration = Duration::from_secs(5);
531const DEFAULT_HEALTH_FAILURE_THRESHOLD: u32 = 3;
532const MAX_HEALTH_METRICS_BYTES: usize = 16 * 1024;
533
534#[derive(Debug, Clone, Copy, PartialEq, Eq)]
535pub enum HealthAction {
536 Report,
537 Restart,
538 Alert,
539}
540
541impl fmt::Display for HealthAction {
542 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
543 f.write_str(match self {
544 Self::Report => "report",
545 Self::Restart => "restart",
546 Self::Alert => "alert",
547 })
548 }
549}
550
551#[derive(Debug, Clone, Copy, PartialEq, Eq)]
552pub struct HealthConfig {
553 pub cadence: Duration,
554 pub deadline: Duration,
555 pub failure_threshold: u32,
556 pub on_degraded: HealthAction,
557 pub on_failing: HealthAction,
558 pub critical: bool,
559}
560
561impl Default for HealthConfig {
562 fn default() -> Self {
563 Self {
564 cadence: DEFAULT_HEALTH_CADENCE,
565 deadline: DEFAULT_HEALTH_DEADLINE,
566 failure_threshold: DEFAULT_HEALTH_FAILURE_THRESHOLD,
567 on_degraded: HealthAction::Report,
568 on_failing: HealthAction::Report,
569 critical: false,
570 }
571 }
572}
573
574#[derive(Debug, Clone, PartialEq)]
592pub struct ModuleHealthStatus {
593 pub status: SupervisorHealthStatus,
594 pub last_probe_ms: Option<u64>,
595 pub detail: Option<String>,
596 pub metrics: Option<Value>,
597 pub consecutive_failures: u32,
598 pub late_answer_count: u64,
601 pub last_late_answer_latency_ms: Option<u64>,
603 pub last_action: Option<String>,
604 pub last_action_ms: Option<u64>,
608}
609
610impl Default for ModuleHealthStatus {
611 fn default() -> Self {
612 Self {
613 status: SupervisorHealthStatus::Unknown,
614 last_probe_ms: None,
615 detail: None,
616 metrics: None,
617 consecutive_failures: 0,
618 late_answer_count: 0,
619 last_late_answer_latency_ms: None,
620 last_action: None,
621 last_action_ms: None,
622 }
623 }
624}
625
626#[derive(Debug, Clone, Copy, PartialEq, Eq)]
628pub enum ModuleState {
629 Starting,
630 Running,
631 Unresponsive,
632 Restarting,
633 Draining,
634 Stopped,
635 Failed,
636 Disabled,
637}
638
639impl fmt::Display for ModuleState {
640 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
641 f.write_str(match self {
642 Self::Starting => "starting",
643 Self::Running => "running",
644 Self::Unresponsive => "unresponsive",
645 Self::Restarting => "restarting",
646 Self::Draining => "draining",
647 Self::Stopped => "stopped",
648 Self::Failed => "failed",
649 Self::Disabled => "disabled",
650 })
651 }
652}
653
654#[derive(Debug, Clone, Copy, PartialEq, Eq)]
656pub enum ExitKind {
657 Clean,
658 Crash,
659 DeliberateSeverance,
660}
661
662impl From<ExitKind> for TerminalExitKind {
663 fn from(kind: ExitKind) -> Self {
664 match kind {
665 ExitKind::Clean => Self::Clean,
666 ExitKind::Crash => Self::Crash,
667 ExitKind::DeliberateSeverance => Self::DeliberateSeverance,
668 }
669 }
670}
671
672#[derive(Debug, Clone, Copy, PartialEq, Eq)]
675pub(crate) struct ProcessIdentity {
676 pub(crate) pid: u32,
677 pub(crate) start_time: u64,
678}
679
680#[derive(Debug, Clone, PartialEq, Eq)]
682pub struct ExitReport {
683 pub kind: ExitKind,
684 pub code: Option<i32>,
685 pub signal: Option<i32>,
686 pub at_ms: u64,
687}
688
689#[derive(Debug, Clone, PartialEq)]
692pub struct ModuleStatus {
693 pub module_id: String,
694 pub state: ModuleState,
695 pub enabled: bool,
696 pub process_alive: bool,
697 pub registration_active: bool,
698 pub protocol: ModuleProtocol,
701 pub live: bool,
712 pub restart_count: u32,
716 pub lifetime_restarts: u32,
720 pub spawn_generation: u64,
721 pub max_restarts: u32,
726 pub restart_window: Duration,
730 pub drain_timeout: Duration,
734 pub restart_backoff: Duration,
735 pub restart_max_backoff: Duration,
736 pub pid: Option<u32>,
737 pub spawned_at_ms: Option<u64>,
738 pub spawned_from: Option<PathBuf>,
739 pub process_start_time: Option<u64>,
740 pub last_exit: Option<ExitReport>,
741 pub health: ModuleHealthStatus,
742}
743
744#[derive(Debug, Clone, PartialEq)]
745struct SupervisorSnapshot {
746 state: ModuleState,
747 enabled: bool,
748 process_alive: bool,
749 crash_restarts: VecDeque<Instant>,
755 lifetime_restarts: u32,
756 spawn_generation: u64,
765 pid: Option<u32>,
766 spawned_at_ms: Option<u64>,
767 spawned_from: Option<PathBuf>,
768 spawned_file_identity: Option<SpawnedFileIdentity>,
769 process_start_time: Option<u64>,
770 deliberate_severance: Option<ProcessIdentity>,
771 last_exit: Option<ExitReport>,
772 health: ModuleHealthStatus,
773 in_alternate_slot: bool,
778 draining_to_replace: bool,
785 configuration_updated_since_spawn: bool,
791}
792
793impl SupervisorSnapshot {
794 fn starting() -> Self {
795 Self::new(ModuleState::Starting, true)
796 }
797
798 fn disabled() -> Self {
799 Self::new(ModuleState::Disabled, false)
800 }
801
802 fn failed() -> Self {
803 Self::new(ModuleState::Failed, true)
804 }
805
806 fn crash_restarts_in_window(&mut self, window: Duration, now: Instant) -> u32 {
810 while let Some(oldest) = self.crash_restarts.front() {
811 if now.duration_since(*oldest) > window {
812 self.crash_restarts.pop_front();
813 } else {
814 break;
815 }
816 }
817 u32::try_from(self.crash_restarts.len()).unwrap_or(u32::MAX)
818 }
819
820 fn record_crash_restart(&mut self, policy: &RestartPolicy, now: Instant) {
826 self.crash_restarts.push_back(now);
827 while self.crash_restarts.len() > policy.max_restarts as usize {
828 self.crash_restarts.pop_front();
829 }
830 self.lifetime_restarts += 1;
831 }
832
833 fn next_crash_restart(
837 &mut self,
838 policy: &RestartPolicy,
839 now: Instant,
840 ) -> Option<CrashRestartSchedule> {
841 let restart_in_window = self.crash_restarts_in_window(policy.window, now);
842 if restart_in_window >= policy.max_restarts {
843 return None;
844 }
845 self.record_crash_restart(policy, now);
846 Some(CrashRestartSchedule {
847 restart_in_window,
848 delay: policy.delay_for_restart(restart_in_window),
849 })
850 }
851
852 fn clear_crash_restarts(&mut self) {
857 self.crash_restarts.clear();
858 }
859
860 fn new(state: ModuleState, enabled: bool) -> Self {
861 Self {
862 state,
863 enabled,
864 process_alive: false,
865 crash_restarts: VecDeque::new(),
866 lifetime_restarts: 0,
867 spawn_generation: 0,
868 pid: None,
869 spawned_at_ms: None,
870 spawned_from: None,
871 spawned_file_identity: None,
872 process_start_time: None,
873 deliberate_severance: None,
874 last_exit: None,
875 health: ModuleHealthStatus::default(),
876 in_alternate_slot: false,
877 draining_to_replace: false,
878 configuration_updated_since_spawn: false,
879 }
880 }
881}
882
883type SharedSnapshot = Arc<Mutex<SupervisorSnapshot>>;
884
885type SpawnSubscriberKey = (ConnectionId, u64);
886
887#[derive(Debug)]
888struct SpawnSubscriber {
889 version: u8,
890 frames: mpsc::Sender<Frame>,
891 lagged: Option<oneshot::Sender<SpawnCursor>>,
895}
896
897#[derive(Debug)]
898struct SpawnEventState {
899 daemon_incarnation: String,
900 seq: u64,
901 capacity: usize,
902 live: HashMap<String, LiveSpawn>,
903 generations: HashMap<String, u64>,
904 events: VecDeque<SpawnEvent>,
905 subscribers: HashMap<SpawnSubscriberKey, SpawnSubscriber>,
906}
907
908impl Default for SpawnEventState {
909 fn default() -> Self {
910 Self {
911 daemon_incarnation: "unconfigured".to_string(),
912 seq: 0,
913 capacity: SPAWN_EVENT_RING_CAPACITY,
914 live: HashMap::new(),
915 generations: HashMap::new(),
916 events: VecDeque::new(),
917 subscribers: HashMap::new(),
918 }
919 }
920}
921
922#[derive(Debug, Clone, Default)]
923struct SpawnEventFeed(Arc<Mutex<SpawnEventState>>);
924
925#[derive(Debug, Clone, PartialEq, Eq)]
926pub(crate) enum SpawnSubscribeRefusal {
927 ForeignIncarnation { current: String },
928 TooOld { oldest: SpawnCursor },
929 Frame(String),
930}
931
932impl SpawnEventFeed {
933 fn configure_incarnation(&self, daemon_incarnation: String) {
934 let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
935 state.daemon_incarnation = daemon_incarnation;
936 state.seq = 0;
937 state.live.clear();
938 state.generations.clear();
939 state.events.clear();
940 state.subscribers.clear();
941 }
942
943 fn cursor(state: &SpawnEventState) -> SpawnCursor {
944 SpawnCursor {
945 daemon_incarnation: state.daemon_incarnation.clone(),
946 seq: state.seq,
947 }
948 }
949
950 fn snapshot(&self) -> SpawnSnapshot {
951 let state = self.0.lock().unwrap_or_else(|p| p.into_inner());
952 let mut live = state.live.values().cloned().collect::<Vec<_>>();
953 live.sort_by(|left, right| left.module_id.cmp(&right.module_id));
954 SpawnSnapshot {
955 cursor: Self::cursor(&state),
956 ring_bound: state.capacity as u64,
957 live,
958 }
959 }
960
961 fn emit_spawned(&self, module_id: &str, pid: u32, spawned_at_ms: u64) -> u64 {
962 let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
963 let generation = state
964 .generations
965 .get(module_id)
966 .copied()
967 .unwrap_or(0)
968 .checked_add(1)
969 .expect("spawn generation exhausted");
970 state.generations.insert(module_id.to_string(), generation);
971 let live = LiveSpawn {
972 module_id: module_id.to_string(),
973 spawn_generation: generation,
974 pid,
975 spawned_at_ms,
976 };
977 state.live.insert(module_id.to_string(), live);
978 Self::emit_locked(
979 &mut state,
980 SpawnEventKind::Spawned,
981 module_id.to_string(),
982 generation,
983 pid,
984 None,
985 None,
986 );
987 generation
988 }
989
990 fn emit_exited(&self, module_id: &str, exit_code: Option<i32>, exit_signal: Option<i32>) {
991 let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
992 let Some(live) = state.live.remove(module_id) else {
993 warn!(
994 module_id,
995 "terminal record had no live spawn event identity"
996 );
997 return;
998 };
999 Self::emit_locked(
1000 &mut state,
1001 SpawnEventKind::Exited,
1002 module_id.to_string(),
1003 live.spawn_generation,
1004 live.pid,
1005 exit_code,
1006 exit_signal,
1007 );
1008 }
1009
1010 fn emit_superseded_exited(
1017 &self,
1018 module_id: &str,
1019 spawn_generation: u64,
1020 pid: u32,
1021 exit_code: Option<i32>,
1022 exit_signal: Option<i32>,
1023 ) {
1024 let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1025 if state
1026 .live
1027 .get(module_id)
1028 .is_some_and(|live| live.spawn_generation == spawn_generation)
1029 {
1030 state.live.remove(module_id);
1031 }
1032 Self::emit_locked(
1033 &mut state,
1034 SpawnEventKind::Exited,
1035 module_id.to_string(),
1036 spawn_generation,
1037 pid,
1038 exit_code,
1039 exit_signal,
1040 );
1041 }
1042
1043 #[allow(clippy::too_many_arguments)]
1044 fn emit_locked(
1045 state: &mut SpawnEventState,
1046 kind: SpawnEventKind,
1047 module_id: String,
1048 spawn_generation: u64,
1049 pid: u32,
1050 exit_code: Option<i32>,
1051 exit_signal: Option<i32>,
1052 ) {
1053 state.seq = state
1054 .seq
1055 .checked_add(1)
1056 .expect("spawn event sequence exhausted");
1057 let event = SpawnEvent {
1058 cursor: Self::cursor(state),
1059 kind,
1060 module_id,
1061 spawn_generation,
1062 pid,
1063 exit_code,
1064 exit_signal,
1065 };
1066 state.events.push_back(event.clone());
1067 while state.events.len() > state.capacity {
1068 state.events.pop_front();
1069 }
1070 let body = match serde_json::to_vec(&event) {
1071 Ok(body) => body,
1072 Err(error) => {
1073 error!(%error, "failed to serialize supervisor spawn event");
1074 return;
1075 }
1076 };
1077 state.subscribers.retain(|(connection_id, corr), subscriber| {
1078 let frame = Frame::build_with_version(
1079 subscriber.version,
1080 FrameType::StreamData,
1081 control_flags(),
1082 0,
1083 0,
1084 *corr,
1085 body.clone(),
1086 );
1087 match frame {
1088 Ok(frame) => {
1089 if subscriber.frames.try_send(frame).is_ok() {
1090 true
1091 } else {
1092 warn!(connection_id = connection_id.get(), corr, "dropping lagged supervisor spawn subscriber");
1093 if let Some(lagged) = subscriber.lagged.take() {
1094 let _ = lagged.send(event.cursor.clone());
1095 }
1096 false
1097 }
1098 }
1099 Err(error) => {
1100 warn!(connection_id = connection_id.get(), corr, %error, "dropping supervisor spawn subscriber after frame build failure");
1101 false
1102 }
1103 }
1104 });
1105 }
1106
1107 fn subscribe(
1108 &self,
1109 connection_id: ConnectionId,
1110 corr: u64,
1111 version: u8,
1112 since: Option<SpawnCursor>,
1113 sink: FrameSink,
1114 ) -> Result<(), SpawnSubscribeRefusal> {
1115 let (frames, mut receiver) = mpsc::channel(SPAWN_SUBSCRIBER_BUFFER);
1116 let (lagged, mut lagged_rx) = oneshot::channel::<SpawnCursor>();
1117 {
1118 let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
1119 let replay = if let Some(since) = since {
1120 if since.daemon_incarnation != state.daemon_incarnation {
1121 return Err(SpawnSubscribeRefusal::ForeignIncarnation {
1122 current: state.daemon_incarnation.clone(),
1123 });
1124 }
1125 if let Some(oldest) = state.events.front().map(|event| event.cursor.clone()) {
1126 if since.seq < oldest.seq.saturating_sub(1) {
1127 return Err(SpawnSubscribeRefusal::TooOld { oldest });
1128 }
1129 }
1130 state
1131 .events
1132 .iter()
1133 .filter(|event| event.cursor.seq > since.seq)
1134 .cloned()
1135 .collect::<Vec<_>>()
1136 } else {
1137 Vec::new()
1138 };
1139 for event in replay {
1140 let body = serde_json::to_vec(&event)
1141 .map_err(|error| SpawnSubscribeRefusal::Frame(error.to_string()))?;
1142 let frame = Frame::build_with_version(
1143 version,
1144 FrameType::StreamData,
1145 control_flags(),
1146 0,
1147 0,
1148 corr,
1149 body,
1150 )
1151 .map_err(|error| SpawnSubscribeRefusal::Frame(error.to_string()))?;
1152 frames
1153 .try_send(frame)
1154 .map_err(|error| SpawnSubscribeRefusal::Frame(error.to_string()))?;
1155 }
1156 state.subscribers.insert(
1157 (connection_id, corr),
1158 SpawnSubscriber {
1159 version,
1160 frames,
1161 lagged: Some(lagged),
1162 },
1163 );
1164 }
1165 tokio::spawn(async move {
1176 while let Some(frame) = receiver.recv().await {
1177 if sink.send(frame).await.is_err() {
1178 return;
1179 }
1180 }
1181 let Ok(first_undelivered) = lagged_rx.try_recv() else {
1182 return;
1183 };
1184 match spawn_subscriber_lagged_frame(version, corr, first_undelivered) {
1185 Ok(frame) => {
1186 let _ = sink.send(frame).await;
1187 }
1188 Err(error) => {
1189 error!(%error, corr, "failed to build lagged spawn subscriber terminal frame");
1190 }
1191 }
1192 });
1193 Ok(())
1194 }
1195
1196 fn cancel(&self, connection_id: ConnectionId, corr: u64) -> bool {
1197 let Some(subscriber) = self
1198 .0
1199 .lock()
1200 .unwrap_or_else(|p| p.into_inner())
1201 .subscribers
1202 .remove(&(connection_id, corr))
1203 else {
1204 return false;
1205 };
1206 if let Ok(frame) = Frame::build_with_version(
1207 subscriber.version,
1208 FrameType::StreamEnd,
1209 control_flags(),
1210 0,
1211 0,
1212 corr,
1213 Vec::new(),
1214 ) {
1215 tokio::spawn(async move {
1216 let _ = subscriber.frames.send(frame).await;
1217 });
1218 }
1219 true
1220 }
1221
1222 fn remove_connection(&self, connection_id: ConnectionId) {
1223 self.0
1224 .lock()
1225 .unwrap_or_else(|p| p.into_inner())
1226 .subscribers
1227 .retain(|(subscriber_connection, _), _| *subscriber_connection != connection_id);
1228 }
1229
1230 #[cfg(any(test, feature = "test-support"))]
1231 fn set_capacity(&self, capacity: usize) {
1232 self.0.lock().unwrap_or_else(|p| p.into_inner()).capacity = capacity;
1233 }
1234
1235 #[cfg(any(test, feature = "test-support"))]
1236 fn subscriber_count(&self) -> usize {
1237 self.0
1238 .lock()
1239 .unwrap_or_else(|p| p.into_inner())
1240 .subscribers
1241 .len()
1242 }
1243}
1244
1245fn spawn_subscriber_lagged_frame(
1248 version: u8,
1249 corr: u64,
1250 first_undelivered: SpawnCursor,
1251) -> Result<Frame, String> {
1252 let body = serde_json::to_vec(&subc_protocol::ErrorBody {
1253 code: SPAWN_SUBSCRIBER_LAGGED_CODE.to_string(),
1254 message: "spawn subscriber fell behind and was dropped; resubscribe from the last cursor received"
1255 .to_string(),
1256 detail: Some(serde_json::json!({
1257 "first_undelivered_cursor": first_undelivered
1258 })),
1259 })
1260 .map_err(|error| error.to_string())?;
1261 Frame::build_with_version(version, FrameType::Error, control_flags(), 0, 0, corr, body)
1262 .map_err(|error| error.to_string())
1263}
1264
1265pub trait ModuleProcessLiveness: Send + Sync {
1266 fn process_live(&self, module_id: &str) -> Option<bool>;
1267
1268 fn process_replacing(&self, _module_id: &str) -> bool {
1274 false
1275 }
1276}
1277
1278#[derive(Debug, Clone, Default)]
1280pub struct SupervisorProcessLiveness {
1281 snapshots: Arc<Mutex<HashMap<String, SharedSnapshot>>>,
1282}
1283
1284impl SupervisorProcessLiveness {
1285 pub fn new() -> Self {
1286 Self::default()
1287 }
1288
1289 fn track(&self, module_id: String, snapshot: SharedSnapshot) {
1290 let mut snapshots = self
1291 .snapshots
1292 .lock()
1293 .unwrap_or_else(|poisoned| poisoned.into_inner());
1294 snapshots.insert(module_id, snapshot);
1295 }
1296
1297 fn untrack_if_current(&self, module_id: &str, snapshot: &SharedSnapshot) {
1298 let mut snapshots = self
1299 .snapshots
1300 .lock()
1301 .unwrap_or_else(|poisoned| poisoned.into_inner());
1302 let is_current = snapshots
1303 .get(module_id)
1304 .map(|tracked| Arc::ptr_eq(tracked, snapshot))
1305 .unwrap_or(false);
1306 if is_current {
1307 snapshots.remove(module_id);
1308 }
1309 }
1310}
1311
1312impl ModuleProcessLiveness for SupervisorProcessLiveness {
1313 fn process_live(&self, module_id: &str) -> Option<bool> {
1314 let snapshot = {
1315 let snapshots = self
1316 .snapshots
1317 .lock()
1318 .unwrap_or_else(|poisoned| poisoned.into_inner());
1319 snapshots.get(module_id).cloned()
1320 }?;
1321 let snapshot = snapshot
1322 .lock()
1323 .unwrap_or_else(|poisoned| poisoned.into_inner());
1324 Some(snapshot.state == ModuleState::Running && snapshot.process_alive)
1325 }
1326
1327 fn process_replacing(&self, module_id: &str) -> bool {
1328 let Some(snapshot) = self
1329 .snapshots
1330 .lock()
1331 .unwrap_or_else(|poisoned| poisoned.into_inner())
1332 .get(module_id)
1333 .cloned()
1334 else {
1335 return false;
1336 };
1337 let snapshot = snapshot
1338 .lock()
1339 .unwrap_or_else(|poisoned| poisoned.into_inner());
1340 snapshot.enabled
1341 && match snapshot.state {
1342 ModuleState::Restarting => true,
1343 ModuleState::Draining => snapshot.draining_to_replace,
1344 ModuleState::Starting
1345 | ModuleState::Running
1346 | ModuleState::Unresponsive
1347 | ModuleState::Stopped
1348 | ModuleState::Failed
1349 | ModuleState::Disabled => false,
1350 }
1351 }
1352}
1353
1354#[derive(Debug, Clone)]
1355struct SupervisorRuntimeConfig {
1356 restart_policy: RestartPolicy,
1357 drain_timeout: Duration,
1360 effective_drain_timeout: Arc<Mutex<Duration>>,
1363 default_drain_timeout: Duration,
1366 health: HealthConfig,
1367 connection_file_path: Option<PathBuf>,
1368 capture_logs_dir: Option<PathBuf>,
1369 forwarding: Option<Arc<ForwardingTable>>,
1370 supervisor_handle: Option<SupervisorHandle>,
1373 stderr_ring: Arc<Mutex<StderrRing>>,
1380 terminal_ring: Arc<Mutex<TerminalRing>>,
1381 spawn_events: SpawnEventFeed,
1382 child_roster: ChildRoster,
1383 #[cfg(target_os = "linux")]
1384 cgroup_placement: Option<subc_cgroup::Placement>,
1385 #[cfg(test)]
1386 test_seed_stale_facts_before_enable_spawn: bool,
1387}
1388
1389#[derive(Debug, Clone, PartialEq, Eq)]
1390struct SupervisedConfiguration {
1391 spec: ModuleSpec,
1392 health: HealthConfig,
1393}
1394
1395#[derive(Debug, Clone, Default)]
1401pub struct SupervisorHandle {
1402 modules: Arc<Mutex<HashMap<String, SupervisedModule>>>,
1403 spawn_events: SpawnEventFeed,
1404 reserved_nonces: Arc<Mutex<HashMap<String, Option<String>>>>,
1415 removal_tombstones: Arc<Mutex<HashMap<String, u64>>>,
1421 spawn_nonces: Arc<Mutex<HashMap<String, String>>>,
1425 reserved_prefix_owners: Arc<Mutex<HashMap<String, String>>>,
1433 swaps: Arc<Mutex<HashMap<String, OpenSwap>>>,
1439 promotion_observer: PromotionObserverSlot,
1441 operation_lock: Arc<AsyncMutex<()>>,
1445}
1446
1447pub(crate) trait SwapPromotionObserver: Send + Sync {
1456 fn swap_promoted(&self, registration: &crate::registry::ModuleRegistration);
1457}
1458
1459#[derive(Clone, Default)]
1463struct PromotionObserverSlot(Arc<Mutex<Option<std::sync::Weak<dyn SwapPromotionObserver>>>>);
1464
1465impl fmt::Debug for PromotionObserverSlot {
1466 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1467 f.write_str("PromotionObserverSlot")
1468 }
1469}
1470
1471#[derive(Debug, Clone)]
1473struct OpenSwap {
1474 candidate_nonce: String,
1477 incumbent_nonce: Option<String>,
1482 candidate_admitted: bool,
1486}
1487
1488#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1491pub(crate) enum SwapHelloAdmission {
1492 NotSwapping,
1495 Candidate,
1497 Refused,
1500}
1501
1502#[derive(Debug, Clone, PartialEq, Eq)]
1503pub(crate) enum ReservedHelloRejection {
1504 Exact {
1505 module_id: String,
1506 },
1507 Prefix {
1508 prefix: String,
1509 owner_module_id: String,
1510 },
1511}
1512
1513impl SupervisorHandle {
1514 pub fn new() -> Self {
1515 Self::default()
1516 }
1517
1518 pub(crate) fn spawn_snapshot(&self) -> SpawnSnapshot {
1519 self.spawn_events.snapshot()
1520 }
1521
1522 pub(crate) fn subscribe_spawns(
1523 &self,
1524 connection_id: ConnectionId,
1525 corr: u64,
1526 version: u8,
1527 since: Option<SpawnCursor>,
1528 sink: FrameSink,
1529 ) -> Result<(), SpawnSubscribeRefusal> {
1530 self.spawn_events
1531 .subscribe(connection_id, corr, version, since, sink)
1532 }
1533
1534 pub(crate) fn cancel_spawn_subscription(&self, connection_id: ConnectionId, corr: u64) -> bool {
1535 self.spawn_events.cancel(connection_id, corr)
1536 }
1537
1538 pub(crate) fn remove_spawn_subscribers(&self, connection_id: ConnectionId) {
1539 self.spawn_events.remove_connection(connection_id);
1540 }
1541
1542 #[cfg(any(test, feature = "test-support"))]
1543 pub fn set_spawn_event_capacity_for_test(&self, capacity: usize) {
1544 assert!(capacity > 0, "spawn event capacity must be non-zero");
1545 self.spawn_events.set_capacity(capacity);
1546 }
1547
1548 #[cfg(any(test, feature = "test-support"))]
1549 pub fn spawn_subscriber_count_for_test(&self) -> usize {
1550 self.spawn_events.subscriber_count()
1551 }
1552
1553 pub fn set_spawn_nonce(&self, module_id: &str, nonce: String) {
1556 self.spawn_nonces
1557 .lock()
1558 .unwrap_or_else(|poisoned| poisoned.into_inner())
1559 .insert(module_id.to_string(), nonce);
1560 }
1561
1562 pub fn set_reserved_nonce(&self, module_id: &str, nonce: String) {
1565 self.reserved_nonces
1566 .lock()
1567 .unwrap_or_else(|poisoned| poisoned.into_inner())
1568 .insert(module_id.to_string(), Some(nonce));
1569 }
1570
1571 pub fn set_reserved_prefixes(&self, owner_module_id: &str, prefixes: &[String]) {
1573 let mut owners = self
1574 .reserved_prefix_owners
1575 .lock()
1576 .unwrap_or_else(|poisoned| poisoned.into_inner());
1577 owners.retain(|_, owner| owner != owner_module_id);
1578 for prefix in prefixes {
1579 owners.insert(prefix.clone(), owner_module_id.to_string());
1580 }
1581 }
1582
1583 #[cfg(test)]
1585 pub(crate) fn spawn_nonce(&self, module_id: &str) -> Option<String> {
1586 self.spawn_nonces
1587 .lock()
1588 .unwrap_or_else(|poisoned| poisoned.into_inner())
1589 .get(module_id)
1590 .cloned()
1591 }
1592
1593 fn apply_identity_configuration(&self, spec: &ModuleSpec) {
1594 self.set_reserved_prefixes(&spec.module_id, &spec.reserved_prefixes);
1595 let spawn_nonce = self
1596 .spawn_nonces
1597 .lock()
1598 .unwrap_or_else(|poisoned| poisoned.into_inner())
1599 .get(&spec.module_id)
1600 .cloned();
1601 let mut reserved_nonces = self
1602 .reserved_nonces
1603 .lock()
1604 .unwrap_or_else(|poisoned| poisoned.into_inner());
1605 if spec.reserved {
1606 reserved_nonces.insert(spec.module_id.clone(), spawn_nonce);
1611 }
1612 drop(reserved_nonces);
1613 self.removal_tombstones
1617 .lock()
1618 .unwrap_or_else(|poisoned| poisoned.into_inner())
1619 .remove(&spec.module_id);
1620 }
1621
1622 pub fn reserved_hello_authorized(&self, module_id: &str, presented: Option<&str>) -> bool {
1627 self.reserved_hello_rejection(module_id, presented)
1628 .is_none()
1629 }
1630
1631 pub(crate) fn reserved_hello_rejection(
1632 &self,
1633 module_id: &str,
1634 presented: Option<&str>,
1635 ) -> Option<ReservedHelloRejection> {
1636 let nonces = self
1637 .reserved_nonces
1638 .lock()
1639 .unwrap_or_else(|poisoned| poisoned.into_inner());
1640 if let Some(expected) = nonces.get(module_id) {
1641 let authorized = match expected {
1645 Some(expected) => {
1646 presented.is_some_and(|p| constant_time_eq(expected.as_bytes(), p.as_bytes()))
1647 }
1648 None => false,
1649 };
1650 if authorized {
1651 return None;
1652 }
1653 return Some(ReservedHelloRejection::Exact {
1654 module_id: module_id.to_string(),
1655 });
1656 }
1657 drop(nonces);
1658
1659 let matched_prefix = self
1660 .reserved_prefix_owners
1661 .lock()
1662 .unwrap_or_else(|poisoned| poisoned.into_inner())
1663 .iter()
1664 .filter(|(prefix, _)| module_id.starts_with(prefix.as_str()))
1665 .max_by_key(|(prefix, _)| prefix.len())
1666 .map(|(prefix, owner)| (prefix.clone(), owner.clone()));
1667 let (prefix, owner_module_id) = matched_prefix?;
1668
1669 let authorized = presented.is_some_and(|presented| {
1670 self.spawn_nonces
1671 .lock()
1672 .unwrap_or_else(|poisoned| poisoned.into_inner())
1673 .get(&owner_module_id)
1674 .is_some_and(|expected| constant_time_eq(expected.as_bytes(), presented.as_bytes()))
1675 || self.swap_nonce_matches(&owner_module_id, presented)
1678 });
1679 if authorized {
1680 None
1681 } else {
1682 Some(ReservedHelloRejection::Prefix {
1683 prefix,
1684 owner_module_id,
1685 })
1686 }
1687 }
1688
1689 pub fn spawned_consumer_authorized(&self, module_id: &str, presented: &str) -> bool {
1694 if presented.is_empty() {
1695 return false;
1696 }
1697 let nonces = self
1698 .spawn_nonces
1699 .lock()
1700 .unwrap_or_else(|poisoned| poisoned.into_inner());
1701 let current = nonces
1702 .get(module_id)
1703 .is_some_and(|expected| constant_time_eq(expected.as_bytes(), presented.as_bytes()));
1704 drop(nonces);
1705 current || self.swap_nonce_matches(module_id, presented)
1710 }
1711
1712 fn swap_nonce_matches(&self, module_id: &str, presented: &str) -> bool {
1714 let swaps = self
1715 .swaps
1716 .lock()
1717 .unwrap_or_else(|poisoned| poisoned.into_inner());
1718 swaps.get(module_id).is_some_and(|swap| {
1719 constant_time_eq(swap.candidate_nonce.as_bytes(), presented.as_bytes())
1720 || swap.incumbent_nonce.as_deref().is_some_and(|incumbent| {
1721 constant_time_eq(incumbent.as_bytes(), presented.as_bytes())
1722 })
1723 })
1724 }
1725
1726 pub(crate) fn open_swap(&self, module_id: &str, candidate_nonce: String) {
1729 let incumbent_nonce = self
1730 .spawn_nonces
1731 .lock()
1732 .unwrap_or_else(|poisoned| poisoned.into_inner())
1733 .get(module_id)
1734 .cloned();
1735 self.swaps
1736 .lock()
1737 .unwrap_or_else(|poisoned| poisoned.into_inner())
1738 .insert(
1739 module_id.to_string(),
1740 OpenSwap {
1741 candidate_nonce,
1742 incumbent_nonce,
1743 candidate_admitted: false,
1744 },
1745 );
1746 }
1747
1748 pub(crate) fn close_swap(&self, module_id: &str) {
1751 self.swaps
1752 .lock()
1753 .unwrap_or_else(|poisoned| poisoned.into_inner())
1754 .remove(module_id);
1755 }
1756
1757 pub(crate) fn set_swap_promotion_observer(
1760 &self,
1761 observer: std::sync::Weak<dyn SwapPromotionObserver>,
1762 ) {
1763 *self
1764 .promotion_observer
1765 .0
1766 .lock()
1767 .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(observer);
1768 }
1769
1770 fn notify_swap_promoted(&self, registration: &crate::registry::ModuleRegistration) {
1773 let observer = self
1774 .promotion_observer
1775 .0
1776 .lock()
1777 .unwrap_or_else(|poisoned| poisoned.into_inner())
1778 .as_ref()
1779 .and_then(std::sync::Weak::upgrade);
1780 if let Some(observer) = observer {
1781 observer.swap_promoted(registration);
1782 }
1783 }
1784
1785 pub(crate) fn swap_open(&self, module_id: &str) -> bool {
1787 self.swaps
1788 .lock()
1789 .unwrap_or_else(|poisoned| poisoned.into_inner())
1790 .contains_key(module_id)
1791 }
1792
1793 fn promote_swap_nonce(&self, module_id: &str, reserved: bool) {
1798 let candidate_nonce = self
1799 .swaps
1800 .lock()
1801 .unwrap_or_else(|poisoned| poisoned.into_inner())
1802 .get(module_id)
1803 .map(|swap| swap.candidate_nonce.clone());
1804 let Some(nonce) = candidate_nonce else {
1805 return;
1806 };
1807 self.set_spawn_nonce(module_id, nonce.clone());
1808 if reserved {
1809 self.set_reserved_nonce(module_id, nonce);
1810 }
1811 }
1812
1813 pub(crate) fn swap_hello_admission(
1828 &self,
1829 module_id: &str,
1830 presented: Option<&str>,
1831 ) -> SwapHelloAdmission {
1832 let swaps = self
1833 .swaps
1834 .lock()
1835 .unwrap_or_else(|poisoned| poisoned.into_inner());
1836 let Some(swap) = swaps.get(module_id) else {
1837 return SwapHelloAdmission::NotSwapping;
1838 };
1839 let Some(presented) = presented else {
1840 return SwapHelloAdmission::Refused;
1841 };
1842 if constant_time_eq(swap.candidate_nonce.as_bytes(), presented.as_bytes()) {
1843 return if swap.candidate_admitted {
1844 SwapHelloAdmission::Refused
1845 } else {
1846 SwapHelloAdmission::Candidate
1847 };
1848 }
1849 if swap
1850 .incumbent_nonce
1851 .as_deref()
1852 .is_some_and(|incumbent| constant_time_eq(incumbent.as_bytes(), presented.as_bytes()))
1853 {
1854 return SwapHelloAdmission::NotSwapping;
1855 }
1856 SwapHelloAdmission::Refused
1857 }
1858
1859 pub(crate) fn mark_swap_candidate_admitted(&self, module_id: &str) {
1862 if let Some(swap) = self
1863 .swaps
1864 .lock()
1865 .unwrap_or_else(|poisoned| poisoned.into_inner())
1866 .get_mut(module_id)
1867 {
1868 swap.candidate_admitted = true;
1869 }
1870 }
1871
1872 pub fn spawn_launch_nonce_for(&self, module_id: &str) -> Option<String> {
1874 self.spawn_nonces
1875 .lock()
1876 .unwrap_or_else(|poisoned| poisoned.into_inner())
1877 .get(module_id)
1878 .cloned()
1879 }
1880
1881 pub fn reserved_launch_nonce_for(&self, module_id: &str) -> Option<String> {
1883 self.reserved_nonces
1884 .lock()
1885 .unwrap_or_else(|poisoned| poisoned.into_inner())
1886 .get(module_id)
1887 .cloned()
1888 .flatten()
1889 }
1890
1891 pub fn insert(&self, module: SupervisedModule) -> Option<SupervisedModule> {
1892 let mut modules = self
1893 .modules
1894 .lock()
1895 .unwrap_or_else(|poisoned| poisoned.into_inner());
1896 modules.insert(module.module_id().to_string(), module)
1897 }
1898
1899 pub fn get(&self, module_id: &str) -> Option<SupervisedModule> {
1900 let modules = self
1901 .modules
1902 .lock()
1903 .unwrap_or_else(|poisoned| poisoned.into_inner());
1904 modules.get(module_id).cloned()
1905 }
1906
1907 pub(crate) fn record_late_health_answer(
1908 &self,
1909 module_id: &str,
1910 latency_ms: u64,
1911 ) -> Result<bool, SuperviseError> {
1912 let Some(module) = self.get(module_id) else {
1913 return Ok(false);
1914 };
1915 update_snapshot(&module.inner.snapshot, Some(module_id), |state| {
1916 state.health.late_answer_count = state.health.late_answer_count.saturating_add(1);
1917 state.health.last_late_answer_latency_ms = Some(latency_ms);
1918 state.health.consecutive_failures = 0;
1926 })?;
1927 Ok(true)
1928 }
1929
1930 pub fn record_deliberate_severance(&self, module_id: &str) -> Result<bool, SuperviseError> {
1936 let Some(module) = self.get(module_id) else {
1937 return Ok(false);
1938 };
1939 let status = module.status()?;
1940 let Some((pid, start_time)) = status.pid.zip(status.process_start_time) else {
1941 return Ok(false);
1942 };
1943 module.record_deliberate_severance(ProcessIdentity { pid, start_time })
1944 }
1945
1946 pub fn list(&self) -> Vec<SupervisedModule> {
1947 let modules = self
1948 .modules
1949 .lock()
1950 .unwrap_or_else(|poisoned| poisoned.into_inner());
1951 let mut modules = modules.values().cloned().collect::<Vec<_>>();
1952 modules.sort_by(|left, right| left.module_id().cmp(right.module_id()));
1953 modules
1954 }
1955
1956 pub(crate) fn retire(&self, module_id: &str) -> Option<SupervisedModule> {
1957 self.spawn_nonces
1958 .lock()
1959 .unwrap_or_else(|poisoned| poisoned.into_inner())
1960 .remove(module_id);
1961 self.close_swap(module_id);
1962 let mut reserved_nonces = self
1963 .reserved_nonces
1964 .lock()
1965 .unwrap_or_else(|poisoned| poisoned.into_inner());
1966 if reserved_nonces.contains_key(module_id) {
1967 reserved_nonces.insert(module_id.to_string(), None);
1970 }
1971 drop(reserved_nonces);
1972 self.reserved_prefix_owners
1973 .lock()
1974 .unwrap_or_else(|poisoned| poisoned.into_inner())
1975 .retain(|_, owner| owner != module_id);
1976 self.modules
1977 .lock()
1978 .unwrap_or_else(|poisoned| poisoned.into_inner())
1979 .remove(module_id)
1980 }
1981
1982 pub(crate) fn record_rescan_removal(&self, module_id: &str) {
1985 self.removal_tombstones
1986 .lock()
1987 .unwrap_or_else(|poisoned| poisoned.into_inner())
1988 .insert(module_id.to_string(), unix_ms_now());
1989 }
1990
1991 pub(crate) fn removal_tombstone_age_ms(&self, module_id: &str) -> Option<u64> {
1993 self.removal_tombstones
1994 .lock()
1995 .unwrap_or_else(|poisoned| poisoned.into_inner())
1996 .get(module_id)
1997 .copied()
1998 .map(|removed_at_ms| unix_ms_now().saturating_sub(removed_at_ms))
1999 }
2000
2001 pub(crate) fn release_retained_reserved_gate(&self, module_id: &str) -> bool {
2006 if self.get(module_id).is_some() {
2007 return false;
2008 }
2009 let mut reserved_nonces = self
2010 .reserved_nonces
2011 .lock()
2012 .unwrap_or_else(|poisoned| poisoned.into_inner());
2013 if !matches!(reserved_nonces.get(module_id), Some(None)) {
2014 return false;
2015 }
2016 reserved_nonces.remove(module_id);
2017 true
2018 }
2019
2020 pub(crate) fn operation_lock(&self) -> Arc<AsyncMutex<()>> {
2021 Arc::clone(&self.operation_lock)
2022 }
2023}
2024
2025#[derive(Debug, Clone)]
2027pub struct Supervisor {
2028 registry: Arc<Registry>,
2029 restart_policy: RestartPolicy,
2030 drain_timeout: Duration,
2031 connection_file_path: Option<PathBuf>,
2032 capture_logs_dir: Option<PathBuf>,
2033 forwarding: Option<Arc<ForwardingTable>>,
2034 process_liveness: Arc<SupervisorProcessLiveness>,
2035 supervisor_handle: Option<SupervisorHandle>,
2036 health: HealthConfig,
2037 daemon_start_clock: crate::clock::StartClock,
2038 terminal_journal: Option<Arc<crate::terminal_journal::TerminalJournal>>,
2039 spawn_events: SpawnEventFeed,
2040 provenance_probe: ExecutableIdentityProbe,
2041 child_roster: ChildRoster,
2044 #[cfg(target_os = "linux")]
2045 cgroup_placement: Option<subc_cgroup::Placement>,
2046}
2047
2048impl Supervisor {
2049 #[cfg(unix)]
2060 pub(crate) fn begin_daemon_shutdown(&self) {
2061 self.child_roster.close();
2062 if let Some(journal) = &self.terminal_journal {
2063 journal.stamp_shutdown();
2064 }
2065 }
2066
2067 #[cfg(unix)]
2071 pub(crate) async fn drain_for_daemon_shutdown(&self) -> Result<(), SuperviseError> {
2072 const NOTICE_BUDGET: Duration = Duration::from_millis(500);
2073 const DRAIN_BUDGET: Duration = Duration::from_secs(2);
2074 let Some(forwarding) = &self.forwarding else {
2075 return Ok(());
2076 };
2077 let module_ids = forwarding
2078 .begin_daemon_drain()
2079 .map_err(SuperviseError::Forwarding)?;
2080 let deadline_ms =
2081 unix_ms_now().saturating_add((NOTICE_BUDGET + DRAIN_BUDGET).as_millis() as u64);
2082 let mut notices = tokio::task::JoinSet::new();
2083 let mut drains = Vec::new();
2084 for module_id in module_ids {
2085 let Some(target) = forwarding
2086 .begin_module_drain(&module_id, RouteCloseReason::Restart)
2087 .map_err(SuperviseError::Forwarding)?
2088 else {
2089 continue;
2090 };
2091 let routes = forwarding
2092 .endpoint_routes(target.endpoint)
2093 .map_err(SuperviseError::Forwarding)?;
2094 let command = serde_json::to_vec(&ModuleControlCommand::Draining {
2100 reason: RouteCloseReason::Restart,
2101 deadline_ms,
2102 })
2103 .expect("module draining serializes");
2104 let closing = serde_json::to_vec(&ClientControlPush::RouteClosing {
2105 module_id: module_id.clone(),
2106 reason: RouteCloseReason::Restart,
2107 })
2108 .expect("route closing serializes");
2109 let mut recipients = vec![(target.sink.clone(), target.negotiated_ver, command)];
2110 let mut seen = std::collections::HashSet::new();
2111 for route in routes {
2112 let client = route.goodbye_target;
2113 if seen.insert(client.connection_id) {
2114 recipients.push((client.sink, client.negotiated_ver, closing.clone()));
2115 }
2116 }
2117 for (sink, version, body) in recipients {
2118 notices.spawn(async move {
2119 let frame = Frame::build_with_version(
2120 version,
2121 FrameType::Push,
2122 control_flags(),
2123 0,
2124 0,
2125 0,
2126 body,
2127 )
2128 .expect("bounded lifecycle notice frame builds");
2129 sink.send_flushed(frame).await
2130 });
2131 }
2132 let gauges = declared_busy_gauges(&self.registry, &module_id)?;
2133 drains.push((module_id, target.endpoint, gauges));
2134 }
2135 let notice_deadline = Instant::now() + NOTICE_BUDGET;
2138 while let Ok(Some(result)) = timeout_at(notice_deadline, notices.join_next()).await {
2139 if !matches!(result, Ok(Ok(()))) {
2140 warn!(?result, "daemon shutdown notice delivery failed");
2141 }
2142 }
2143 notices.abort_all();
2144 let deadline = Instant::now() + DRAIN_BUDGET;
2145 let mut waits = tokio::task::JoinSet::new();
2146 for (module_id, endpoint, gauges) in drains {
2147 let forwarding = Arc::clone(forwarding);
2148 let mut runtime = self.runtime_config();
2149 runtime.health.cadence = Duration::from_millis(100);
2150 waits.spawn(async move {
2151 wait_for_forwarding_quiescence(
2152 &forwarding,
2153 &module_id,
2154 &runtime,
2155 endpoint,
2156 deadline,
2157 &gauges,
2158 DrainScope::Active,
2159 )
2160 .await
2161 });
2162 }
2163 while let Ok(Some(result)) = timeout_at(deadline, waits.join_next()).await {
2164 if !matches!(result, Ok(Ok(true))) {
2165 warn!(?result, "daemon shutdown drain did not reach quiescence");
2166 }
2167 }
2168 Ok(())
2169 }
2170
2171 #[cfg(unix)]
2183 pub(crate) async fn end_children_for_daemon_shutdown(
2184 &self,
2185 already_escalated: bool,
2186 escalate: impl std::future::Future<Output = ()>,
2187 ) {
2188 tokio::pin!(escalate);
2189 let mut escalated = already_escalated;
2190 if let Some(forwarding) = &self.forwarding {
2191 let reason = CloseReason::new(
2192 "daemon_shutdown",
2193 "the daemon is exiting after its shutdown notice and drain",
2194 );
2195 if escalated {
2196 send_module_goodbyes_for_daemon_shutdown(forwarding, &reason, false).await;
2199 } else {
2200 tokio::select! {
2201 biased;
2202 _ = escalate.as_mut() => {
2203 info!("second SIGTERM: abandoning module GOODBYE delivery");
2204 escalated = true;
2205 }
2206 _ = send_module_goodbyes_for_daemon_shutdown(forwarding, &reason, true) => {}
2207 }
2208 }
2209 let closed = forwarding.close_all_connections(&reason);
2210 debug!(closed, "closed established connections for daemon shutdown");
2211 }
2212 let escalated_here = escalated && !already_escalated;
2216 let remaining_escalate = async move {
2217 if escalated_here {
2218 std::future::pending::<()>().await;
2219 } else {
2220 escalate.await;
2221 }
2222 };
2223 crate::child_roster::end_children_for_daemon_shutdown(
2224 &self.child_roster,
2225 escalated,
2226 remaining_escalate,
2227 )
2228 .await;
2229 }
2230
2231 pub fn new(registry: Arc<Registry>, restart_policy: RestartPolicy) -> Self {
2232 Self {
2233 registry,
2234 restart_policy,
2235 drain_timeout: DEFAULT_DRAIN_TIMEOUT,
2236 connection_file_path: None,
2237 capture_logs_dir: None,
2238 forwarding: None,
2239 process_liveness: Arc::new(SupervisorProcessLiveness::default()),
2240 supervisor_handle: None,
2241 health: HealthConfig::default(),
2242 daemon_start_clock: crate::clock::StartClock::capture(),
2243 terminal_journal: None,
2244 spawn_events: SpawnEventFeed::default(),
2245 provenance_probe: ExecutableIdentityProbe::default(),
2246 child_roster: ChildRoster::default(),
2247 #[cfg(target_os = "linux")]
2248 cgroup_placement: None,
2249 }
2250 }
2251
2252 pub fn with_drain_timeout(mut self, drain_timeout: Duration) -> Self {
2253 self.drain_timeout = drain_timeout;
2254 self
2255 }
2256
2257 pub fn with_process_liveness(
2258 mut self,
2259 process_liveness: Arc<SupervisorProcessLiveness>,
2260 ) -> Self {
2261 self.process_liveness = process_liveness;
2262 self
2263 }
2264
2265 pub fn with_connection_file_path(mut self, connection_file_path: impl Into<PathBuf>) -> Self {
2266 self.connection_file_path = Some(connection_file_path.into());
2267 self
2268 }
2269
2270 pub fn with_capture_logs_dir(mut self, logs_dir: impl Into<PathBuf>) -> Self {
2272 self.capture_logs_dir = Some(logs_dir.into());
2273 self
2274 }
2275
2276 pub fn with_daemon_incarnation(self, daemon_incarnation: String) -> Self {
2279 self.spawn_events.configure_incarnation(daemon_incarnation);
2283 self
2284 }
2285
2286 pub fn with_terminal_journal(self, path: PathBuf, daemon_incarnation: String) -> Self {
2289 let mut this = self.with_daemon_incarnation(daemon_incarnation.clone());
2290 this.terminal_journal = Some(Arc::new(crate::terminal_journal::TerminalJournal::open(
2291 path,
2292 daemon_incarnation,
2293 )));
2294 this
2295 }
2296
2297 pub fn with_forwarding(mut self, forwarding: Arc<ForwardingTable>) -> Self {
2298 self.forwarding = Some(forwarding);
2299 self
2300 }
2301
2302 pub fn with_handle(mut self, supervisor_handle: SupervisorHandle) -> Self {
2303 self.spawn_events = supervisor_handle.spawn_events.clone();
2304 self.supervisor_handle = Some(supervisor_handle);
2305 self
2306 }
2307
2308 pub fn with_health_config(mut self, health: HealthConfig) -> Self {
2309 self.health = health;
2310 self
2311 }
2312
2313 pub fn with_live_children_record(self, path: impl Into<PathBuf>) -> Self {
2317 self.child_roster.record_to(path.into());
2318 self
2319 }
2320
2321 #[cfg(target_os = "linux")]
2322 pub fn with_cgroup_placement(
2323 mut self,
2324 cgroup_placement: Option<subc_cgroup::Placement>,
2325 ) -> Self {
2326 self.cgroup_placement = cgroup_placement;
2327 self
2328 }
2329
2330 pub fn spawn(&self, spec: ModuleSpec) -> Result<SupervisedModule, SuperviseError> {
2336 validate_spec(&spec)?;
2337
2338 let runtime = self.runtime_config();
2339 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
2340 let child = spawn_child(
2341 &spec,
2342 runtime.connection_file_path.as_deref(),
2343 self.supervisor_handle.as_ref(),
2344 &runtime.stderr_ring,
2345 runtime.capture_logs_dir.as_deref(),
2346 &runtime.child_roster,
2347 #[cfg(target_os = "linux")]
2348 runtime.cgroup_placement.as_ref(),
2349 )?;
2350 set_running(&snapshot, &child, &spec.module_id, &runtime.spawn_events)?;
2351 self.process_liveness
2352 .track(spec.module_id.clone(), Arc::clone(&snapshot));
2353
2354 Ok(self.supervised_module(spec, runtime, snapshot, Some(child)))
2355 }
2356
2357 pub fn supervise_configured(
2363 &self,
2364 spec: ModuleSpec,
2365 enabled: bool,
2366 ) -> Result<SupervisedModule, SuperviseError> {
2367 validate_spec(&spec)?;
2368
2369 let runtime = self.runtime_config();
2370 if !enabled {
2371 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::disabled()));
2372 return Ok(self.supervised_module(spec, runtime, snapshot, None));
2373 }
2374
2375 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
2376 match spawn_child(
2377 &spec,
2378 runtime.connection_file_path.as_deref(),
2379 self.supervisor_handle.as_ref(),
2380 &runtime.stderr_ring,
2381 runtime.capture_logs_dir.as_deref(),
2382 &runtime.child_roster,
2383 #[cfg(target_os = "linux")]
2384 runtime.cgroup_placement.as_ref(),
2385 ) {
2386 Ok(child) => {
2387 set_running(&snapshot, &child, &spec.module_id, &runtime.spawn_events)?;
2388 self.process_liveness
2389 .track(spec.module_id.clone(), Arc::clone(&snapshot));
2390 Ok(self.supervised_module(spec, runtime, snapshot, Some(child)))
2391 }
2392 Err(err) => {
2393 error!(
2394 module_id = %spec.module_id,
2395 program = %spec.program.display(),
2396 error = %err,
2397 "configured module failed to spawn; marking failed and continuing"
2398 );
2399 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::failed()));
2400 Ok(self.supervised_module(spec, runtime, snapshot, None))
2401 }
2402 }
2403 }
2404
2405 pub fn supervise_configured_with_health(
2411 &self,
2412 spec: ModuleSpec,
2413 enabled: bool,
2414 health: HealthConfig,
2415 drain_timeout_ms: Option<u64>,
2416 restart_policy: RestartPolicy,
2417 ) -> Result<SupervisedModule, SuperviseError> {
2418 validate_spec(&spec)?;
2419
2420 let mut runtime = self.runtime_config();
2421 runtime.health = health;
2422 runtime.restart_policy = restart_policy;
2423 if let Some(ms) = drain_timeout_ms {
2424 runtime.drain_timeout = Duration::from_millis(ms);
2425 *runtime
2426 .effective_drain_timeout
2427 .lock()
2428 .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.drain_timeout;
2429 }
2430 if !enabled {
2431 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::disabled()));
2432 return Ok(self.supervised_module(spec, runtime, snapshot, None));
2433 }
2434
2435 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
2436 match spawn_child(
2437 &spec,
2438 runtime.connection_file_path.as_deref(),
2439 self.supervisor_handle.as_ref(),
2440 &runtime.stderr_ring,
2441 runtime.capture_logs_dir.as_deref(),
2442 &runtime.child_roster,
2443 #[cfg(target_os = "linux")]
2444 runtime.cgroup_placement.as_ref(),
2445 ) {
2446 Ok(child) => {
2447 set_running(&snapshot, &child, &spec.module_id, &runtime.spawn_events)?;
2448 self.process_liveness
2449 .track(spec.module_id.clone(), Arc::clone(&snapshot));
2450 Ok(self.supervised_module(spec, runtime, snapshot, Some(child)))
2451 }
2452 Err(err) => {
2453 if health.critical {
2454 error!(
2455 module_id = %spec.module_id,
2456 program = %spec.program.display(),
2457 error = %err,
2458 "critical configured module failed to spawn; marking failed and alerting"
2459 );
2460 } else {
2461 error!(
2462 module_id = %spec.module_id,
2463 program = %spec.program.display(),
2464 error = %err,
2465 "configured module failed to spawn; marking failed and continuing"
2466 );
2467 }
2468 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::failed()));
2469 Ok(self.supervised_module(spec, runtime, snapshot, None))
2470 }
2471 }
2472 }
2473
2474 fn runtime_config(&self) -> SupervisorRuntimeConfig {
2475 let effective_drain_timeout = Arc::new(Mutex::new(self.drain_timeout));
2476 SupervisorRuntimeConfig {
2477 restart_policy: self.restart_policy,
2478 drain_timeout: self.drain_timeout,
2479 child_roster: self
2482 .child_roster
2483 .for_module(Arc::clone(&effective_drain_timeout)),
2484 effective_drain_timeout,
2485 default_drain_timeout: self.drain_timeout,
2486 health: self.health,
2487 connection_file_path: self.connection_file_path.clone(),
2488 capture_logs_dir: self.capture_logs_dir.clone(),
2489 forwarding: self.forwarding.clone(),
2490 supervisor_handle: self.supervisor_handle.clone(),
2491 stderr_ring: Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default()))),
2492 terminal_ring: Arc::new(Mutex::new(
2493 TerminalRing::new(
2494 TerminalRingConfig::default(),
2495 self.daemon_start_clock.started_at_ms(),
2496 )
2497 .with_start_clock(self.daemon_start_clock)
2498 .with_journal(self.terminal_journal.clone())
2499 .with_daemon_shutdown(self.child_roster.shutdown_flag()),
2500 )),
2501 spawn_events: self.spawn_events.clone(),
2502 #[cfg(target_os = "linux")]
2503 cgroup_placement: self.cgroup_placement.clone(),
2504 #[cfg(test)]
2505 test_seed_stale_facts_before_enable_spawn: false,
2506 }
2507 }
2508
2509 fn supervised_module(
2510 &self,
2511 spec: ModuleSpec,
2512 runtime: SupervisorRuntimeConfig,
2513 snapshot: SharedSnapshot,
2514 child: Option<SupervisedChild>,
2515 ) -> SupervisedModule {
2516 let configuration = Arc::new(Mutex::new(SupervisedConfiguration {
2517 spec: spec.clone(),
2518 health: runtime.health,
2519 }));
2520 let stderr_ring = Arc::clone(&runtime.stderr_ring);
2521 let terminal_ring = Arc::clone(&runtime.terminal_ring);
2522 let restart_policy = runtime.restart_policy;
2526 let effective_drain_timeout = Arc::clone(&runtime.effective_drain_timeout);
2527 let (tx, rx) = mpsc::channel(4);
2528 let monitor = tokio::spawn(supervise_loop(
2529 spec.clone(),
2530 runtime,
2531 Arc::clone(&self.registry),
2532 Arc::clone(&self.process_liveness),
2533 Arc::clone(&snapshot),
2534 child,
2535 rx,
2536 ));
2537
2538 let module_id = spec.module_id.clone();
2539 let module = SupervisedModule {
2540 inner: Arc::new(SupervisedModuleInner {
2541 module_id: module_id.clone(),
2542 registry: Arc::clone(&self.registry),
2543 snapshot,
2544 configuration,
2545 stderr_ring,
2546 terminal_ring,
2547 commands: tx,
2548 monitor: Mutex::new(Some(monitor)),
2549 restart_policy,
2550 effective_drain_timeout,
2551 provenance_probe: self.provenance_probe.clone(),
2552 }),
2553 };
2554 if let Some(supervisor_handle) = &self.supervisor_handle {
2555 supervisor_handle.apply_identity_configuration(&spec);
2556 supervisor_handle.insert(module.clone());
2557 }
2558 module
2559 }
2560}
2561
2562impl Default for Supervisor {
2563 fn default() -> Self {
2564 Self::new(Arc::new(Registry::default()), RestartPolicy::default())
2565 }
2566}
2567
2568#[derive(Clone)]
2570pub struct SupervisedModule {
2571 inner: Arc<SupervisedModuleInner>,
2572}
2573
2574struct SupervisedModuleInner {
2575 module_id: String,
2576 registry: Arc<Registry>,
2577 snapshot: SharedSnapshot,
2578 configuration: Arc<Mutex<SupervisedConfiguration>>,
2579 stderr_ring: Arc<Mutex<StderrRing>>,
2580 terminal_ring: Arc<Mutex<TerminalRing>>,
2581 commands: mpsc::Sender<SupervisorCommand>,
2582 monitor: Mutex<Option<JoinHandle<()>>>,
2583 restart_policy: RestartPolicy,
2587 effective_drain_timeout: Arc<Mutex<Duration>>,
2588 provenance_probe: ExecutableIdentityProbe,
2589}
2590
2591impl fmt::Debug for SupervisedModule {
2592 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2593 f.debug_struct("SupervisedModule")
2594 .field("module_id", &self.inner.module_id)
2595 .field("status", &self.status())
2596 .finish_non_exhaustive()
2597 }
2598}
2599
2600impl SupervisedModule {
2601 pub fn module_id(&self) -> &str {
2602 &self.inner.module_id
2603 }
2604
2605 #[cfg(test)]
2609 pub(crate) fn record_health_probe_failure_for_test(
2610 &self,
2611 detail: &str,
2612 ) -> Result<(), SuperviseError> {
2613 update_snapshot(&self.inner.snapshot, Some(&self.inner.module_id), |state| {
2614 state.health.consecutive_failures = state.health.consecutive_failures.saturating_add(1);
2615 state.health.detail = Some(detail.to_string());
2616 })
2617 }
2618
2619 pub fn state(&self) -> Result<ModuleState, SuperviseError> {
2620 Ok(lock_snapshot(&self.inner.snapshot)?.state)
2621 }
2622
2623 pub fn stderr_tail(
2630 &self,
2631 max_lines: Option<usize>,
2632 max_bytes: Option<usize>,
2633 ) -> StderrTailSnapshot {
2634 self.inner
2635 .stderr_ring
2636 .lock()
2637 .unwrap_or_else(|poisoned| poisoned.into_inner())
2638 .snapshot(max_lines, max_bytes)
2639 }
2640
2641 pub fn terminal_history(&self) -> TerminalHistorySnapshot {
2646 self.inner
2647 .terminal_ring
2648 .lock()
2649 .unwrap_or_else(|poisoned| poisoned.into_inner())
2650 .snapshot()
2651 }
2652
2653 pub fn durable_terminal_history(&self) -> subc_control::TerminalHistory {
2658 durable_terminal_history_of(&self.inner.terminal_ring, &self.inner.module_id)
2659 }
2660
2661 pub(crate) async fn read_durable_terminal_history(
2666 &self,
2667 ) -> Result<subc_control::TerminalHistory, tokio::task::JoinError> {
2668 let terminal_ring = Arc::clone(&self.inner.terminal_ring);
2669 let module_id = self.inner.module_id.clone();
2670 tokio::task::spawn_blocking(move || durable_terminal_history_of(&terminal_ring, &module_id))
2671 .await
2672 }
2673
2674 pub fn status(&self) -> Result<ModuleStatus, SuperviseError> {
2675 self.status_with_snapshot_lock(&self.inner.snapshot, None)
2676 }
2677
2678 pub(crate) fn record_deliberate_severance(
2679 &self,
2680 identity: ProcessIdentity,
2681 ) -> Result<bool, SuperviseError> {
2682 let mut snapshot = lock_snapshot(&self.inner.snapshot)?;
2683 if snapshot.pid != Some(identity.pid)
2684 || snapshot.process_start_time != Some(identity.start_time)
2685 {
2686 return Ok(false);
2687 }
2688 snapshot.deliberate_severance = Some(identity);
2689 Ok(true)
2690 }
2691
2692 pub(crate) fn status_for_control(
2697 &self,
2698 caller: &'static str,
2699 ) -> Result<ModuleStatus, SuperviseError> {
2700 self.status_with_snapshot_lock(&self.inner.snapshot, Some(caller))
2701 }
2702
2703 fn status_with_snapshot_lock(
2704 &self,
2705 snapshot: &SharedSnapshot,
2706 caller: Option<&'static str>,
2707 ) -> Result<ModuleStatus, SuperviseError> {
2708 let mut guard = match caller {
2709 Some(caller) => lock_snapshot_for_control(snapshot, &self.inner.module_id, caller)?,
2710 None => lock_snapshot(snapshot)?,
2711 };
2712 let restart_count =
2715 guard.crash_restarts_in_window(self.inner.restart_policy.window, Instant::now());
2716 let snapshot = guard.clone();
2717 drop(guard);
2718 let drain_timeout = *self.inner.effective_drain_timeout.lock().map_err(|_| {
2719 SuperviseError::StatePoisoned {
2720 module_id: Some(self.inner.module_id.clone()),
2721 }
2722 })?;
2723 let registration_active = self
2724 .inner
2725 .registry
2726 .get_module(&self.inner.module_id)
2727 .map_err(SuperviseError::Registry)?
2728 .is_some();
2729 let protocol = self.declared_protocol()?;
2730 let running_process =
2731 snapshot.enabled && snapshot.state == ModuleState::Running && snapshot.process_alive;
2732 let live = match protocol {
2738 ModuleProtocol::Subc => running_process && registration_active,
2739 ModuleProtocol::None => running_process,
2740 };
2741
2742 Ok(ModuleStatus {
2743 module_id: self.inner.module_id.clone(),
2744 state: snapshot.state,
2745 enabled: snapshot.enabled,
2746 process_alive: snapshot.process_alive,
2747 registration_active,
2748 protocol,
2749 live,
2750 restart_count,
2751 lifetime_restarts: snapshot.lifetime_restarts,
2752 spawn_generation: snapshot.spawn_generation,
2753 max_restarts: self.inner.restart_policy.max_restarts,
2754 restart_window: self.inner.restart_policy.window,
2755 drain_timeout,
2756 restart_backoff: self.inner.restart_policy.backoff,
2757 restart_max_backoff: self.inner.restart_policy.max_backoff,
2758 pid: snapshot.pid,
2759 spawned_at_ms: snapshot.spawned_at_ms,
2760 spawned_from: snapshot.spawned_from,
2761 process_start_time: snapshot.process_start_time,
2762 last_exit: snapshot.last_exit,
2763 health: snapshot.health,
2764 })
2765 }
2766
2767 #[cfg(test)]
2768 pub(crate) fn hold_snapshot_for_test(
2769 &self,
2770 acquired: std::sync::mpsc::Sender<()>,
2771 hold: Duration,
2772 ) -> std::thread::JoinHandle<()> {
2773 let snapshot = Arc::clone(&self.inner.snapshot);
2774 std::thread::spawn(move || {
2775 let _guard = snapshot.lock().expect("test snapshot lock is not poisoned");
2776 acquired
2777 .send(())
2778 .expect("test receiver waits for snapshot lock");
2779 std::thread::sleep(hold);
2780 })
2781 }
2782
2783 pub(crate) async fn running_image_agreement(&self) -> subc_control::RunningImageAgreement {
2784 let snapshot = match lock_snapshot(&self.inner.snapshot) {
2785 Ok(snapshot) => snapshot.clone(),
2786 Err(_) => {
2787 return subc_control::RunningImageAgreement::Unavailable {
2788 reason: subc_control::RunningImageUnavailableReason::NotRunning,
2789 };
2790 }
2791 };
2792 self.inner
2793 .provenance_probe
2794 .observe(
2795 snapshot.pid,
2796 snapshot.spawned_from.as_deref(),
2797 snapshot.spawned_file_identity,
2798 snapshot.process_start_time,
2799 )
2800 .await
2801 }
2802
2803 pub(crate) fn child_resource_usage(&self) -> subc_control::ChildResourceUsage {
2806 let (pid, start_time) = match lock_snapshot(&self.inner.snapshot) {
2807 Ok(snapshot) => (snapshot.pid, snapshot.process_start_time),
2808 Err(_) => {
2809 return subc_control::ChildResourceUsage::Unavailable {
2810 reason: subc_control::ChildResourceUnavailableReason::Unreadable,
2811 }
2812 }
2813 };
2814 crate::child_resources::read(pid, start_time)
2815 }
2816
2817 pub(crate) fn will_recover_after_connection_loss(&self) -> Result<bool, SuperviseError> {
2818 let mut snapshot = lock_snapshot(&self.inner.snapshot)?;
2819 Ok(match snapshot.state {
2820 ModuleState::Restarting => true,
2821 ModuleState::Failed | ModuleState::Disabled => false,
2822 _ => daemon_will_restart(&mut snapshot, &self.inner.restart_policy, Instant::now()),
2823 })
2824 }
2825
2826 #[cfg(test)]
2827 pub(crate) fn is_warming(&self) -> Result<bool, SuperviseError> {
2828 self.is_warming_with_snapshot_lock(None)
2829 }
2830
2831 pub(crate) fn is_warming_for_control(
2832 &self,
2833 caller: &'static str,
2834 ) -> Result<bool, SuperviseError> {
2835 self.is_warming_with_snapshot_lock(Some(caller))
2836 }
2837
2838 fn is_warming_with_snapshot_lock(
2839 &self,
2840 caller: Option<&'static str>,
2841 ) -> Result<bool, SuperviseError> {
2842 let snapshot = match caller {
2843 Some(caller) => {
2844 lock_snapshot_for_control(&self.inner.snapshot, &self.inner.module_id, caller)?
2845 }
2846 None => lock_snapshot(&self.inner.snapshot)?,
2847 }
2848 .clone();
2849 Ok(matches!(
2850 snapshot.state,
2851 ModuleState::Starting | ModuleState::Running | ModuleState::Restarting
2852 ))
2853 }
2854
2855 pub async fn drain(&self) -> Result<(), SuperviseError> {
2857 self.stop().await
2858 }
2859
2860 pub(crate) async fn retire(&self) -> Result<(), SuperviseError> {
2861 match self.state()? {
2862 ModuleState::Stopped | ModuleState::Failed => return Ok(()),
2863 ModuleState::Starting
2864 | ModuleState::Running
2865 | ModuleState::Unresponsive
2866 | ModuleState::Restarting
2867 | ModuleState::Draining
2868 | ModuleState::Disabled => {}
2869 }
2870
2871 let (reply_tx, reply_rx) = oneshot::channel();
2872 self.inner
2873 .commands
2874 .send(SupervisorCommand::Retire { reply: reply_tx })
2875 .await
2876 .map_err(|_| SuperviseError::CommandClosed {
2877 module_id: self.inner.module_id.clone(),
2878 })?;
2879 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
2880 module_id: self.inner.module_id.clone(),
2881 })?
2882 }
2883
2884 pub async fn stop(&self) -> Result<(), SuperviseError> {
2885 match self.state()? {
2886 ModuleState::Stopped | ModuleState::Failed => return Ok(()),
2887 ModuleState::Starting
2888 | ModuleState::Running
2889 | ModuleState::Unresponsive
2890 | ModuleState::Restarting
2891 | ModuleState::Draining
2892 | ModuleState::Disabled => {}
2893 }
2894
2895 let (reply_tx, reply_rx) = oneshot::channel();
2896 self.inner
2897 .commands
2898 .send(SupervisorCommand::Drain { reply: reply_tx })
2899 .await
2900 .map_err(|_| SuperviseError::CommandClosed {
2901 module_id: self.inner.module_id.clone(),
2902 })?;
2903 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
2904 module_id: self.inner.module_id.clone(),
2905 })?
2906 }
2907
2908 pub async fn restart(&self, drain_timeout_ms: Option<u64>) -> Result<(), SuperviseError> {
2909 let received_at_generation = lock_snapshot(&self.inner.snapshot)?.spawn_generation;
2910 let (reply_tx, reply_rx) = oneshot::channel();
2911 self.inner
2912 .commands
2913 .send(SupervisorCommand::Restart {
2914 drain_timeout_ms,
2915 received_at_generation,
2916 queued_at: Instant::now(),
2917 reply: reply_tx,
2918 })
2919 .await
2920 .map_err(|_| SuperviseError::CommandClosed {
2921 module_id: self.inner.module_id.clone(),
2922 })?;
2923 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
2924 module_id: self.inner.module_id.clone(),
2925 })?
2926 }
2927
2928 pub async fn swap(&self, ready_timeout: Option<Duration>) -> Result<(), SuperviseError> {
2933 let (reply_tx, reply_rx) = oneshot::channel();
2934 self.inner
2935 .commands
2936 .send(SupervisorCommand::Swap {
2937 ready_timeout,
2938 reply: reply_tx,
2939 })
2940 .await
2941 .map_err(|_| SuperviseError::CommandClosed {
2942 module_id: self.inner.module_id.clone(),
2943 })?;
2944 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
2945 module_id: self.inner.module_id.clone(),
2946 })?
2947 }
2948
2949 pub async fn reload(&self) -> Result<(), SuperviseError> {
2950 let (reply_tx, reply_rx) = oneshot::channel();
2951 self.inner
2952 .commands
2953 .send(SupervisorCommand::Reload { reply: reply_tx })
2954 .await
2955 .map_err(|_| SuperviseError::CommandClosed {
2956 module_id: self.inner.module_id.clone(),
2957 })?;
2958 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
2959 module_id: self.inner.module_id.clone(),
2960 })?
2961 }
2962
2963 pub async fn set_enabled(&self, enabled: bool) -> Result<bool, SuperviseError> {
2964 let (reply_tx, reply_rx) = oneshot::channel();
2965 self.inner
2966 .commands
2967 .send(SupervisorCommand::SetEnabled {
2968 enabled,
2969 reply: reply_tx,
2970 })
2971 .await
2972 .map_err(|_| SuperviseError::CommandClosed {
2973 module_id: self.inner.module_id.clone(),
2974 })?;
2975 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
2976 module_id: self.inner.module_id.clone(),
2977 })?
2978 }
2979
2980 pub(crate) fn declared_protocol(&self) -> Result<ModuleProtocol, SuperviseError> {
2985 Ok(self
2986 .inner
2987 .configuration
2988 .lock()
2989 .map_err(|_| SuperviseError::StatePoisoned {
2990 module_id: Some(self.inner.module_id.clone()),
2991 })?
2992 .spec
2993 .protocol)
2994 }
2995
2996 pub(crate) fn configuration(&self) -> Result<(ModuleSpec, HealthConfig), SuperviseError> {
2997 let configuration =
2998 self.inner
2999 .configuration
3000 .lock()
3001 .map_err(|_| SuperviseError::StatePoisoned {
3002 module_id: Some(self.inner.module_id.clone()),
3003 })?;
3004 Ok((configuration.spec.clone(), configuration.health))
3005 }
3006
3007 #[cfg(any(test, feature = "test-support"))]
3011 pub async fn update_spec_for_test(&self, spec: ModuleSpec) -> Result<(), SuperviseError> {
3012 let (_, health) = self.configuration()?;
3013 let drain_timeout_ms = u64::try_from(
3014 self.inner
3015 .effective_drain_timeout
3016 .lock()
3017 .unwrap_or_else(|poisoned| poisoned.into_inner())
3018 .as_millis(),
3019 )
3020 .ok();
3021 self.update_configuration(spec, health, drain_timeout_ms)
3022 .await
3023 }
3024
3025 pub(crate) async fn update_configuration(
3026 &self,
3027 spec: ModuleSpec,
3028 health: HealthConfig,
3029 drain_timeout_ms: Option<u64>,
3030 ) -> Result<(), SuperviseError> {
3031 if spec.module_id != self.inner.module_id {
3032 return Err(SuperviseError::InvalidSpec {
3033 reason: "a supervised module's module_id cannot be changed".to_string(),
3034 });
3035 }
3036 validate_spec(&spec)?;
3037 let (reply_tx, reply_rx) = oneshot::channel();
3038 self.inner
3039 .commands
3040 .send(SupervisorCommand::UpdateConfiguration {
3041 spec: spec.clone(),
3042 health,
3043 drain_timeout_ms,
3044 reply: reply_tx,
3045 })
3046 .await
3047 .map_err(|_| SuperviseError::CommandClosed {
3048 module_id: self.inner.module_id.clone(),
3049 })?;
3050 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
3051 module_id: self.inner.module_id.clone(),
3052 })?;
3053 let mut configuration =
3054 self.inner
3055 .configuration
3056 .lock()
3057 .map_err(|_| SuperviseError::StatePoisoned {
3058 module_id: Some(self.inner.module_id.clone()),
3059 })?;
3060 configuration.spec = spec;
3061 configuration.health = health;
3062 Ok(())
3063 }
3064}
3065
3066impl Drop for SupervisedModuleInner {
3067 fn drop(&mut self) {
3068 let Ok(mut monitor) = self.monitor.lock() else {
3069 return;
3070 };
3071 if let Some(monitor) = monitor.as_ref().filter(|monitor| !monitor.is_finished()) {
3072 let _ = update_snapshot(&self.snapshot, Some(&self.module_id), |state| {
3073 state.state = ModuleState::Stopped;
3074 clear_current_process_facts(state);
3075 });
3076 monitor.abort();
3077 }
3078 let _ = monitor.take();
3079 }
3080}
3081
3082#[derive(Debug)]
3083enum SupervisorCommand {
3084 Drain {
3085 reply: oneshot::Sender<Result<(), SuperviseError>>,
3086 },
3087 Retire {
3088 reply: oneshot::Sender<Result<(), SuperviseError>>,
3089 },
3090 Restart {
3091 drain_timeout_ms: Option<u64>,
3096 received_at_generation: u64,
3100 queued_at: Instant,
3103 reply: oneshot::Sender<Result<(), SuperviseError>>,
3104 },
3105 Reload {
3106 reply: oneshot::Sender<Result<(), SuperviseError>>,
3107 },
3108 SetEnabled {
3109 enabled: bool,
3110 reply: oneshot::Sender<Result<bool, SuperviseError>>,
3111 },
3112 UpdateConfiguration {
3113 spec: ModuleSpec,
3114 health: HealthConfig,
3115 drain_timeout_ms: Option<u64>,
3118 reply: oneshot::Sender<()>,
3119 },
3120 Swap {
3121 ready_timeout: Option<Duration>,
3124 reply: oneshot::Sender<Result<(), SuperviseError>>,
3126 },
3127}
3128
3129#[derive(Debug)]
3130pub enum SuperviseError {
3131 InvalidSpec {
3132 reason: String,
3133 },
3134 Spawn {
3135 program: PathBuf,
3136 source: io::Error,
3137 cgroup_path: Option<PathBuf>,
3138 },
3139 Cgroup {
3140 module_id: String,
3141 source: io::Error,
3142 },
3143 LaunchNonce {
3146 reason: String,
3147 },
3148 Wait {
3149 module_id: String,
3150 source: io::Error,
3151 },
3152 Kill {
3153 module_id: String,
3154 source: io::Error,
3155 },
3156 Forwarding(ForwardingError),
3157 Registry(RegistryError),
3158 ReloadUnavailable {
3159 module_id: String,
3160 reason: String,
3161 },
3162 Disabled {
3167 module_id: String,
3168 },
3169 ReloadFailed {
3170 module_id: String,
3171 reason: String,
3172 },
3173 RegistrationStillActive {
3174 module_id: String,
3175 waited: Duration,
3176 },
3177 StatePoisoned {
3178 module_id: Option<String>,
3179 },
3180 CommandClosed {
3181 module_id: String,
3182 },
3183 SwapInProgress {
3187 module_id: String,
3188 },
3189 SwapRefused {
3191 module_id: String,
3192 reason: SwapRefusal,
3193 },
3194 SwapFailed {
3198 module_id: String,
3199 arm: SwapFailureArm,
3200 detail: String,
3201 candidate_exit: Option<ExitReport>,
3204 },
3205}
3206
3207#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3209pub enum SwapRefusal {
3210 OverlapExclusive,
3212 NotRegistered,
3215 ProtocolNone,
3218 NotConfigured,
3221 AlreadySwapping,
3223}
3224
3225impl SwapRefusal {
3226 pub fn as_str(self) -> &'static str {
3227 match self {
3228 Self::OverlapExclusive => "overlap_exclusive",
3229 Self::NotRegistered => "not_registered",
3230 Self::ProtocolNone => "protocol_none",
3231 Self::NotConfigured => "not_configured",
3232 Self::AlreadySwapping => "already_swapping",
3233 }
3234 }
3235}
3236
3237#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3240pub enum SwapFailureArm {
3241 SpawnFailed,
3243 NeverRegistered,
3245 NeverReady,
3247 CandidateExited,
3249 CandidateUnhealthy,
3251 Interrupted,
3255 CutoverLost,
3260}
3261
3262impl SwapFailureArm {
3263 pub fn as_str(self) -> &'static str {
3264 match self {
3265 Self::SpawnFailed => "spawn_failed",
3266 Self::NeverRegistered => "never_registered",
3267 Self::NeverReady => "never_ready",
3268 Self::CandidateExited => "candidate_exited",
3269 Self::CandidateUnhealthy => "candidate_unhealthy",
3270 Self::Interrupted => "interrupted",
3271 Self::CutoverLost => "cutover_lost",
3272 }
3273 }
3274}
3275
3276impl fmt::Display for SuperviseError {
3277 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3278 match self {
3279 Self::InvalidSpec { reason } => write!(f, "invalid module spec: {reason}"),
3280 Self::Spawn {
3281 program,
3282 source,
3283 cgroup_path: Some(cgroup_path),
3284 } => write!(
3285 f,
3286 "failed to place module in cgroup '{}' while spawning '{}': {source}",
3287 cgroup_path.display(),
3288 program.display()
3289 ),
3290 Self::Spawn {
3291 program,
3292 source,
3293 cgroup_path: None,
3294 } => write!(
3295 f,
3296 "failed to spawn module '{}': {source}",
3297 program.display()
3298 ),
3299 Self::Cgroup { module_id, source } => {
3300 write!(
3301 f,
3302 "failed to prepare cgroup for module '{module_id}': {source}"
3303 )
3304 }
3305 Self::LaunchNonce { reason } => {
3306 write!(
3307 f,
3308 "failed to generate reserved-module launch nonce: {reason}"
3309 )
3310 }
3311 Self::Wait { module_id, source } => {
3312 write!(f, "failed to wait for module '{module_id}': {source}")
3313 }
3314 Self::Kill { module_id, source } => {
3315 write!(f, "failed to kill module '{module_id}': {source}")
3316 }
3317 Self::Forwarding(err) => write!(f, "forwarding error: {err}"),
3318 Self::Registry(err) => write!(f, "registry error: {err}"),
3319 Self::ReloadUnavailable { module_id, reason } => {
3320 write!(f, "reload unavailable for module '{module_id}': {reason}")
3321 }
3322 Self::Disabled { module_id } => {
3323 write!(
3324 f,
3325 "module '{module_id}' is disabled; enable it before restart or reload"
3326 )
3327 }
3328 Self::ReloadFailed { module_id, reason } => {
3329 write!(f, "reload failed for module '{module_id}': {reason}")
3330 }
3331 Self::RegistrationStillActive { module_id, waited } => write!(
3332 f,
3333 "module '{module_id}' registration remained active after waiting {waited:?}"
3334 ),
3335 Self::StatePoisoned { module_id } => match module_id {
3336 Some(module_id) => {
3337 write!(f, "supervisor state for module '{module_id}' was poisoned")
3338 }
3339 None => write!(f, "supervisor state was poisoned"),
3340 },
3341 Self::CommandClosed { module_id } => {
3342 write!(
3343 f,
3344 "supervisor command channel for module '{module_id}' is closed"
3345 )
3346 }
3347 Self::SwapInProgress { module_id } => write!(
3348 f,
3349 "module '{module_id}' is being swapped; retry once the swap has cut over or failed, or stop the module to abort the swap"
3350 ),
3351 Self::SwapRefused { module_id, reason } => match reason {
3352 SwapRefusal::OverlapExclusive => write!(
3353 f,
3354 "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"
3355 ),
3356 SwapRefusal::NotRegistered => write!(
3357 f,
3358 "module '{module_id}' is not registered, so there is no serving process to keep while a replacement warms; use a plain restart"
3359 ),
3360 SwapRefusal::ProtocolNone => write!(
3361 f,
3362 "module '{module_id}' is protocol: \"none\" and never registers, so a swap could never see its replacement become ready; use a plain restart"
3363 ),
3364 SwapRefusal::NotConfigured => write!(
3365 f,
3366 "module '{module_id}' cannot be swapped: the supervisor was built without the forwarding table or shared handle a swap needs"
3367 ),
3368 SwapRefusal::AlreadySwapping => {
3369 write!(f, "module '{module_id}' is already being swapped")
3370 }
3371 },
3372 Self::SwapFailed {
3373 module_id,
3374 arm,
3375 detail,
3376 ..
3377 } => write!(
3378 f,
3379 "swap of module '{module_id}' failed ({}): {detail}; the running process was left serving",
3380 arm.as_str()
3381 ),
3382 }
3383 }
3384}
3385
3386impl Error for SuperviseError {
3387 fn source(&self) -> Option<&(dyn Error + 'static)> {
3388 match self {
3389 Self::Spawn { source, .. }
3390 | Self::Cgroup { source, .. }
3391 | Self::Wait { source, .. }
3392 | Self::Kill { source, .. } => Some(source),
3393 Self::Forwarding(err) => Some(err),
3394 Self::Registry(err) => Some(err),
3395 Self::LaunchNonce { .. }
3396 | Self::InvalidSpec { .. }
3397 | Self::ReloadUnavailable { .. }
3398 | Self::Disabled { .. }
3399 | Self::ReloadFailed { .. }
3400 | Self::RegistrationStillActive { .. }
3401 | Self::StatePoisoned { .. }
3402 | Self::CommandClosed { .. }
3403 | Self::SwapInProgress { .. }
3404 | Self::SwapRefused { .. }
3405 | Self::SwapFailed { .. } => None,
3406 }
3407 }
3408}
3409
3410pub(crate) fn validate_spec(spec: &ModuleSpec) -> Result<(), SuperviseError> {
3411 if spec.module_id.trim().is_empty() {
3412 return Err(SuperviseError::InvalidSpec {
3413 reason: "module_id must not be empty".to_string(),
3414 });
3415 }
3416
3417 Ok(())
3418}
3419
3420#[derive(Debug, Default)]
3421struct HealthProbeRuntime {
3422 registered_connection: Option<crate::ConnectionId>,
3423 advertised: bool,
3424 next_probe_at: Option<Instant>,
3425 probe_index: u64,
3426}
3427
3428impl HealthProbeRuntime {
3429 fn refresh_registration(
3430 &mut self,
3431 spec: &ModuleSpec,
3432 runtime: &SupervisorRuntimeConfig,
3433 registry: &Registry,
3434 snapshot: &SharedSnapshot,
3435 ) {
3436 if spec.protocol == ModuleProtocol::None {
3448 self.registered_connection = None;
3449 self.advertised = false;
3450 self.next_probe_at = None;
3451 return;
3452 }
3453
3454 let registration = match registry.get_module(&spec.module_id) {
3455 Ok(registration) => registration,
3456 Err(err) => {
3457 warn!(module_id = %spec.module_id, error = %err, "health prober could not read registry");
3458 self.advertised = false;
3459 self.next_probe_at = None;
3460 return;
3461 }
3462 };
3463
3464 let Some(registration) = registration else {
3465 self.registered_connection = None;
3466 self.advertised = false;
3467 self.next_probe_at = None;
3468 return;
3469 };
3470
3471 let advertised = registration
3472 .control_ops
3473 .iter()
3474 .any(|op| op == MODULE_CONTROL_OP_HEALTH_CHECK);
3475 if !advertised {
3476 self.registered_connection = Some(registration.connection_id);
3477 self.advertised = false;
3478 self.next_probe_at = None;
3479 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
3480 state.health.status = SupervisorHealthStatus::Unknown;
3481 state.health.consecutive_failures = 0;
3482 state.health.last_probe_ms = None;
3483 state.health.detail = None;
3484 state.health.metrics = None;
3485 });
3486 return;
3487 }
3488
3489 let reregistered = self.registered_connection != Some(registration.connection_id);
3490 self.registered_connection = Some(registration.connection_id);
3491 self.advertised = true;
3492 if reregistered || self.next_probe_at.is_none() {
3493 self.probe_index = 0;
3494 self.next_probe_at = Some(
3495 Instant::now() + jittered_health_delay(&spec.module_id, 0, runtime.health.cadence),
3496 );
3497 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
3498 state.health.status = SupervisorHealthStatus::Unknown;
3499 state.health.consecutive_failures = 0;
3500 state.health.detail = None;
3501 state.health.metrics = None;
3502 });
3503 }
3504 }
3505
3506 fn wake_after(&self) -> Duration {
3507 if !self.advertised {
3508 return REGISTRY_RELEASE_POLL;
3509 }
3510 self.next_probe_at
3511 .map(|next| next.saturating_duration_since(Instant::now()))
3512 .unwrap_or(REGISTRY_RELEASE_POLL)
3513 }
3514
3515 fn due(&self) -> bool {
3516 self.advertised
3517 && self
3518 .next_probe_at
3519 .is_some_and(|next| Instant::now() >= next)
3520 }
3521
3522 fn schedule_next(&mut self, spec: &ModuleSpec, cadence: Duration) {
3523 self.probe_index = self.probe_index.wrapping_add(1);
3524 self.next_probe_at = Some(
3525 Instant::now() + jittered_health_delay(&spec.module_id, self.probe_index, cadence),
3526 );
3527 }
3528}
3529
3530#[derive(Debug)]
3565enum HealthProbeEvidence {
3566 LaneDead,
3568 NoAnswer,
3570 BadAnswer,
3572 Misconfigured,
3574}
3575
3576#[derive(Debug)]
3577struct HealthProbeError {
3578 evidence: HealthProbeEvidence,
3579 message: String,
3580}
3581
3582impl HealthProbeError {
3583 fn lane_dead(message: impl Into<String>) -> Self {
3584 Self::with(HealthProbeEvidence::LaneDead, message)
3585 }
3586
3587 fn no_answer(message: impl Into<String>) -> Self {
3588 Self::with(HealthProbeEvidence::NoAnswer, message)
3589 }
3590
3591 fn bad_answer(message: impl Into<String>) -> Self {
3592 Self::with(HealthProbeEvidence::BadAnswer, message)
3593 }
3594
3595 fn misconfigured(message: impl Into<String>) -> Self {
3596 Self::with(HealthProbeEvidence::Misconfigured, message)
3597 }
3598
3599 fn with(evidence: HealthProbeEvidence, message: impl Into<String>) -> Self {
3600 Self {
3601 evidence,
3602 message: message.into(),
3603 }
3604 }
3605
3606 #[allow(dead_code)]
3620 fn is_proof_of_death(&self) -> bool {
3621 matches!(self.evidence, HealthProbeEvidence::LaneDead)
3622 }
3623
3624 fn label(&self) -> &'static str {
3632 match self.evidence {
3633 HealthProbeEvidence::LaneDead => "lane-dead",
3634 HealthProbeEvidence::NoAnswer => "no-answer",
3635 HealthProbeEvidence::BadAnswer => "bad-answer",
3636 HealthProbeEvidence::Misconfigured => "daemon-misconfigured",
3637 }
3638 }
3639}
3640
3641impl fmt::Display for HealthProbeError {
3642 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3643 f.write_str(&self.message)
3644 }
3645}
3646
3647async fn run_health_probe_cycle(
3648 spec: &ModuleSpec,
3649 runtime: &SupervisorRuntimeConfig,
3650 registry: &Registry,
3651 process_liveness: &SupervisorProcessLiveness,
3652 snapshot: &SharedSnapshot,
3653 child: &mut Option<SupervisedChild>,
3654) {
3655 let now_ms = unix_ms_now();
3656 match probe_module_health(&spec.module_id, runtime, None).await {
3657 Ok(report) => {
3658 handle_health_report(
3659 spec,
3660 runtime,
3661 registry,
3662 process_liveness,
3663 snapshot,
3664 child,
3665 report,
3666 now_ms,
3667 )
3668 .await;
3669 }
3670 Err(err) => {
3671 handle_health_probe_failure(
3672 spec,
3673 runtime,
3674 registry,
3675 process_liveness,
3676 snapshot,
3677 child,
3678 err,
3679 now_ms,
3680 )
3681 .await;
3682 }
3683 }
3684}
3685
3686async fn probe_module_health(
3687 module_id: &str,
3688 runtime: &SupervisorRuntimeConfig,
3689 drain_deadline: Option<Instant>,
3690) -> Result<HealthReport, HealthProbeError> {
3691 let Some(forwarding) = runtime.forwarding.as_ref() else {
3692 return Err(HealthProbeError::misconfigured(
3693 "supervisor was not configured with a forwarding table",
3694 ));
3695 };
3696 let probe_started_at = Instant::now();
3697 let mut deadline = probe_started_at + runtime.health.deadline;
3698 if let Some(drain_deadline) = drain_deadline {
3699 deadline = deadline.min(drain_deadline);
3700 }
3701 let pending = if drain_deadline.is_some() {
3702 forwarding.begin_drain_health_probe_rpc_for(
3703 module_id,
3704 MODULE_CONTROL_OP_HEALTH_CHECK,
3705 probe_started_at,
3706 deadline,
3707 )
3708 } else {
3709 forwarding.begin_health_probe_rpc_for(
3710 module_id,
3711 MODULE_CONTROL_OP_HEALTH_CHECK,
3712 probe_started_at,
3713 deadline,
3714 )
3715 }
3716 .map_err(|err| {
3717 HealthProbeError::lane_dead(format!("failed to begin health.check RPC: {err}"))
3720 })?;
3721 await_health_probe(forwarding, pending, deadline, runtime.health.deadline).await
3722}
3723
3724async fn probe_endpoint_health(
3731 endpoint: crate::ModuleEndpointId,
3732 runtime: &SupervisorRuntimeConfig,
3733 deadline_cap: Option<Instant>,
3734) -> Result<HealthReport, HealthProbeError> {
3735 let Some(forwarding) = runtime.forwarding.as_ref() else {
3736 return Err(HealthProbeError::misconfigured(
3737 "supervisor was not configured with a forwarding table",
3738 ));
3739 };
3740 let probe_started_at = Instant::now();
3741 let mut deadline = probe_started_at + runtime.health.deadline;
3742 if let Some(cap) = deadline_cap {
3743 deadline = deadline.min(cap);
3744 }
3745 let pending = forwarding
3746 .begin_endpoint_health_probe_rpc_for(
3747 endpoint,
3748 MODULE_CONTROL_OP_HEALTH_CHECK,
3749 probe_started_at,
3750 deadline,
3751 )
3752 .map_err(|err| {
3753 HealthProbeError::lane_dead(format!("failed to begin health.check RPC: {err}"))
3754 })?;
3755 await_health_probe(forwarding, pending, deadline, runtime.health.deadline).await
3756}
3757
3758async fn await_health_probe(
3760 forwarding: &ForwardingTable,
3761 pending: PendingModuleControlRpc,
3762 deadline: Instant,
3763 probe_budget: Duration,
3764) -> Result<HealthReport, HealthProbeError> {
3765 let PendingModuleControlRpc {
3766 endpoint,
3767 module_sink,
3768 negotiated_ver,
3769 corr,
3770 receiver,
3771 } = pending;
3772 let body = serde_json::to_vec(&ModuleControlRequest::HealthCheck {}).map_err(|err| {
3773 HealthProbeError::misconfigured(format!("failed to encode health.check: {err}"))
3774 })?;
3775 let frame = Frame::build_with_version(
3776 negotiated_ver,
3777 FrameType::Request,
3778 control_flags(),
3779 0,
3780 0,
3781 corr,
3782 body,
3783 )
3784 .map_err(|err| {
3785 HealthProbeError::misconfigured(format!("failed to build health.check frame: {err}"))
3786 })?;
3787
3788 match timeout_at(deadline, module_sink.send(frame)).await {
3794 Ok(Ok(())) => {}
3795 Ok(Err(err)) => {
3796 let _ = forwarding.cancel_module_control_rpc(endpoint, corr);
3797 return Err(HealthProbeError::lane_dead(format!(
3800 "failed to send health.check: {err}"
3801 )));
3802 }
3803 Err(_elapsed) => {
3804 let _ = forwarding.cancel_module_control_rpc(endpoint, corr);
3805 return Err(HealthProbeError::no_answer(
3809 "health.check send timed out before enqueue (module egress full)",
3810 ));
3811 }
3812 }
3813
3814 match timeout_at(deadline, receiver).await {
3815 Ok(Ok(ModuleControlRpcOutcome::Response(response))) => {
3819 response.health_report().ok_or_else(|| {
3820 HealthProbeError::bad_answer("health.check RPC returned a non-health response")
3821 })
3822 }
3823 Ok(Ok(ModuleControlRpcOutcome::Rejected(body))) => Err(HealthProbeError::bad_answer(
3824 format!("health.check rejected: {}", body.message),
3825 )),
3826 Ok(Ok(ModuleControlRpcOutcome::ModuleGone(message))) => {
3827 Err(HealthProbeError::lane_dead(message))
3828 }
3829 Ok(Ok(ModuleControlRpcOutcome::MalformedResponse(message))) => {
3830 Err(HealthProbeError::bad_answer(message))
3831 }
3832 Ok(Ok(ModuleControlRpcOutcome::UnexpectedOp { expected, actual })) => {
3833 Err(HealthProbeError::bad_answer(format!(
3834 "expected module-control op '{expected}', got '{actual}'"
3835 )))
3836 }
3837 Ok(Ok(ModuleControlRpcOutcome::DeadlineElapsed)) => Err(HealthProbeError::bad_answer(
3841 "module answered health.check after its daemon deadline",
3842 )),
3843 Ok(Err(_)) => Err(HealthProbeError::misconfigured(
3844 "health.check waiter was canceled before the module responded",
3845 )),
3846 Err(_) => {
3847 let _ = forwarding.tombstone_health_probe_rpc(endpoint, corr);
3848 Err(HealthProbeError::no_answer(format!(
3849 "module did not answer health.check within {probe_budget:?}"
3850 )))
3851 }
3852 }
3853}
3854
3855#[allow(clippy::too_many_arguments)]
3856async fn handle_health_report(
3857 spec: &ModuleSpec,
3858 runtime: &SupervisorRuntimeConfig,
3859 registry: &Registry,
3860 process_liveness: &SupervisorProcessLiveness,
3861 snapshot: &SharedSnapshot,
3862 child: &mut Option<SupervisedChild>,
3863 report: HealthReport,
3864 now_ms: u64,
3865) {
3866 let status = supervisor_health_status(report.status);
3867 let detail = report.detail.clone();
3868 let metrics = truncate_health_metrics(report.metrics);
3869 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
3870 state.health.status = status;
3871 state.health.last_probe_ms = Some(now_ms);
3872 state.health.detail = detail.clone();
3873 state.health.metrics = metrics.clone();
3874 state.health.consecutive_failures = 0;
3875 });
3876
3877 let action = match report.status {
3878 HealthStatus::Ok => return,
3879 HealthStatus::Degraded => runtime.health.on_degraded,
3880 HealthStatus::Failing => runtime.health.on_failing,
3881 };
3882 apply_l3_health_action(
3883 spec,
3884 runtime,
3885 registry,
3886 process_liveness,
3887 snapshot,
3888 child,
3889 status,
3890 detail.as_deref(),
3891 action,
3892 now_ms,
3893 )
3894 .await;
3895}
3896
3897#[allow(clippy::too_many_arguments)]
3898async fn handle_health_probe_failure(
3899 spec: &ModuleSpec,
3900 runtime: &SupervisorRuntimeConfig,
3901 registry: &Registry,
3902 process_liveness: &SupervisorProcessLiveness,
3903 snapshot: &SharedSnapshot,
3904 child: &mut Option<SupervisedChild>,
3905 err: HealthProbeError,
3906 now_ms: u64,
3907) {
3908 let threshold = runtime.health.failure_threshold.max(1);
3909 let mut failures = 0;
3910 let detail = format!("[{}] {err}", err.label());
3915 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
3916 state.health.last_probe_ms = Some(now_ms);
3917 state.health.consecutive_failures = state.health.consecutive_failures.saturating_add(1);
3918 state.health.detail = Some(detail.clone());
3919 state.health.metrics = None;
3920 failures = state.health.consecutive_failures;
3921 });
3922
3923 if failures < threshold {
3924 warn!(
3925 module_id = %spec.module_id,
3926 consecutive_failures = failures,
3927 threshold,
3928 evidence = err.label(),
3929 detail = %detail,
3930 "health.check probe failed"
3931 );
3932 return;
3933 }
3934
3935 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
3936 state.state = ModuleState::Unresponsive;
3937 state.health.status = SupervisorHealthStatus::Unresponsive;
3938 });
3939 if runtime.health.critical {
3943 error!(
3944 module_id = %spec.module_id,
3945 status = "unresponsive",
3946 evidence = err.label(),
3947 detail = %detail,
3948 "critical module health alert"
3949 );
3950 } else {
3951 warn!(
3952 module_id = %spec.module_id,
3953 status = "unresponsive",
3954 evidence = err.label(),
3955 detail = %detail,
3956 "module health threshold breached"
3957 );
3958 }
3959 if let Err(err) = health_restart_child(
3960 spec,
3961 runtime,
3962 registry,
3963 process_liveness,
3964 snapshot,
3965 child,
3966 SupervisorHealthStatus::Unresponsive,
3967 Some(&detail),
3968 now_ms,
3969 )
3970 .await
3971 {
3972 error!(module_id = %spec.module_id, error = %err, "health-triggered restart failed");
3973 }
3974}
3975
3976#[allow(clippy::too_many_arguments)]
3977async fn apply_l3_health_action(
3978 spec: &ModuleSpec,
3979 runtime: &SupervisorRuntimeConfig,
3980 registry: &Registry,
3981 process_liveness: &SupervisorProcessLiveness,
3982 snapshot: &SharedSnapshot,
3983 child: &mut Option<SupervisedChild>,
3984 status: SupervisorHealthStatus,
3985 detail: Option<&str>,
3986 action: HealthAction,
3987 now_ms: u64,
3988) {
3989 record_health_action(snapshot, &spec.module_id, action.to_string(), now_ms);
3990 match action {
3991 HealthAction::Report => {
3992 info!(
3993 module_id = %spec.module_id,
3994 status = ?status,
3995 detail,
3996 "module reported non-ok health"
3997 );
3998 }
3999 HealthAction::Alert => {
4000 error!(
4001 module_id = %spec.module_id,
4002 status = ?status,
4003 detail,
4004 "module health alert"
4005 );
4006 }
4007 HealthAction::Restart => {
4008 if let Err(err) = health_restart_child(
4009 spec,
4010 runtime,
4011 registry,
4012 process_liveness,
4013 snapshot,
4014 child,
4015 status,
4016 detail,
4017 now_ms,
4018 )
4019 .await
4020 {
4021 error!(module_id = %spec.module_id, error = %err, "health-triggered restart failed");
4022 }
4023 }
4024 }
4025}
4026
4027#[allow(clippy::too_many_arguments)]
4028async fn health_restart_child(
4029 spec: &ModuleSpec,
4030 runtime: &SupervisorRuntimeConfig,
4031 registry: &Registry,
4032 process_liveness: &SupervisorProcessLiveness,
4033 snapshot: &SharedSnapshot,
4034 child: &mut Option<SupervisedChild>,
4035 status: SupervisorHealthStatus,
4036 detail: Option<&str>,
4037 now_ms: u64,
4038) -> Result<(), SuperviseError> {
4039 let (enabled, schedule) = {
4040 let mut state = lock_snapshot(snapshot)?;
4041 let enabled = state.enabled;
4042 let schedule = if enabled {
4043 state.next_crash_restart(&runtime.restart_policy, Instant::now())
4044 } else {
4045 None
4046 };
4047 (enabled, schedule)
4048 };
4049
4050 if !enabled {
4051 return Err(SuperviseError::Disabled {
4052 module_id: spec.module_id.clone(),
4053 });
4054 }
4055
4056 if schedule.is_none() {
4057 record_health_action(snapshot, &spec.module_id, "disabled".to_string(), now_ms);
4058 error!(
4059 module_id = %spec.module_id,
4060 status = ?status,
4061 detail,
4062 max_restarts = runtime.restart_policy.max_restarts,
4063 window_secs = runtime.restart_policy.window.as_secs(),
4064 "health restart budget exhausted; disabling module"
4065 );
4066 let stop_notice = begin_forwarding_drain_if_configured(
4067 spec,
4068 runtime,
4069 registry,
4070 snapshot,
4071 Some(false),
4072 RouteCloseReason::Disable,
4073 )
4074 .await?;
4075 drain_optional_child(
4076 &spec.module_id,
4077 spec.protocol,
4078 stop_notice,
4079 registry,
4080 snapshot,
4081 &runtime.terminal_ring,
4082 &runtime.spawn_events,
4083 child,
4084 runtime.drain_timeout,
4085 ModuleState::Disabled,
4086 Some(false),
4087 )
4088 .await?;
4089 process_liveness.untrack_if_current(&spec.module_id, snapshot);
4090 return Ok(());
4091 }
4092
4093 let schedule = schedule.expect("a health restart must have a crash-restart schedule");
4094 let mut restart_count = 0;
4095 update_snapshot(snapshot, Some(&spec.module_id), |state| {
4096 restart_count = state.crash_restarts.len();
4097 state.state = ModuleState::Unresponsive;
4098 state.health.status = status;
4099 state.health.last_action = Some(HealthAction::Restart.to_string());
4100 state.health.last_action_ms = Some(now_ms);
4101 })?;
4102 warn!(
4103 module_id = %spec.module_id,
4104 status = ?status,
4105 detail,
4106 restart_count,
4107 restart_in_window = schedule.restart_in_window,
4108 delay_ms = schedule.delay.as_millis() as u64,
4109 "health-triggered module restart"
4110 );
4111
4112 let stop_notice = begin_forwarding_drain_if_configured(
4113 spec,
4114 runtime,
4115 registry,
4116 snapshot,
4117 Some(true),
4118 RouteCloseReason::Restart,
4119 )
4120 .await?;
4121 drain_optional_child(
4122 &spec.module_id,
4123 spec.protocol,
4124 stop_notice,
4125 registry,
4126 snapshot,
4127 &runtime.terminal_ring,
4128 &runtime.spawn_events,
4129 child,
4130 runtime.drain_timeout,
4131 ModuleState::Restarting,
4132 Some(true),
4133 )
4134 .await?;
4135 sleep(schedule.delay).await;
4136 if !respawn_still_pending(snapshot) {
4140 process_liveness.untrack_if_current(&spec.module_id, snapshot);
4141 return Ok(());
4142 }
4143 process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
4144 match spawn_and_mark_running(spec, runtime, snapshot) {
4145 Ok(next_child) => {
4146 *child = Some(next_child);
4147 Ok(())
4148 }
4149 Err(err) => {
4150 fail_snapshot(snapshot, Some(&spec.module_id), None);
4151 process_liveness.untrack_if_current(&spec.module_id, snapshot);
4152 *child = None;
4153 Err(err)
4154 }
4155 }
4156}
4157
4158fn record_health_action(snapshot: &SharedSnapshot, module_id: &str, action: String, now_ms: u64) {
4159 let _ = update_snapshot(snapshot, Some(module_id), |state| {
4160 state.health.last_action = Some(action);
4161 state.health.last_action_ms = Some(now_ms);
4162 });
4163}
4164
4165fn supervisor_health_status(status: HealthStatus) -> SupervisorHealthStatus {
4166 match status {
4167 HealthStatus::Ok => SupervisorHealthStatus::Ok,
4168 HealthStatus::Degraded => SupervisorHealthStatus::Degraded,
4169 HealthStatus::Failing => SupervisorHealthStatus::Failing,
4170 }
4171}
4172
4173fn truncate_health_metrics(metrics: Option<Value>) -> Option<Value> {
4185 let metrics = metrics?;
4186 match serde_json::to_vec(&metrics) {
4187 Ok(encoded) if encoded.len() > MAX_HEALTH_METRICS_BYTES => Some(serde_json::json!({
4188 "truncated": true,
4189 "original_bytes": encoded.len(),
4190 })),
4191 Ok(_) | Err(_) => Some(metrics),
4192 }
4193}
4194
4195fn jittered_health_delay(module_id: &str, probe_index: u64, cadence: Duration) -> Duration {
4201 if cadence.is_zero() {
4202 return Duration::ZERO;
4203 }
4204 let cadence_ms = cadence.as_millis() as u64;
4205 if cadence_ms == 0 {
4221 return cadence;
4222 }
4223 let jitter_span = (cadence_ms / 10).max(1);
4238 let hash = module_id.as_bytes().iter().fold(
4239 probe_index.wrapping_mul(0x9E37_79B9_7F4A_7C15),
4240 |acc, byte| {
4241 acc.wrapping_mul(1099511628211)
4242 .wrapping_add(u64::from(*byte))
4243 },
4244 );
4245 cadence + Duration::from_millis(hash % jitter_span)
4246}
4247
4248#[cfg(test)]
4249mod tests {
4250 use super::*;
4251
4252 #[test]
4253 fn readding_a_module_clears_its_rescan_removal_tombstone() {
4254 let handle = SupervisorHandle::new();
4255 let module_id = "readded-tombstone";
4256 handle.record_rescan_removal(module_id);
4257 assert!(handle.removal_tombstone_age_ms(module_id).is_some());
4258
4259 handle.apply_identity_configuration(&ModuleSpec {
4260 module_id: module_id.to_string(),
4261 program: PathBuf::from("/test/module"),
4262 args: Vec::new(),
4263 env: Vec::new(),
4264 reserved: false,
4265 reserved_prefixes: Vec::new(),
4266 protocol: ModuleProtocol::Subc,
4267 overlap: Default::default(),
4268 });
4269
4270 assert!(
4271 handle.removal_tombstone_age_ms(module_id).is_none(),
4272 "a re-added module must not retain a stale removal tombstone"
4273 );
4274 }
4275
4276 fn stale_process_snapshot(state: ModuleState, enabled: bool) -> SharedSnapshot {
4277 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(state, enabled)));
4278 update_snapshot(&snapshot, Some("stale-process-facts"), |snapshot| {
4279 snapshot.process_alive = true;
4280 snapshot.pid = Some(41);
4281 snapshot.spawned_at_ms = Some(42);
4282 snapshot.spawned_from = Some(PathBuf::from("/spawned/module"));
4283 snapshot.spawned_file_identity = Some(SpawnedFileIdentity {
4284 device: 43,
4285 inode: 44,
4286 });
4287 })
4288 .unwrap();
4289 snapshot
4290 }
4291
4292 fn assert_snapshot_process_facts_cleared(snapshot: &SharedSnapshot) {
4293 let snapshot = lock_snapshot(snapshot).unwrap();
4294 assert!(!snapshot.process_alive);
4295 assert_eq!(snapshot.pid, None);
4296 assert_eq!(snapshot.spawned_at_ms, None);
4297 assert_eq!(snapshot.spawned_from, None);
4298 assert_eq!(snapshot.spawned_file_identity, None);
4299 }
4300
4301 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4302 async fn failed_enable_spawn_clears_preexisting_current_process_facts() {
4303 let supervisor = Supervisor::default();
4304 let mut runtime = supervisor.runtime_config();
4305 runtime.test_seed_stale_facts_before_enable_spawn = true;
4306 let snapshot = stale_process_snapshot(ModuleState::Disabled, false);
4307 let mut child = None;
4308 let spec = ModuleSpec {
4309 module_id: "failed-enable-clears-facts".to_string(),
4310 program: PathBuf::from("/definitely/missing/failed-enable-module"),
4311 args: Vec::new(),
4312 env: Vec::new(),
4313 reserved: false,
4314 reserved_prefixes: Vec::new(),
4315 protocol: ModuleProtocol::Subc,
4316 overlap: Default::default(),
4317 };
4318
4319 let result = set_child_enabled(
4320 &spec,
4321 &runtime,
4322 &supervisor.registry,
4323 &supervisor.process_liveness,
4324 &snapshot,
4325 &mut child,
4326 true,
4327 )
4328 .await;
4329
4330 assert!(matches!(result, Err(SuperviseError::Spawn { .. })));
4331 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
4332 assert_snapshot_process_facts_cleared(&snapshot);
4333 }
4334
4335 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4336 async fn failed_reload_spawn_clears_current_process_facts() {
4337 let supervisor = Supervisor::default();
4338 let mut runtime = supervisor.runtime_config();
4339 runtime.restart_policy = RestartPolicy::new(0, Duration::ZERO);
4340 let snapshot = stale_process_snapshot(ModuleState::Running, true);
4341 let mut child = None;
4342 let spec = ModuleSpec {
4343 module_id: "failed-reload-clears-facts".to_string(),
4344 program: PathBuf::from("/unused/failed-reload-module"),
4345 args: Vec::new(),
4346 env: Vec::new(),
4347 reserved: false,
4348 reserved_prefixes: Vec::new(),
4349 protocol: ModuleProtocol::Subc,
4350 overlap: Default::default(),
4351 };
4352
4353 let result = handle_reload_spawn_failure(
4354 &spec,
4355 &runtime,
4356 &supervisor.process_liveness,
4357 &snapshot,
4358 &mut child,
4359 "forced reload spawn failure".to_string(),
4360 )
4361 .await;
4362
4363 assert!(matches!(result, Err(SuperviseError::ReloadFailed { .. })));
4364 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
4365 assert_snapshot_process_facts_cleared(&snapshot);
4366 }
4367
4368 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4369 async fn dropping_a_module_with_an_active_monitor_clears_current_process_facts() {
4370 let supervisor = Supervisor::default();
4371 let snapshot = stale_process_snapshot(ModuleState::Running, true);
4372 let module = supervisor.supervised_module(
4373 ModuleSpec {
4374 module_id: "drop-clears-facts".to_string(),
4375 program: PathBuf::from("/unused/drop-module"),
4376 args: Vec::new(),
4377 env: Vec::new(),
4378 reserved: false,
4379 reserved_prefixes: Vec::new(),
4380 protocol: ModuleProtocol::Subc,
4381 overlap: Default::default(),
4382 },
4383 supervisor.runtime_config(),
4384 Arc::clone(&snapshot),
4385 None,
4386 );
4387 assert!(!module
4388 .inner
4389 .monitor
4390 .lock()
4391 .unwrap()
4392 .as_ref()
4393 .unwrap()
4394 .is_finished());
4395
4396 drop(module);
4397
4398 assert_eq!(
4399 lock_snapshot(&snapshot).unwrap().state,
4400 ModuleState::Stopped
4401 );
4402 assert_snapshot_process_facts_cleared(&snapshot);
4403 }
4404
4405 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4406 async fn configuration_update_does_not_replace_captured_running_process_facts() {
4407 let supervisor = Supervisor::default();
4408 let snapshot = stale_process_snapshot(ModuleState::Running, true);
4409 let initial = ModuleSpec {
4410 module_id: "rescan-preserves-spawn-facts".to_string(),
4411 program: PathBuf::from("/spawned/module"),
4412 args: Vec::new(),
4413 env: Vec::new(),
4414 reserved: false,
4415 reserved_prefixes: Vec::new(),
4416 protocol: ModuleProtocol::Subc,
4417 overlap: Default::default(),
4418 };
4419 let module = supervisor.supervised_module(
4420 initial.clone(),
4421 supervisor.runtime_config(),
4422 snapshot,
4423 None,
4424 );
4425 let before = module.status().unwrap();
4426 let mut replacement = initial;
4427 replacement.program = PathBuf::from("/rescanned/replacement-module");
4428
4429 module
4430 .update_configuration(replacement, HealthConfig::default(), None)
4431 .await
4432 .unwrap();
4433
4434 let after = module.status().unwrap();
4435 assert_eq!(after.pid, before.pid);
4436 assert_eq!(after.spawned_at_ms, before.spawned_at_ms);
4437 assert_eq!(after.spawned_from, before.spawned_from);
4438 drop(module);
4439 }
4440}
4441
4442fn unix_ms_now() -> u64 {
4443 SystemTime::now()
4444 .duration_since(UNIX_EPOCH)
4445 .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64)
4446 .unwrap_or(0)
4447}
4448
4449async fn supervise_loop(
4450 mut spec: ModuleSpec,
4451 mut runtime: SupervisorRuntimeConfig,
4452 registry: Arc<Registry>,
4453 process_liveness: Arc<SupervisorProcessLiveness>,
4454 snapshot: SharedSnapshot,
4455 mut child: Option<SupervisedChild>,
4456 mut commands: mpsc::Receiver<SupervisorCommand>,
4457) {
4458 let mut health_probe = HealthProbeRuntime::default();
4459 let mut pending_respawn: Option<Instant> = None;
4463 let mut requeued: VecDeque<SupervisorCommand> = VecDeque::new();
4466 loop {
4467 if let Some(command) = requeued.pop_front() {
4468 if !handle_supervisor_command(
4469 command,
4470 &mut spec,
4471 &mut runtime,
4472 ®istry,
4473 &process_liveness,
4474 &snapshot,
4475 &mut child,
4476 &mut commands,
4477 &mut requeued,
4478 )
4479 .await
4480 {
4481 return;
4482 }
4483 if child.is_some() || !respawn_still_pending(&snapshot) {
4484 pending_respawn = None;
4485 }
4486 continue;
4487 }
4488 if child.is_some() {
4489 health_probe.refresh_registration(&spec, &runtime, ®istry, &snapshot);
4490 let probe_sleep = sleep(health_probe.wake_after());
4491 tokio::pin!(probe_sleep);
4492 let active_child = child.as_mut().expect("child checked above");
4493 tokio::select! {
4494 wait_result = active_child.wait() => {
4495 let exit_report = match wait_result {
4504 Ok(status) => classify_reaped_child_exit(&snapshot, active_child, &status),
4505 Err(err) => {
4506 active_child.drain_stderr(&spec.module_id).await;
4507 fail_snapshot(&snapshot, Some(&spec.module_id), None);
4508 record_wait_error_terminal(
4514 &spec.module_id,
4515 &runtime.terminal_ring,
4516 &runtime.spawn_events,
4517 );
4518 untrack_if_registration_released(
4519 &process_liveness,
4520 ®istry,
4521 &spec.module_id,
4522 &snapshot,
4523 );
4524 error!(module_id = %spec.module_id, error = %err, "failed to wait for supervised module");
4525 child = None;
4526 continue;
4527 }
4528 };
4529 active_child.drain_stderr(&spec.module_id).await;
4530
4531 let next = on_child_exit(
4532 &spec,
4533 runtime.restart_policy,
4534 ®istry,
4535 &snapshot,
4536 &runtime.terminal_ring,
4537 &runtime.spawn_events,
4538 &runtime.child_roster,
4539 exit_report,
4540 ).await;
4541 active_child.release_roster();
4544 match next {
4545 NextAction::Stop { registration_released } => {
4546 if registration_released {
4547 process_liveness.untrack_if_current(&spec.module_id, &snapshot);
4548 }
4549 child = None;
4550 }
4551 NextAction::Restart { schedule } => {
4552 let delay = schedule.map_or(
4553 runtime.restart_policy.delay_for_restart(0),
4554 |schedule| schedule.delay,
4555 );
4556 if let Some(schedule) = schedule {
4557 log_crash_respawn(&spec.module_id, schedule);
4558 }
4559 child = None;
4567 pending_respawn = Some(Instant::now() + delay);
4568 }
4569 }
4570 }
4571 command = commands.recv() => {
4572 let Some(command) = command else {
4573 return;
4574 };
4575 if !handle_supervisor_command(
4576 command,
4577 &mut spec,
4578 &mut runtime,
4579 ®istry,
4580 &process_liveness,
4581 &snapshot,
4582 &mut child,
4583 &mut commands,
4584 &mut requeued,
4585 ).await {
4586 return;
4587 }
4588 }
4589 _ = &mut probe_sleep => {
4590 if health_probe.due() {
4591 run_health_probe_cycle(
4592 &spec,
4593 &runtime,
4594 ®istry,
4595 &process_liveness,
4596 &snapshot,
4597 &mut child,
4598 ).await;
4599 if child.is_some() {
4600 health_probe.schedule_next(&spec, runtime.health.cadence);
4601 }
4602 }
4603 }
4604 }
4605 } else if let Some(deadline) = pending_respawn {
4606 tokio::select! {
4607 _ = sleep_until(deadline) => {
4608 pending_respawn = None;
4609 if !respawn_still_pending(&snapshot) {
4613 continue;
4614 }
4615 if runtime.child_roster.is_closed() {
4620 let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| {
4621 state.state = ModuleState::Stopped;
4622 });
4623 debug!(module_id = %spec.module_id, "crash respawn cancelled by daemon shutdown");
4624 continue;
4625 }
4626 if let Err(err) = wait_for_registration_release(
4627 ®istry,
4628 &spec.module_id,
4629 REGISTRY_RELEASE_TIMEOUT,
4630 ).await {
4631 fail_snapshot(&snapshot, Some(&spec.module_id), None);
4632 error!(module_id = %spec.module_id, error = %err, "registration did not release before restart");
4633 continue;
4634 }
4635
4636 match spawn_and_mark_running(&spec, &runtime, &snapshot) {
4637 Ok(next_child) => {
4638 child = Some(next_child);
4639 debug!(module_id = %spec.module_id, "supervised module restarted after crash");
4640 }
4641 Err(err) => {
4642 fail_snapshot(&snapshot, Some(&spec.module_id), None);
4643 process_liveness.untrack_if_current(&spec.module_id, &snapshot);
4644 error!(module_id = %spec.module_id, error = %err, "failed to restart supervised module");
4645 }
4646 }
4647 }
4648 command = commands.recv() => {
4649 let Some(command) = command else {
4650 return;
4651 };
4652 if !handle_supervisor_command(
4653 command,
4654 &mut spec,
4655 &mut runtime,
4656 ®istry,
4657 &process_liveness,
4658 &snapshot,
4659 &mut child,
4660 &mut commands,
4661 &mut requeued,
4662 ).await {
4663 return;
4664 }
4665 if child.is_some() || !respawn_still_pending(&snapshot) {
4670 pending_respawn = None;
4671 }
4672 }
4673 }
4674 } else {
4675 let Some(command) = commands.recv().await else {
4676 return;
4677 };
4678 if !handle_supervisor_command(
4679 command,
4680 &mut spec,
4681 &mut runtime,
4682 ®istry,
4683 &process_liveness,
4684 &snapshot,
4685 &mut child,
4686 &mut commands,
4687 &mut requeued,
4688 )
4689 .await
4690 {
4691 return;
4692 }
4693 }
4694 }
4695}
4696
4697fn log_crash_respawn(module_id: &str, schedule: CrashRestartSchedule) {
4698 info!(
4699 module_id,
4700 restart_in_window = schedule.restart_in_window,
4701 delay_ms = schedule.delay.as_millis() as u64,
4702 "respawning after crash"
4703 );
4704}
4705
4706fn respawn_still_pending(snapshot: &SharedSnapshot) -> bool {
4712 matches!(
4713 lock_snapshot(snapshot),
4714 Ok(state) if state.enabled && state.state == ModuleState::Restarting
4715 )
4716}
4717
4718enum NextAction {
4719 Stop {
4720 registration_released: bool,
4721 },
4722 Restart {
4723 schedule: Option<CrashRestartSchedule>,
4724 },
4725}
4726
4727#[allow(clippy::too_many_arguments)]
4728async fn handle_supervisor_command(
4729 command: SupervisorCommand,
4730 spec: &mut ModuleSpec,
4731 runtime: &mut SupervisorRuntimeConfig,
4732 registry: &Registry,
4733 process_liveness: &SupervisorProcessLiveness,
4734 snapshot: &SharedSnapshot,
4735 child: &mut Option<SupervisedChild>,
4736 commands: &mut mpsc::Receiver<SupervisorCommand>,
4737 requeued: &mut VecDeque<SupervisorCommand>,
4738) -> bool {
4739 match command {
4740 SupervisorCommand::Drain { reply } => {
4741 let result = drain_optional_child(
4744 &spec.module_id,
4745 spec.protocol,
4746 StopNotice::NotSent,
4747 registry,
4748 snapshot,
4749 &runtime.terminal_ring,
4750 &runtime.spawn_events,
4751 child,
4752 runtime.drain_timeout,
4753 ModuleState::Stopped,
4754 None,
4755 )
4756 .await;
4757 let registration_released = result.is_ok();
4758 let _ = reply.send(result);
4759 if registration_released {
4760 process_liveness.untrack_if_current(&spec.module_id, snapshot);
4761 }
4762 false
4763 }
4764 SupervisorCommand::Retire { reply } => {
4765 let result = async {
4766 let stop_notice = begin_forwarding_drain_if_configured(
4767 spec,
4768 runtime,
4769 registry,
4770 snapshot,
4771 None,
4772 RouteCloseReason::Disable,
4773 )
4774 .await?;
4775 drain_optional_child(
4776 &spec.module_id,
4777 spec.protocol,
4778 stop_notice,
4779 registry,
4780 snapshot,
4781 &runtime.terminal_ring,
4782 &runtime.spawn_events,
4783 child,
4784 runtime.drain_timeout,
4785 ModuleState::Stopped,
4786 None,
4787 )
4788 .await
4789 }
4790 .await;
4791 let registration_released = result.is_ok();
4792 let _ = reply.send(result);
4793 if registration_released {
4794 process_liveness.untrack_if_current(&spec.module_id, snapshot);
4795 }
4796 false
4797 }
4798 SupervisorCommand::Restart {
4799 drain_timeout_ms,
4800 received_at_generation,
4801 queued_at,
4802 reply,
4803 } => {
4804 info!(
4808 module_id = %spec.module_id,
4809 queued_ms = u64::try_from(queued_at.elapsed().as_millis()).unwrap_or(u64::MAX),
4810 "restart command dequeued"
4811 );
4812 let validation = match lock_snapshot(snapshot) {
4824 Ok(state) if !state.enabled => Err(SuperviseError::Disabled {
4825 module_id: spec.module_id.clone(),
4826 }),
4827 Ok(_) => Ok(()),
4828 Err(err) => Err(err),
4829 };
4830 let initiated = validation.is_ok();
4831 let _ = reply.send(validation);
4832 let satisfied_by_generation = if initiated && child.is_some() {
4843 lock_snapshot(snapshot).ok().and_then(|state| {
4844 (state.spawn_generation > received_at_generation
4845 && !state.configuration_updated_since_spawn)
4846 .then_some(state.spawn_generation)
4847 })
4848 } else {
4849 None
4850 };
4851 if let Some(generation) = satisfied_by_generation {
4852 info!(
4853 module_id = %spec.module_id,
4854 received_at_generation,
4855 "restart already satisfied by generation {generation}; not restarting again"
4856 );
4857 } else if initiated {
4858 let drain_timeout = drain_timeout_ms
4861 .map(Duration::from_millis)
4862 .unwrap_or(runtime.drain_timeout);
4863 if let Err(err) = restart_child(
4864 spec,
4865 runtime,
4866 registry,
4867 process_liveness,
4868 snapshot,
4869 child,
4870 drain_timeout,
4871 )
4872 .await
4873 {
4874 warn!(
4875 module_id = %spec.module_id,
4876 error = %err,
4877 "operator restart failed after initiation ack; module state carries the outcome"
4878 );
4879 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4880 state.state = ModuleState::Failed;
4881 clear_current_process_facts(state);
4882 });
4883 }
4884 }
4885 true
4886 }
4887 SupervisorCommand::Reload { reply } => {
4888 let result =
4889 reload_child(spec, runtime, registry, process_liveness, snapshot, child).await;
4890 let _ = reply.send(result);
4891 true
4892 }
4893 SupervisorCommand::SetEnabled { enabled, reply } => {
4894 let result = set_child_enabled(
4895 spec,
4896 runtime,
4897 registry,
4898 process_liveness,
4899 snapshot,
4900 child,
4901 enabled,
4902 )
4903 .await;
4904 let _ = reply.send(result);
4905 true
4906 }
4907 SupervisorCommand::UpdateConfiguration {
4908 spec: next_spec,
4909 health,
4910 drain_timeout_ms,
4911 reply,
4912 } => {
4913 if let Some(handle) = &runtime.supervisor_handle {
4914 handle.apply_identity_configuration(&next_spec);
4915 }
4916 *spec = next_spec;
4917 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4918 state.configuration_updated_since_spawn = true;
4919 });
4920 runtime.health = health;
4921 runtime.drain_timeout = drain_timeout_ms
4922 .map(Duration::from_millis)
4923 .unwrap_or(runtime.default_drain_timeout);
4924 *runtime
4925 .effective_drain_timeout
4926 .lock()
4927 .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.drain_timeout;
4928 let _ = reply.send(());
4929 true
4930 }
4931 SupervisorCommand::Swap {
4932 ready_timeout,
4933 reply,
4934 } => {
4935 let end = swap::run_swap(
4936 spec,
4937 runtime,
4938 registry,
4939 process_liveness,
4940 snapshot,
4941 child,
4942 commands,
4943 ready_timeout.unwrap_or(DEFAULT_SWAP_READY_TIMEOUT),
4944 reply,
4945 )
4946 .await;
4947 requeued.extend(end.requeue);
4948 true
4949 }
4950 }
4951}
4952
4953async fn restart_child(
4954 spec: &ModuleSpec,
4955 runtime: &SupervisorRuntimeConfig,
4956 registry: &Registry,
4957 process_liveness: &SupervisorProcessLiveness,
4958 snapshot: &SharedSnapshot,
4959 child: &mut Option<SupervisedChild>,
4960 drain_timeout: Duration,
4961) -> Result<(), SuperviseError> {
4962 if !lock_snapshot(snapshot)?.enabled {
4964 return Err(SuperviseError::Disabled {
4965 module_id: spec.module_id.clone(),
4966 });
4967 }
4968 let stop_notice = begin_forwarding_drain_with_timeout(
4969 spec,
4970 runtime,
4971 registry,
4972 snapshot,
4973 None,
4974 RouteCloseReason::Restart,
4975 drain_timeout,
4976 )
4977 .await?;
4978
4979 if child.is_some() {
4980 drain_optional_child(
4981 &spec.module_id,
4982 spec.protocol,
4983 stop_notice,
4984 registry,
4985 snapshot,
4986 &runtime.terminal_ring,
4987 &runtime.spawn_events,
4988 child,
4989 drain_timeout,
4990 ModuleState::Restarting,
4991 Some(true),
4992 )
4993 .await?;
4994 } else {
4995 update_snapshot(snapshot, Some(&spec.module_id), |state| {
4996 state.enabled = true;
4997 state.state = ModuleState::Restarting;
4998 clear_current_process_facts(state);
4999 })?;
5000 wait_for_registration_release(registry, &spec.module_id, REGISTRY_RELEASE_TIMEOUT).await?;
5001 }
5002
5003 reset_restart_count(snapshot, &spec.module_id)?;
5004 sleep(runtime.restart_policy.backoff).await;
5005 if !respawn_still_pending(snapshot) {
5008 process_liveness.untrack_if_current(&spec.module_id, snapshot);
5009 return Ok(());
5010 }
5011 process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
5012 match spawn_and_mark_running(spec, runtime, snapshot) {
5018 Ok(next_child) => {
5019 *child = Some(next_child);
5020 debug!(module_id = %spec.module_id, "supervised module restarted by operator request");
5021 Ok(())
5022 }
5023 Err(err) => {
5024 fail_snapshot(snapshot, Some(&spec.module_id), None);
5025 process_liveness.untrack_if_current(&spec.module_id, snapshot);
5026 *child = None;
5027 Err(err)
5028 }
5029 }
5030}
5031
5032async fn reload_child(
5033 spec: &ModuleSpec,
5034 runtime: &SupervisorRuntimeConfig,
5035 registry: &Registry,
5036 process_liveness: &SupervisorProcessLiveness,
5037 snapshot: &SharedSnapshot,
5038 child: &mut Option<SupervisedChild>,
5039) -> Result<(), SuperviseError> {
5040 if !lock_snapshot(snapshot)?.enabled {
5042 return Err(SuperviseError::Disabled {
5043 module_id: spec.module_id.clone(),
5044 });
5045 }
5046 let stop_notice = begin_forwarding_drain(
5047 spec,
5048 runtime,
5049 registry,
5050 snapshot,
5051 Some(true),
5052 RouteCloseReason::Reload,
5053 )
5054 .await?;
5055
5056 if child.is_some() {
5057 drain_optional_child(
5058 &spec.module_id,
5059 spec.protocol,
5060 stop_notice,
5061 registry,
5062 snapshot,
5063 &runtime.terminal_ring,
5064 &runtime.spawn_events,
5065 child,
5066 runtime.drain_timeout,
5067 ModuleState::Restarting,
5068 Some(true),
5069 )
5070 .await?;
5071 } else {
5072 update_snapshot(snapshot, Some(&spec.module_id), |state| {
5073 state.enabled = true;
5074 state.state = ModuleState::Restarting;
5075 clear_current_process_facts(state);
5076 })?;
5077 wait_for_registration_release(registry, &spec.module_id, REGISTRY_RELEASE_TIMEOUT).await?;
5078 }
5079
5080 reset_restart_count(snapshot, &spec.module_id)?;
5081 sleep(runtime.restart_policy.backoff).await;
5082 if !respawn_still_pending(snapshot) {
5085 process_liveness.untrack_if_current(&spec.module_id, snapshot);
5086 return Ok(());
5087 }
5088 process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
5089 let next_child = match spawn_and_mark_running(spec, runtime, snapshot) {
5090 Ok(next_child) => next_child,
5091 Err(err) => {
5092 return handle_reload_spawn_failure(
5093 spec,
5094 runtime,
5095 process_liveness,
5096 snapshot,
5097 child,
5098 format!("new child failed to spawn: {err}"),
5099 )
5100 .await;
5101 }
5102 };
5103 *child = Some(next_child);
5104
5105 let wait_outcome = {
5106 let active_child = child.as_mut().expect("new reload child was just stored");
5107 wait_for_registration_after_reload(
5108 registry,
5109 &spec.module_id,
5110 snapshot,
5111 active_child,
5112 REGISTRY_RELEASE_TIMEOUT,
5113 )
5114 .await?
5115 };
5116
5117 match wait_outcome {
5118 RegistrationWaitOutcome::Registered => {
5119 debug!(module_id = %spec.module_id, "supervised module reloaded and registered");
5120 Ok(())
5121 }
5122 RegistrationWaitOutcome::Exited(exit_report) => {
5123 if let Some(active_child) = child.as_mut() {
5124 active_child.drain_stderr(&spec.module_id).await;
5125 }
5126 *child = None;
5127 handle_reload_child_registration_failure(
5128 spec,
5129 runtime,
5130 registry,
5131 process_liveness,
5132 snapshot,
5133 child,
5134 ReloadRegistrationFailure {
5135 exit_report: registration_failure_exit_report(exit_report),
5136 reason: "new child exited before registering".to_string(),
5137 },
5138 )
5139 .await
5140 }
5141 RegistrationWaitOutcome::TimedOut => {
5142 let mut timed_out_child = child
5143 .take()
5144 .expect("timed-out reload child is still running");
5145 timed_out_child
5146 .start_kill()
5147 .map_err(|source| SuperviseError::Kill {
5148 module_id: spec.module_id.clone(),
5149 source,
5150 })?;
5151 let status = timed_out_child
5152 .wait()
5153 .await
5154 .map_err(|source| SuperviseError::Wait {
5155 module_id: spec.module_id.clone(),
5156 source,
5157 })?;
5158 timed_out_child.drain_stderr(&spec.module_id).await;
5159 handle_reload_child_registration_failure(
5160 spec,
5161 runtime,
5162 registry,
5163 process_liveness,
5164 snapshot,
5165 child,
5166 ReloadRegistrationFailure {
5167 exit_report: registration_failure_exit_report(classify_reaped_child_exit(
5168 snapshot,
5169 &timed_out_child,
5170 &status,
5171 )),
5172 reason: format!(
5173 "new child did not register within {:?}",
5174 REGISTRY_RELEASE_TIMEOUT
5175 ),
5176 },
5177 )
5178 .await
5179 }
5180 }
5181}
5182
5183async fn set_child_enabled(
5184 spec: &ModuleSpec,
5185 runtime: &SupervisorRuntimeConfig,
5186 registry: &Registry,
5187 process_liveness: &SupervisorProcessLiveness,
5188 snapshot: &SharedSnapshot,
5189 child: &mut Option<SupervisedChild>,
5190 enabled: bool,
5191) -> Result<bool, SuperviseError> {
5192 let (current_enabled, current_state) = {
5193 let state = lock_snapshot(snapshot)?;
5194 (state.enabled, state.state)
5195 };
5196 let revive_terminal = enabled
5204 && current_enabled
5205 && child.is_none()
5206 && matches!(current_state, ModuleState::Failed | ModuleState::Stopped);
5207 if current_enabled == enabled && !revive_terminal {
5208 return Ok(false);
5209 }
5210
5211 if enabled {
5212 update_snapshot(snapshot, Some(&spec.module_id), |state| {
5213 state.enabled = true;
5214 state.state = ModuleState::Starting;
5215 clear_current_process_facts(state);
5216 })?;
5217 #[cfg(test)]
5218 if runtime.test_seed_stale_facts_before_enable_spawn {
5219 update_snapshot(snapshot, Some(&spec.module_id), |state| {
5220 state.process_alive = true;
5221 state.pid = Some(41);
5222 state.spawned_at_ms = Some(42);
5223 state.spawned_from = Some(PathBuf::from("/spawned/module"));
5224 state.spawned_file_identity = Some(SpawnedFileIdentity {
5225 device: 43,
5226 inode: 44,
5227 });
5228 })?;
5229 }
5230 wait_for_registration_release(registry, &spec.module_id, REGISTRY_RELEASE_TIMEOUT).await?;
5231 reset_restart_count(snapshot, &spec.module_id)?;
5232 process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
5233 let next_child = match spawn_and_mark_running(spec, runtime, snapshot) {
5234 Ok(next_child) => next_child,
5235 Err(err) => {
5236 if let Err(state_err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
5237 state.state = ModuleState::Failed;
5238 clear_current_process_facts(state);
5239 }) {
5240 error!(module_id = %spec.module_id, error = %state_err, "failed to record enable spawn failure");
5241 }
5242 process_liveness.untrack_if_current(&spec.module_id, snapshot);
5243 return Err(err);
5244 }
5245 };
5246 *child = Some(next_child);
5247 debug!(module_id = %spec.module_id, "supervised module enabled");
5248 Ok(true)
5249 } else {
5250 let stop_notice = begin_forwarding_drain_if_configured(
5251 spec,
5252 runtime,
5253 registry,
5254 snapshot,
5255 Some(false),
5256 RouteCloseReason::Disable,
5257 )
5258 .await?;
5259 drain_optional_child(
5260 &spec.module_id,
5261 spec.protocol,
5262 stop_notice,
5263 registry,
5264 snapshot,
5265 &runtime.terminal_ring,
5266 &runtime.spawn_events,
5267 child,
5268 runtime.drain_timeout,
5269 ModuleState::Disabled,
5270 Some(false),
5271 )
5272 .await?;
5273 debug!(module_id = %spec.module_id, "supervised module disabled");
5274 Ok(true)
5275 }
5276}
5277
5278#[allow(clippy::too_many_arguments)]
5279async fn on_child_exit(
5280 spec: &ModuleSpec,
5281 policy: RestartPolicy,
5282 registry: &Registry,
5283 snapshot: &SharedSnapshot,
5284 terminal_ring: &Arc<Mutex<TerminalRing>>,
5285 spawn_events: &SpawnEventFeed,
5286 roster: &ChildRoster,
5287 exit_report: ExitReport,
5288) -> NextAction {
5289 if roster.is_closed() {
5295 return on_child_exit_during_daemon_shutdown(
5296 spec,
5297 registry,
5298 snapshot,
5299 terminal_ring,
5300 spawn_events,
5301 exit_report,
5302 )
5303 .await;
5304 }
5305 let unrequested_clean_exit_of_protocol_none =
5320 exit_report.kind == ExitKind::Clean && spec.protocol == ModuleProtocol::None;
5321 match exit_report.kind {
5322 ExitKind::Clean if !unrequested_clean_exit_of_protocol_none => {
5323 info!(
5324 module_id = %spec.module_id,
5325 exit_code = ?exit_report.code,
5326 exit_signal = ?exit_report.signal,
5327 "supervised module exited cleanly"
5328 );
5329 if let Err(err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
5330 state.state = ModuleState::Stopped;
5331 clear_current_process_facts(state);
5332 state.last_exit = Some(exit_report.clone());
5333 }) {
5334 error!(module_id = %spec.module_id, error = %err, "failed to record clean module exit");
5335 }
5336 record_terminal(
5337 &spec.module_id,
5338 terminal_ring,
5339 spawn_events,
5340 &exit_report,
5341 TerminalDisposition::Stopped,
5342 );
5343 let registration_released = match wait_for_registration_release(
5344 registry,
5345 &spec.module_id,
5346 REGISTRY_RELEASE_TIMEOUT,
5347 )
5348 .await
5349 {
5350 Ok(()) => true,
5351 Err(err) => {
5352 warn!(module_id = %spec.module_id, error = %err, "registration still active after clean exit");
5353 false
5354 }
5355 };
5356 NextAction::Stop {
5357 registration_released,
5358 }
5359 }
5360 ExitKind::Clean | ExitKind::Crash => {
5361 if unrequested_clean_exit_of_protocol_none {
5362 warn!(
5363 module_id = %spec.module_id,
5364 exit_code = ?exit_report.code,
5365 exit_signal = ?exit_report.signal,
5366 "protocol-none module exited cleanly without a stop request; handling it as a crash"
5367 );
5368 } else {
5369 warn!(
5370 module_id = %spec.module_id,
5371 exit_code = ?exit_report.code,
5372 exit_signal = ?exit_report.signal,
5373 "supervised module exited abnormally (crash)"
5374 );
5375 }
5376 let mut restart_schedule = None;
5377 let mut disposition = TerminalDisposition::Disabled;
5378 let mut disposition_detail = None;
5382 let now = Instant::now();
5383 if let Err(err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
5384 clear_current_process_facts(state);
5385 state.last_exit = Some(exit_report.clone());
5386 if state.enabled {
5387 if let Some(schedule) = state.next_crash_restart(&policy, now) {
5388 state.state = ModuleState::Restarting;
5389 restart_schedule = Some(schedule);
5390 disposition = TerminalDisposition::Restarting;
5391 } else {
5392 state.state = ModuleState::Failed;
5393 disposition = TerminalDisposition::Failed;
5394 disposition_detail = Some(policy.budget_exhausted_detail());
5395 }
5396 } else {
5397 state.state = ModuleState::Disabled;
5398 disposition = TerminalDisposition::Disabled;
5399 }
5400 }) {
5401 error!(module_id = %spec.module_id, error = %err, "failed to record crashed module exit");
5402 return NextAction::Stop {
5403 registration_released: false,
5404 };
5405 }
5406 if disposition_detail.is_some() {
5407 error!(
5412 module_id = %spec.module_id,
5413 max_restarts = policy.max_restarts,
5414 window_secs = policy.window.as_secs(),
5415 "module stopped: {}",
5416 policy.budget_exhausted_detail()
5417 );
5418 }
5419 record_terminal_with_detail(
5420 &spec.module_id,
5421 terminal_ring,
5422 spawn_events,
5423 &exit_report,
5424 disposition,
5425 disposition_detail,
5426 );
5427
5428 if let Some(schedule) = restart_schedule {
5429 NextAction::Restart {
5430 schedule: Some(schedule),
5431 }
5432 } else {
5433 let registration_released = match wait_for_registration_release(
5434 registry,
5435 &spec.module_id,
5436 REGISTRY_RELEASE_TIMEOUT,
5437 )
5438 .await
5439 {
5440 Ok(()) => true,
5441 Err(err) => {
5442 warn!(module_id = %spec.module_id, error = %err, "registration still active after failed module");
5443 false
5444 }
5445 };
5446 NextAction::Stop {
5447 registration_released,
5448 }
5449 }
5450 }
5451 ExitKind::DeliberateSeverance => {
5452 warn!(
5453 module_id = %spec.module_id,
5454 exit_code = ?exit_report.code,
5455 exit_signal = ?exit_report.signal,
5456 "supervised module exited after deliberate connection severance"
5457 );
5458 let mut should_restart = false;
5459 let mut disposition = TerminalDisposition::Disabled;
5460 if let Err(err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
5461 clear_current_process_facts(state);
5462 state.last_exit = Some(exit_report.clone());
5463 state.lifetime_restarts += 1;
5464 if state.enabled {
5465 state.state = ModuleState::Restarting;
5466 should_restart = true;
5467 disposition = TerminalDisposition::Restarting;
5468 } else {
5469 state.state = ModuleState::Disabled;
5470 }
5471 }) {
5472 error!(module_id = %spec.module_id, error = %err, "failed to record deliberately severed module exit");
5473 return NextAction::Stop {
5474 registration_released: false,
5475 };
5476 }
5477 record_terminal(
5478 &spec.module_id,
5479 terminal_ring,
5480 spawn_events,
5481 &exit_report,
5482 disposition,
5483 );
5484
5485 if should_restart {
5486 NextAction::Restart { schedule: None }
5487 } else {
5488 let registration_released = match wait_for_registration_release(
5489 registry,
5490 &spec.module_id,
5491 REGISTRY_RELEASE_TIMEOUT,
5492 )
5493 .await
5494 {
5495 Ok(()) => true,
5496 Err(err) => {
5497 warn!(module_id = %spec.module_id, error = %err, "registration still active after deliberately severed module exit");
5498 false
5499 }
5500 };
5501 NextAction::Stop {
5502 registration_released,
5503 }
5504 }
5505 }
5506 }
5507}
5508
5509async fn on_child_exit_during_daemon_shutdown(
5510 spec: &ModuleSpec,
5511 registry: &Registry,
5512 snapshot: &SharedSnapshot,
5513 terminal_ring: &Arc<Mutex<TerminalRing>>,
5514 spawn_events: &SpawnEventFeed,
5515 exit_report: ExitReport,
5516) -> NextAction {
5517 info!(
5518 module_id = %spec.module_id,
5519 exit_code = ?exit_report.code,
5520 exit_signal = ?exit_report.signal,
5521 exit_kind = ?exit_report.kind,
5522 "supervised module exited during daemon shutdown; not restarting it"
5523 );
5524 if let Err(err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
5525 state.state = ModuleState::Stopped;
5526 clear_current_process_facts(state);
5527 state.last_exit = Some(exit_report.clone());
5528 }) {
5529 error!(module_id = %spec.module_id, error = %err, "failed to record module exit during daemon shutdown");
5530 }
5531 record_terminal(
5532 &spec.module_id,
5533 terminal_ring,
5534 spawn_events,
5535 &exit_report,
5536 TerminalDisposition::DaemonShutdown,
5537 );
5538 let registration_released =
5539 wait_for_registration_release(registry, &spec.module_id, REGISTRY_RELEASE_TIMEOUT)
5540 .await
5541 .is_ok();
5542 NextAction::Stop {
5543 registration_released,
5544 }
5545}
5546
5547fn record_wait_error_terminal(
5548 module_id: &str,
5549 terminal_ring: &Arc<Mutex<TerminalRing>>,
5550 spawn_events: &SpawnEventFeed,
5551) {
5552 record_terminal(
5553 module_id,
5554 terminal_ring,
5555 spawn_events,
5556 &wait_error_exit_report(),
5557 TerminalDisposition::Failed,
5558 );
5559}
5560
5561fn record_terminal(
5562 module_id: &str,
5563 terminal_ring: &Arc<Mutex<TerminalRing>>,
5564 spawn_events: &SpawnEventFeed,
5565 exit_report: &ExitReport,
5566 disposition: TerminalDisposition,
5567) {
5568 record_terminal_with_detail(
5569 module_id,
5570 terminal_ring,
5571 spawn_events,
5572 exit_report,
5573 disposition,
5574 None,
5575 );
5576}
5577
5578fn durable_terminal_history_of(
5582 terminal_ring: &Mutex<TerminalRing>,
5583 module_id: &str,
5584) -> subc_control::TerminalHistory {
5585 let read = terminal_ring
5586 .lock()
5587 .unwrap_or_else(|p| p.into_inner())
5588 .capture_durable_history();
5589 read.read(module_id)
5590}
5591
5592fn record_terminal_with_detail(
5593 module_id: &str,
5594 terminal_ring: &Arc<Mutex<TerminalRing>>,
5595 spawn_events: &SpawnEventFeed,
5596 exit_report: &ExitReport,
5597 disposition: TerminalDisposition,
5598 disposition_detail: Option<String>,
5599) {
5600 spawn_events.emit_exited(module_id, exit_report.code, exit_report.signal);
5601 let record = TerminalRecord {
5602 exit_code: exit_report.code,
5603 exit_signal: exit_report.signal,
5604 at_ms: exit_report.at_ms,
5605 disposition,
5606 exit_kind: exit_report.kind.into(),
5607 disposition_detail,
5608 };
5609 terminal_ring
5610 .lock()
5611 .unwrap_or_else(|poisoned| poisoned.into_inner())
5612 .record_exit(module_id, record);
5613}
5614
5615fn untrack_if_registration_released(
5616 process_liveness: &SupervisorProcessLiveness,
5617 registry: &Registry,
5618 module_id: &str,
5619 snapshot: &SharedSnapshot,
5620) {
5621 match registry.get_module(module_id) {
5622 Ok(None) => process_liveness.untrack_if_current(module_id, snapshot),
5623 Ok(Some(_)) => {}
5624 Err(err) => {
5625 warn!(module_id, error = %err, "could not determine whether supervisor liveness can be untracked");
5626 }
5627 }
5628}
5629
5630#[cfg(test)]
5644fn apply_wire_spawn_args(
5645 command: &mut Command,
5646 spec: &ModuleSpec,
5647 connection_file_path: Option<&std::path::Path>,
5648 handle: Option<&SupervisorHandle>,
5649) -> Result<Option<NonceHandoff>, SuperviseError> {
5650 apply_wire_spawn_args_for_role(
5651 command,
5652 spec,
5653 connection_file_path,
5654 handle,
5655 SpawnRole::Plain,
5656 )
5657}
5658
5659#[cfg(unix)]
5664type NonceHandoff = subc_os::LaunchNonceHandoff;
5665#[cfg(not(unix))]
5666type NonceHandoff = std::convert::Infallible;
5667
5668fn apply_wire_spawn_args_for_role(
5685 command: &mut Command,
5686 spec: &ModuleSpec,
5687 connection_file_path: Option<&std::path::Path>,
5688 handle: Option<&SupervisorHandle>,
5689 role: SpawnRole,
5690) -> Result<Option<NonceHandoff>, SuperviseError> {
5691 command.env(SUBC_MODULE_ID_ENV, &spec.module_id);
5692 command.env_remove(subc_os::LAUNCH_NONCE_FD_ENV);
5697 command.env_remove(SUBC_LAUNCH_NONCE_ENV);
5699 if spec.protocol == ModuleProtocol::None {
5700 return Ok(None);
5701 }
5702 if let Some(connection_file_path) = connection_file_path {
5703 command.arg(SUBC_ARG).arg(connection_file_path);
5704 }
5705
5706 let nonce = generate_launch_nonce()?;
5710 if let Some(handle) = handle {
5711 match role {
5712 SpawnRole::Plain => {
5713 handle.set_spawn_nonce(&spec.module_id, nonce.clone());
5714 if spec.reserved {
5715 handle.set_reserved_nonce(&spec.module_id, nonce.clone());
5716 }
5717 }
5718 SpawnRole::SwapCandidate => handle.open_swap(&spec.module_id, nonce.clone()),
5719 }
5720 }
5721 #[cfg(unix)]
5722 let handoff = {
5723 let handoff =
5724 subc_os::LaunchNonceHandoff::new(&nonce).map_err(|source| SuperviseError::Spawn {
5725 program: spec.program.clone(),
5726 source,
5727 cgroup_path: None,
5728 })?;
5729 command.env(subc_os::LAUNCH_NONCE_FD_ENV, handoff.fd_env_value());
5730 Some(handoff)
5731 };
5732 #[cfg(not(unix))]
5733 let handoff = None;
5734 #[cfg(not(unix))]
5737 command.env(SUBC_LAUNCH_NONCE_ENV, nonce);
5738 Ok(handoff)
5739}
5740
5741fn apply_child_env(command: &mut Command, spec: &ModuleSpec) {
5742 command.env_remove(CK_LOG_ENV);
5743 command.env_remove(SUBC_SPAWN_ROLE_ENV);
5750 for (key, value) in &spec.env {
5751 if matches!(
5755 key.as_str(),
5756 CAPTURE_MAX_FILE_MB_ENV | CAPTURE_KEEP_ENV | CAPTURE_MAX_AGE_DAYS_ENV
5757 ) || key == SUBC_SPAWN_ROLE_ENV
5758 {
5759 continue;
5760 }
5761 command.env(key, value);
5762 }
5763}
5764
5765#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5768enum SpawnRole {
5769 Plain,
5770 SwapCandidate,
5771}
5772
5773fn apply_spawn_role(command: &mut Command, role: SpawnRole) {
5776 if role == SpawnRole::SwapCandidate {
5777 command.env(SUBC_SPAWN_ROLE_ENV, SPAWN_ROLE_SWAP_CANDIDATE);
5778 }
5779}
5780
5781fn spawn_child(
5782 spec: &ModuleSpec,
5783 connection_file_path: Option<&std::path::Path>,
5784 handle: Option<&SupervisorHandle>,
5785 ring: &Arc<Mutex<StderrRing>>,
5786 capture_logs_dir: Option<&std::path::Path>,
5787 roster: &ChildRoster,
5788 #[cfg(target_os = "linux")] cgroup_placement: Option<&subc_cgroup::Placement>,
5789) -> Result<SupervisedChild, SuperviseError> {
5790 spawn_child_in_slot(
5791 spec,
5792 connection_file_path,
5793 handle,
5794 ring,
5795 capture_logs_dir,
5796 roster,
5797 #[cfg(target_os = "linux")]
5798 cgroup_placement,
5799 SpawnRole::Plain,
5800 false,
5801 )
5802}
5803
5804#[allow(clippy::too_many_arguments)]
5817fn spawn_child_in_slot(
5818 spec: &ModuleSpec,
5819 connection_file_path: Option<&std::path::Path>,
5820 handle: Option<&SupervisorHandle>,
5821 ring: &Arc<Mutex<StderrRing>>,
5822 capture_logs_dir: Option<&std::path::Path>,
5823 roster: &ChildRoster,
5824 #[cfg(target_os = "linux")] cgroup_placement: Option<&subc_cgroup::Placement>,
5825 role: SpawnRole,
5826 alternate_slot: bool,
5827) -> Result<SupervisedChild, SuperviseError> {
5828 if roster.is_closed() {
5829 return Err(SuperviseError::Spawn {
5830 program: spec.program.clone(),
5831 source: io::Error::other("the daemon is shutting down; not starting a new process"),
5832 cgroup_path: None,
5833 });
5834 }
5835 #[cfg(target_os = "linux")]
5836 let cgroup_name = swap::cgroup_name(&spec.module_id, alternate_slot);
5837 #[cfg(not(target_os = "linux"))]
5838 let _ = alternate_slot;
5839 let mut command = Command::new(&spec.program);
5840 command.args(&spec.args);
5841 apply_child_env(&mut command, spec);
5871 apply_spawn_role(&mut command, role);
5872 let nonce_handoff =
5873 apply_wire_spawn_args_for_role(&mut command, spec, connection_file_path, handle, role)?;
5874
5875 #[cfg(target_os = "linux")]
5876 let cgroup_path = cgroup_placement
5877 .map(|placement| placement.module_path(&cgroup_name))
5878 .transpose()
5879 .map_err(|source| SuperviseError::Cgroup {
5880 module_id: spec.module_id.clone(),
5881 source,
5882 })?;
5883 #[cfg(not(target_os = "linux"))]
5884 let cgroup_path: Option<PathBuf> = None;
5885 #[cfg(target_os = "linux")]
5886 if let Some(path) = &cgroup_path {
5887 if let Err(error) = apply_cgroup_placement(&mut command, spec, path) {
5888 if let Some(placement) = cgroup_placement {
5889 remove_module_cgroup(placement, &cgroup_name);
5890 }
5891 return Err(error);
5892 }
5893 }
5894
5895 let output_sink = if let Some(logs_dir) = capture_logs_dir {
5896 let path = logs_dir.join(format!("{}.stderr.log", spec.module_id));
5897 match ChildOutputSink::open(&path, capture_retention(spec)) {
5898 Ok(sink) => sink,
5899 Err(error) => {
5900 warn!(
5901 module_id = %spec.module_id,
5902 path = %path.display(),
5903 error = %error,
5904 "could not open child output capture file; forwarding to stderr"
5905 );
5906 ChildOutputSink::Stderr
5907 }
5908 }
5909 } else {
5910 ChildOutputSink::Stderr
5911 };
5912
5913 command.stdout(Stdio::piped());
5914 command.stderr(Stdio::piped());
5915 command.kill_on_drop(true);
5916 #[cfg(unix)]
5933 command.process_group(0);
5934 command.stdin(Stdio::null());
5935 #[cfg(unix)]
5939 if let Some(handoff) = nonce_handoff {
5940 handoff.install_last(command.as_std_mut());
5941 }
5942 #[cfg(not(unix))]
5943 let _ = nonce_handoff;
5944
5945 #[cfg(windows)]
5950 subc_jobobject::suspend_on_create_async(&mut command);
5951 let mut child = match command.spawn() {
5952 Ok(child) => child,
5953 Err(source) => {
5954 #[cfg(target_os = "linux")]
5955 if let Some(placement) = cgroup_placement {
5956 remove_module_cgroup(placement, &cgroup_name);
5957 }
5958 return Err(SuperviseError::Spawn {
5959 program: spec.program.clone(),
5960 source,
5961 cgroup_path,
5962 });
5963 }
5964 };
5965
5966 #[cfg(windows)]
5968 let job = contain_spawned_child(&child, spec)?;
5969 let spawned_at_ms = unix_ms_now();
5970 let spawned_from = spec.program.clone();
5971 let spawned_file_identity = spawned_file_identity(&spawned_from);
5972 let pid = child.id().ok_or_else(|| SuperviseError::Spawn {
5973 program: spec.program.clone(),
5974 source: io::Error::other("spawned child exposed no live pid"),
5975 cgroup_path: cgroup_path.clone(),
5976 })?;
5977 let process_start_time = crate::provenance::process_start_time(pid);
5978 let process_identity = process_start_time.map(|start_time| ProcessIdentity { pid, start_time });
5979 #[cfg(target_os = "linux")]
5983 let recorded_cgroup_name = cgroup_path.as_ref().map(|_| cgroup_name.clone());
5984 #[cfg(not(target_os = "linux"))]
5985 let recorded_cgroup_name = None;
5986 let roster_guard = roster.admit(
5987 spec.module_id.clone(),
5988 pid,
5989 spec.protocol,
5990 process_start_time,
5991 crate::child_roster::RecordedIdentity {
5992 start_time: subc_os::start_time(pid),
5993 executable: spawned_file_identity.map(|identity| {
5994 crate::live_children::ExecutableIdentity {
5995 device: identity.device,
5996 inode: identity.inode,
5997 }
5998 }),
5999 cgroup_name: recorded_cgroup_name,
6000 },
6001 );
6002 if roster.is_closed() {
6011 if let Err(error) = child.start_kill() {
6012 debug!(module_id = %spec.module_id, pid, %error, "kill of a process spawned during daemon shutdown failed; it may already have exited");
6013 }
6014 drop(roster_guard);
6015 return Err(SuperviseError::Spawn {
6016 program: spec.program.clone(),
6017 source: io::Error::other(
6018 "the daemon began shutting down while this process was starting; ended it",
6019 ),
6020 cgroup_path,
6021 });
6022 }
6023
6024 let stdout_pump = match child.stdout.take() {
6025 Some(stdout) => Some(tokio::spawn(pump_stdout_to(stdout, output_sink.clone()))),
6026 None => {
6027 warn!(
6028 module_id = %spec.module_id,
6029 "spawned child exposed no stdout pipe; file capture will be incomplete"
6030 );
6031 None
6032 }
6033 };
6034 let stderr_pump = match child.stderr.take() {
6035 Some(stderr) => {
6036 let generation = ring
6037 .lock()
6038 .unwrap_or_else(|poisoned| poisoned.into_inner())
6039 .begin_process();
6040 Some(StderrPump {
6041 task: tokio::spawn(pump_stderr_to(
6042 stderr,
6043 Arc::clone(ring),
6044 generation,
6045 output_sink,
6046 )),
6047 generation,
6048 })
6049 }
6050 None => {
6051 ring.lock()
6055 .unwrap_or_else(|poisoned| poisoned.into_inner())
6056 .mark_not_captured("stderr pipe was not available on spawn");
6057 warn!(
6058 module_id = %spec.module_id,
6059 "spawned child exposed no stderr pipe; tail will be unavailable"
6060 );
6061 None
6062 }
6063 };
6064
6065 Ok(SupervisedChild {
6066 child,
6067 #[cfg(target_os = "linux")]
6068 module_id: cgroup_name,
6069 #[cfg(target_os = "linux")]
6070 cgroup_placement: cgroup_placement.cloned(),
6071 #[cfg(windows)]
6072 job,
6073 stdout_pump,
6074 stderr_pump,
6075 stderr_ring: Arc::clone(ring),
6076 spawned_at_ms,
6077 spawned_from,
6078 spawned_file_identity,
6079 process_start_time,
6080 process_identity,
6081 pid,
6082 roster_guard: Some(roster_guard),
6083 })
6084}
6085
6086#[cfg(windows)]
6100fn contain_spawned_child(
6101 child: &Child,
6102 spec: &ModuleSpec,
6103) -> Result<Option<subc_jobobject::JobObject>, SuperviseError> {
6104 let module_id = spec.module_id.as_str();
6105 let Some(pid) = child.id() else {
6106 warn!(
6109 module_id,
6110 "spawned child had already exited before containment; no job object attached"
6111 );
6112 return Ok(None);
6113 };
6114
6115 let job = match subc_jobobject::JobObject::new() {
6116 Ok(job) => job,
6117 Err(source) => {
6118 warn!(
6119 module_id,
6120 error = %source,
6121 "could not create a job object; this module's helper processes will not be \
6122 reaped on teardown"
6123 );
6124 resume_suspended_child(pid, spec)?;
6127 return Ok(None);
6128 }
6129 };
6130
6131 if let Err(source) = job.assign(child) {
6132 warn!(
6133 module_id,
6134 error = %source,
6135 "could not assign the child to its job object; this module's helper processes \
6136 will not be reaped on teardown"
6137 );
6138 resume_suspended_child(pid, spec)?;
6139 return Ok(None);
6140 }
6141
6142 resume_suspended_child(pid, spec)?;
6143 Ok(Some(job))
6144}
6145
6146#[cfg(windows)]
6151fn resume_suspended_child(pid: u32, spec: &ModuleSpec) -> Result<(), SuperviseError> {
6152 if let Err(source) = subc_jobobject::resume_main_thread(pid) {
6153 let _ = std::process::Command::new("taskkill.exe")
6157 .args(["/PID", &pid.to_string(), "/T", "/F"])
6158 .stdin(Stdio::null())
6159 .stdout(Stdio::null())
6160 .stderr(Stdio::null())
6161 .status();
6162 return Err(SuperviseError::Spawn {
6163 program: spec.program.clone(),
6164 source,
6165 cgroup_path: None,
6166 });
6167 }
6168 Ok(())
6169}
6170
6171#[cfg(target_os = "linux")]
6172fn remove_module_cgroup(placement: &subc_cgroup::Placement, module_id: &str) {
6173 match placement.remove_module(module_id) {
6174 Ok(()) => debug!(module_id, "removed module cgroup after process exit"),
6175 Err(error) => warn!(
6176 module_id,
6177 error = %error,
6178 "could not remove module cgroup after process exit; continuing teardown"
6179 ),
6180 }
6181}
6182
6183#[cfg(target_os = "linux")]
6184fn apply_cgroup_placement(
6185 command: &mut Command,
6186 spec: &ModuleSpec,
6187 path: &std::path::Path,
6188) -> Result<(), SuperviseError> {
6189 subc_cgroup::apply(command, path).map_err(|source| SuperviseError::Cgroup {
6190 module_id: spec.module_id.clone(),
6191 source,
6192 })
6193}
6194
6195fn capture_retention(spec: &ModuleSpec) -> Retention {
6196 let defaults = Retention::default();
6197 let value = |name: &str| {
6198 spec.env
6199 .iter()
6200 .rev()
6201 .find_map(|(key, value)| (key == name).then_some(value.as_str()))
6202 };
6203 Retention {
6204 max_file_mb: value(CAPTURE_MAX_FILE_MB_ENV)
6205 .and_then(|value| value.parse().ok())
6206 .unwrap_or(defaults.max_file_mb),
6207 keep: value(CAPTURE_KEEP_ENV)
6208 .and_then(|value| value.parse().ok())
6209 .unwrap_or(defaults.keep),
6210 max_age_days: value(CAPTURE_MAX_AGE_DAYS_ENV)
6211 .and_then(|value| value.parse().ok())
6212 .unwrap_or(defaults.max_age_days),
6213 }
6214}
6215
6216fn generate_launch_nonce() -> Result<String, SuperviseError> {
6219 let mut bytes = [0u8; 32];
6220 getrandom::getrandom(&mut bytes).map_err(|source| SuperviseError::LaunchNonce {
6221 reason: source.to_string(),
6222 })?;
6223 let mut hex = String::with_capacity(64);
6224 for b in bytes {
6225 use std::fmt::Write;
6226 let _ = write!(hex, "{b:02x}");
6227 }
6228 Ok(hex)
6229}
6230
6231fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
6234 if a.len() != b.len() {
6235 return false;
6236 }
6237 let mut diff = 0u8;
6238 for (x, y) in a.iter().zip(b.iter()) {
6239 diff |= x ^ y;
6240 }
6241 diff == 0
6242}
6243
6244fn spawn_and_mark_running(
6245 spec: &ModuleSpec,
6246 runtime: &SupervisorRuntimeConfig,
6247 snapshot: &SharedSnapshot,
6248) -> Result<SupervisedChild, SuperviseError> {
6249 let child = spawn_child(
6250 spec,
6251 runtime.connection_file_path.as_deref(),
6252 runtime.supervisor_handle.as_ref(),
6253 &runtime.stderr_ring,
6254 runtime.capture_logs_dir.as_deref(),
6255 &runtime.child_roster,
6256 #[cfg(target_os = "linux")]
6257 runtime.cgroup_placement.as_ref(),
6258 )?;
6259 set_running(snapshot, &child, &spec.module_id, &runtime.spawn_events)?;
6260 Ok(child)
6261}
6262
6263enum RegistrationWaitOutcome {
6264 Registered,
6265 Exited(ExitReport),
6266 TimedOut,
6267}
6268
6269struct ReloadRegistrationFailure {
6270 exit_report: ExitReport,
6271 reason: String,
6272}
6273
6274#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6275enum BusyGaugeObservation {
6276 Quiescent,
6277 Busy,
6278 Omitted,
6279}
6280
6281fn busy_gauge_observation(metrics: Option<&Value>, gauges: &[String]) -> BusyGaugeObservation {
6282 let Some(metrics) = metrics.and_then(Value::as_object) else {
6283 return BusyGaugeObservation::Omitted;
6284 };
6285 let mut sum = 0u128;
6286 for gauge in gauges {
6287 let Some(value) = metrics.get(gauge) else {
6288 return BusyGaugeObservation::Omitted;
6289 };
6290 let Some(value) = value.as_u64() else {
6291 return BusyGaugeObservation::Busy;
6292 };
6293 sum = sum.saturating_add(u128::from(value));
6294 }
6295 if sum == 0 {
6296 BusyGaugeObservation::Quiescent
6297 } else {
6298 BusyGaugeObservation::Busy
6299 }
6300}
6301
6302fn declared_busy_gauges(
6303 registry: &Registry,
6304 module_id: &str,
6305) -> Result<Vec<String>, SuperviseError> {
6306 busy_gauges_of(
6307 registry
6308 .get_module(module_id)
6309 .map_err(SuperviseError::Registry)?,
6310 )
6311}
6312
6313fn declared_busy_gauges_for_connection(
6317 registry: &Registry,
6318 connection_id: ConnectionId,
6319) -> Result<Vec<String>, SuperviseError> {
6320 busy_gauges_of(
6321 registry
6322 .get_module_by_connection(connection_id)
6323 .map_err(SuperviseError::Registry)?,
6324 )
6325}
6326
6327fn busy_gauges_of(
6328 registration: Option<crate::registry::ModuleRegistration>,
6329) -> Result<Vec<String>, SuperviseError> {
6330 let Some(registration) = registration else {
6331 return Ok(Vec::new());
6332 };
6333 let Some(self_signals) = registration.manifest.self_signals else {
6334 return Ok(Vec::new());
6335 };
6336
6337 let mut gauges = Vec::new();
6338 for declaration in self_signals {
6339 if declaration.kind != SelfSignalKind::Busy {
6340 continue;
6341 }
6342 match declaration.anchored_to {
6343 SignalAnchor::HealthGauges { gauges: declared } if !declared.is_empty() => {
6344 gauges.extend(declared)
6345 }
6346 _ => {
6347 gauges.push(String::new());
6350 }
6351 }
6352 }
6353 Ok(gauges)
6354}
6355
6356async fn wait_for_forwarding_quiescence(
6361 forwarding: &ForwardingTable,
6362 module_id: &str,
6363 runtime: &SupervisorRuntimeConfig,
6364 endpoint: crate::ModuleEndpointId,
6365 deadline: Instant,
6366 busy_gauges: &[String],
6367 scope: DrainScope,
6368) -> Result<bool, SuperviseError> {
6369 let mut gauges_quiescent = busy_gauges.is_empty();
6370 let mut next_probe_at = Instant::now();
6371 let mut omission_counted = false;
6372
6373 loop {
6374 let now = Instant::now();
6375 if !busy_gauges.is_empty() && now >= next_probe_at && now < deadline {
6376 let report = match scope {
6377 DrainScope::Active => probe_module_health(module_id, runtime, Some(deadline)).await,
6378 DrainScope::Endpoint(endpoint) => {
6379 probe_endpoint_health(endpoint, runtime, Some(deadline)).await
6380 }
6381 };
6382 gauges_quiescent = match report {
6383 Ok(report) => match busy_gauge_observation(report.metrics.as_ref(), busy_gauges) {
6384 BusyGaugeObservation::Quiescent => true,
6385 BusyGaugeObservation::Busy => false,
6386 BusyGaugeObservation::Omitted => {
6387 if !omission_counted {
6388 forwarding
6389 .counters()
6390 .increment_drains_with_undeclared_gauge();
6391 omission_counted = true;
6392 }
6393 false
6394 }
6395 },
6396 Err(err) => {
6397 warn!(
6398 module_id,
6399 error = %err,
6400 "drain health.check did not produce declared busy gauges; treating module as busy"
6401 );
6402 false
6403 }
6404 };
6405 next_probe_at = Instant::now() + runtime.health.cadence.max(REGISTRY_RELEASE_POLL);
6406 }
6407
6408 let in_flight = forwarding
6409 .endpoint_in_flight_count(endpoint)
6410 .map_err(SuperviseError::Forwarding)?;
6411 if in_flight == 0 && gauges_quiescent {
6412 return Ok(true);
6413 }
6414
6415 let now = Instant::now();
6416 if now >= deadline {
6417 return Ok(false);
6418 }
6419 let mut wait = deadline
6420 .saturating_duration_since(now)
6421 .min(REGISTRY_RELEASE_POLL);
6422 if !busy_gauges.is_empty() {
6423 wait = wait.min(next_probe_at.saturating_duration_since(now));
6424 }
6425 sleep(wait).await;
6426 }
6427}
6428
6429fn drained_after_quiescence_wait(wait_result: &Result<bool, SuperviseError>) -> bool {
6437 match wait_result {
6438 Ok(drained) => *drained,
6439 Err(_) => false,
6440 }
6441}
6442
6443fn send_route_goodbyes(forwarding: &ForwardingTable, released_routes: Vec<GoodbyeTarget>) {
6444 for released in released_routes {
6445 let frame = match Frame::build_with_version(
6446 released.negotiated_ver,
6447 FrameType::Goodbye,
6448 control_flags(),
6449 released.channel,
6450 released.epoch,
6451 0,
6452 Vec::new(),
6453 ) {
6454 Ok(frame) => frame,
6455 Err(err) => {
6456 warn!(
6457 route_channel = released.channel,
6458 error = %err,
6459 "failed to build supervisor drain route GOODBYE frame"
6460 );
6461 continue;
6462 }
6463 };
6464 if !released.close_on_delivery_failure() {
6465 crate::forwarding::send_module_route_goodbye(
6466 &forwarding.counters(),
6467 &released.sink,
6468 frame,
6469 released.module_id.as_deref(),
6470 "supervisor drain",
6471 );
6472 continue;
6473 }
6474 if let Err(err) = released.sink.try_send(frame) {
6475 warn!(
6476 target_connection_id = released.connection_id.get(),
6477 route_channel = released.channel,
6478 error = %err,
6479 "supervisor drain route GOODBYE was not delivered to client; closing target connection"
6480 );
6481 let _ = forwarding.escalate_client_delivery_failure(
6482 released.connection_id,
6483 released.channel,
6484 released.epoch,
6485 CloseReason::new(
6486 "route_goodbye_delivery_failed",
6487 format!(
6488 "failed to enqueue supervisor drain route GOODBYE for channel {}: {err}",
6489 released.channel
6490 ),
6491 ),
6492 crate::forwarding::UndeliveredFrame {
6493 module_id: released.module_id.as_deref(),
6494 sink: &released.sink,
6495 },
6496 );
6497 }
6498 }
6499}
6500
6501fn send_module_draining(
6502 module_id: &str,
6503 reason: RouteCloseReason,
6504 deadline_ms: u64,
6505 target: &ModuleDrainTarget,
6506) {
6507 let body = match serde_json::to_vec(&ModuleControlCommand::Draining {
6508 reason,
6509 deadline_ms,
6510 }) {
6511 Ok(body) => body,
6512 Err(err) => {
6513 warn!(
6514 module_id,
6515 error = %err,
6516 "failed to encode module draining command"
6517 );
6518 return;
6519 }
6520 };
6521 let frame = match Frame::build_with_version(
6522 target.negotiated_ver,
6523 FrameType::Push,
6524 control_flags(),
6525 0,
6526 0,
6527 0,
6528 body,
6529 ) {
6530 Ok(frame) => frame,
6531 Err(err) => {
6532 warn!(
6533 module_id,
6534 error = %err,
6535 "failed to build module draining command frame"
6536 );
6537 return;
6538 }
6539 };
6540 if let Err(err) = target.sink.try_send(frame) {
6541 warn!(
6542 module_id,
6543 target_connection_id = target.endpoint.connection_id.get(),
6544 error = %err,
6545 "module draining command was not delivered to peer"
6546 );
6547 }
6548}
6549
6550fn module_goodbye_frame(module_id: &str, negotiated_ver: u8) -> Option<Frame> {
6552 match Frame::build_with_version(
6553 negotiated_ver,
6554 FrameType::Goodbye,
6555 control_flags(),
6556 0,
6557 0,
6558 0,
6559 Vec::new(),
6560 ) {
6561 Ok(frame) => Some(frame),
6562 Err(err) => {
6563 warn!(
6564 module_id,
6565 error = %err,
6566 "failed to build module GOODBYE frame"
6567 );
6568 None
6569 }
6570 }
6571}
6572
6573#[cfg(unix)]
6587async fn send_module_goodbyes_for_daemon_shutdown(
6588 forwarding: &Arc<ForwardingTable>,
6589 reason: &CloseReason,
6590 wait_for_flush: bool,
6591) {
6592 const GOODBYE_BUDGET: Duration = Duration::from_millis(500);
6593 let targets = match forwarding.module_connections() {
6594 Ok(targets) => targets,
6595 Err(err) => {
6596 warn!(error = %err, "could not list module connections for shutdown GOODBYE");
6597 return;
6598 }
6599 };
6600 let deadline = Instant::now() + GOODBYE_BUDGET;
6601 let mut sends = tokio::task::JoinSet::new();
6602 for target in targets {
6603 let Some(frame) = module_goodbye_frame(&target.module_id, target.negotiated_ver) else {
6604 continue;
6605 };
6606 if !wait_for_flush {
6607 if let Err(err) = target.sink.try_send(frame) {
6608 debug!(
6609 module_id = %target.module_id,
6610 error = %err,
6611 "shutdown module GOODBYE was not queued"
6612 );
6613 }
6614 continue;
6615 }
6616 let forwarding = Arc::clone(forwarding);
6617 let reason = reason.clone();
6618 sends.spawn(async move {
6619 match timeout_at(deadline, target.sink.send_flushed(frame)).await {
6620 Ok(Ok(())) => {}
6621 Ok(Err(err)) => debug!(
6622 module_id = %target.module_id,
6623 error = %err,
6624 "module connection closed before its shutdown GOODBYE was written"
6625 ),
6626 Err(_) => warn!(
6627 module_id = %target.module_id,
6628 budget = ?GOODBYE_BUDGET,
6629 "shutdown module GOODBYE was not written within its budget; closing anyway"
6630 ),
6631 }
6632 forwarding.request_connection_close(target.endpoint.connection_id, reason);
6633 });
6634 }
6635 while sends.join_next().await.is_some() {}
6637}
6638
6639fn send_module_goodbye(module_id: &str, forwarding: &ForwardingTable, target: &ModuleDrainTarget) {
6640 let Some(frame) = module_goodbye_frame(module_id, target.negotiated_ver) else {
6641 return;
6642 };
6643 if let Err(err) = target.sink.try_send(frame) {
6644 warn!(
6645 module_id,
6646 target_connection_id = target.endpoint.connection_id.get(),
6647 error = %err,
6648 "supervisor drain module GOODBYE was not delivered to peer; closing module connection"
6649 );
6650 forwarding.request_connection_close(
6651 target.endpoint.connection_id,
6652 CloseReason::new(
6653 "module_goodbye_delivery_failed",
6654 format!("failed to enqueue supervisor drain module GOODBYE for module '{module_id}': {err}"),
6655 ),
6656 );
6657 }
6658}
6659
6660#[derive(Clone, Copy)]
6661struct ForwardingDrainContext<'a> {
6662 spec: &'a ModuleSpec,
6663 runtime: &'a SupervisorRuntimeConfig,
6664 registry: &'a Registry,
6665 scope: DrainScope,
6666}
6667
6668#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6670enum DrainScope {
6671 Active,
6674 Endpoint(crate::ModuleEndpointId),
6679}
6680
6681#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6689enum StopNotice {
6690 SentOverConnection,
6693 NoConnection,
6697 NotSent,
6701}
6702
6703async fn begin_forwarding_drain(
6704 spec: &ModuleSpec,
6705 runtime: &SupervisorRuntimeConfig,
6706 registry: &Registry,
6707 snapshot: &SharedSnapshot,
6708 enabled: Option<bool>,
6709 reason: RouteCloseReason,
6710) -> Result<StopNotice, SuperviseError> {
6711 let Some(forwarding) = runtime.forwarding.as_ref() else {
6712 return Err(SuperviseError::ReloadUnavailable {
6713 module_id: spec.module_id.clone(),
6714 reason: "supervisor was not configured with a forwarding table".to_string(),
6715 });
6716 };
6717
6718 begin_forwarding_drain_with(
6719 forwarding,
6720 ForwardingDrainContext {
6721 spec,
6722 runtime,
6723 registry,
6724 scope: DrainScope::Active,
6725 },
6726 snapshot,
6727 enabled,
6728 reason,
6729 runtime.drain_timeout,
6730 )
6731 .await
6732}
6733
6734async fn begin_forwarding_drain_if_configured(
6735 spec: &ModuleSpec,
6736 runtime: &SupervisorRuntimeConfig,
6737 registry: &Registry,
6738 snapshot: &SharedSnapshot,
6739 enabled: Option<bool>,
6740 reason: RouteCloseReason,
6741) -> Result<StopNotice, SuperviseError> {
6742 begin_forwarding_drain_with_timeout(
6743 spec,
6744 runtime,
6745 registry,
6746 snapshot,
6747 enabled,
6748 reason,
6749 runtime.drain_timeout,
6750 )
6751 .await
6752}
6753
6754async fn begin_forwarding_drain_with_timeout(
6758 spec: &ModuleSpec,
6759 runtime: &SupervisorRuntimeConfig,
6760 registry: &Registry,
6761 snapshot: &SharedSnapshot,
6762 enabled: Option<bool>,
6763 reason: RouteCloseReason,
6764 drain_timeout: Duration,
6765) -> Result<StopNotice, SuperviseError> {
6766 let Some(forwarding) = runtime.forwarding.as_ref() else {
6767 return Ok(StopNotice::NotSent);
6768 };
6769
6770 begin_forwarding_drain_with(
6771 forwarding,
6772 ForwardingDrainContext {
6773 spec,
6774 runtime,
6775 registry,
6776 scope: DrainScope::Active,
6777 },
6778 snapshot,
6779 enabled,
6780 reason,
6781 drain_timeout,
6782 )
6783 .await
6784}
6785
6786async fn begin_forwarding_drain_with(
6787 forwarding: &ForwardingTable,
6788 context: ForwardingDrainContext<'_>,
6789 snapshot: &SharedSnapshot,
6790 enabled: Option<bool>,
6791 reason: RouteCloseReason,
6792 drain_timeout: Duration,
6793) -> Result<StopNotice, SuperviseError> {
6794 let ForwardingDrainContext {
6795 spec,
6796 runtime,
6797 registry,
6798 scope,
6799 } = context;
6800 debug_assert_ne!(reason, RouteCloseReason::Crash);
6801 let terminal = matches!(reason, RouteCloseReason::Disable);
6802 let drain_started_at = Instant::now();
6803 let drain_deadline = drain_started_at + drain_timeout;
6804 let deadline_ms =
6805 unix_ms_now().saturating_add(u64::try_from(drain_timeout.as_millis()).unwrap_or(u64::MAX));
6806 let busy_gauges = match scope {
6807 DrainScope::Active => declared_busy_gauges(registry, &spec.module_id)?,
6808 DrainScope::Endpoint(endpoint) => {
6809 declared_busy_gauges_for_connection(registry, endpoint.connection_id)?
6810 }
6811 };
6812
6813 let gate_started = Instant::now();
6816 let drain_target = match scope {
6817 DrainScope::Active => forwarding.begin_module_drain(&spec.module_id, reason),
6818 DrainScope::Endpoint(endpoint) => forwarding.begin_endpoint_drain(endpoint, reason),
6819 }
6820 .map_err(SuperviseError::Forwarding)?;
6821 info!(
6826 module_id = %spec.module_id,
6827 ?reason,
6828 gate_ms = u64::try_from(gate_started.elapsed().as_millis()).unwrap_or(u64::MAX),
6829 connected = drain_target.is_some(),
6830 "module drain began; route admission closed"
6831 );
6832 if scope == DrainScope::Active {
6833 update_snapshot(snapshot, Some(&spec.module_id), |state| {
6834 state.state = ModuleState::Draining;
6835 state.draining_to_replace =
6836 matches!(reason, RouteCloseReason::Restart | RouteCloseReason::Reload);
6837 if let Some(enabled) = enabled {
6838 state.enabled = enabled;
6839 }
6840 })?;
6841 }
6842
6843 let Some(target) = drain_target.as_ref() else {
6844 return Ok(StopNotice::NoConnection);
6848 };
6849 {
6850 send_module_draining(&spec.module_id, reason, deadline_ms, target);
6851 let routes = forwarding
6852 .endpoint_routes(target.endpoint)
6853 .map_err(SuperviseError::Forwarding)?;
6854 let routes_notified = routes.len();
6855 crate::control::send_route_control_pushes(
6856 forwarding,
6857 routes.clone(),
6858 ClientControlPush::RouteClosing {
6859 module_id: spec.module_id.clone(),
6860 reason,
6861 },
6862 );
6863 send_route_goodbyes(forwarding, target.abandoned_bindings.clone());
6864
6865 let wait_result = wait_for_forwarding_quiescence(
6871 forwarding,
6872 &spec.module_id,
6873 runtime,
6874 target.endpoint,
6875 drain_deadline,
6876 &busy_gauges,
6877 scope,
6878 )
6879 .await;
6880 let drained = drained_after_quiescence_wait(&wait_result);
6881 if let Err(err) = &wait_result {
6882 error!(
6883 module_id = %spec.module_id,
6884 ?reason,
6885 error = %err,
6886 "forwarding quiescence wait failed after route.closing; forcing route.closed(drained: false) so the client is not left waiting on an unfulfilled promise"
6887 );
6888 } else if !drained {
6889 let holdouts = forwarding
6895 .endpoint_drain_holdouts(target.endpoint)
6896 .unwrap_or_default();
6897 warn!(
6898 module_id = %spec.module_id,
6899 waited = ?drain_timeout,
6900 ?reason,
6901 held_requests = holdouts.requests,
6902 held_routes = holdouts.routes,
6903 total_routes = holdouts.total_routes,
6904 top_connections = ?holdouts.top_connections,
6905 held = %holdouts
6908 .held
6909 .iter()
6910 .map(|(channel, corr)| format!("{channel}:{corr}"))
6911 .collect::<Vec<_>>()
6912 .join(","),
6913 "route drain timed out before request quiescence; forcing teardown"
6914 );
6915 }
6916 crate::control::send_route_control_pushes(
6917 forwarding,
6918 routes,
6919 ClientControlPush::RouteClosed {
6920 module_id: spec.module_id.clone(),
6921 reason,
6922 drained,
6923 abandoned: target.abandoned_bindings.len() as u32,
6924 excluded_subscriptions: target.excluded_subscriptions,
6925 terminal: Some(terminal),
6926 },
6927 );
6928 wait_result?;
6929
6930 let released_routes = match forwarding.release_module_endpoint_routes(target.endpoint) {
6936 Ok(routes) => routes,
6937 Err(err) => {
6938 warn!(
6939 module_id = %spec.module_id,
6940 ?reason,
6941 error = %err,
6942 "failed to release module endpoint routes after route.closed; module GOODBYE will still be sent"
6943 );
6944 send_module_goodbye(&spec.module_id, forwarding, target);
6945 return Err(SuperviseError::Forwarding(err));
6946 }
6947 };
6948 let route_goodbye_count = released_routes.len();
6949 send_route_goodbyes(forwarding, released_routes);
6950 send_module_goodbye(&spec.module_id, forwarding, target);
6951
6952 info!(
6958 module_id = %spec.module_id,
6959 ?reason,
6960 routes_notified,
6961 route_goodbyes = route_goodbye_count,
6962 abandoned_reservations = target.abandoned_bindings.len(),
6963 excluded_subscriptions = target.excluded_subscriptions,
6964 drained,
6965 "module drain complete; consumers notified via route.closing/route.closed pushes and per-route GOODBYE frames"
6966 );
6967 }
6968
6969 Ok(StopNotice::SentOverConnection)
6970}
6971
6972async fn wait_for_registration_after_reload(
6975 registry: &Registry,
6976 module_id: &str,
6977 snapshot: &SharedSnapshot,
6978 child: &mut SupervisedChild,
6979 wait: Duration,
6980) -> Result<RegistrationWaitOutcome, SuperviseError> {
6981 wait_for_slot_registration(
6982 registry,
6983 crate::registry::RegistrationSlot::Active(module_id),
6984 module_id,
6985 snapshot,
6986 child,
6987 wait,
6988 )
6989 .await
6990}
6991
6992async fn wait_for_slot_registration(
7000 registry: &Registry,
7001 slot: crate::registry::RegistrationSlot<'_>,
7002 module_id: &str,
7003 snapshot: &SharedSnapshot,
7004 child: &mut SupervisedChild,
7005 wait: Duration,
7006) -> Result<RegistrationWaitOutcome, SuperviseError> {
7007 let deadline = Instant::now() + wait;
7008 loop {
7009 if registry
7010 .registration(slot)
7011 .map_err(SuperviseError::Registry)?
7012 .is_some()
7013 {
7014 return Ok(RegistrationWaitOutcome::Registered);
7015 }
7016
7017 let now = Instant::now();
7018 if now >= deadline {
7019 return Ok(RegistrationWaitOutcome::TimedOut);
7020 }
7021 let remaining = deadline.saturating_duration_since(now);
7022 let poll = remaining.min(REGISTRY_RELEASE_POLL);
7023
7024 tokio::select! {
7025 wait_result = child.wait() => {
7026 let status = wait_result.map_err(|source| SuperviseError::Wait {
7027 module_id: module_id.to_string(),
7028 source,
7029 })?;
7030 return Ok(RegistrationWaitOutcome::Exited(classify_reaped_child_exit(
7031 snapshot,
7032 child,
7033 &status,
7034 )));
7035 }
7036 _ = sleep(poll) => {}
7037 }
7038 }
7039}
7040
7041fn registration_failure_exit_report(mut exit_report: ExitReport) -> ExitReport {
7042 if exit_report.kind != ExitKind::DeliberateSeverance {
7045 exit_report.kind = ExitKind::Crash;
7046 }
7047 exit_report
7048}
7049
7050async fn handle_reload_child_registration_failure(
7051 spec: &ModuleSpec,
7052 runtime: &SupervisorRuntimeConfig,
7053 registry: &Registry,
7054 process_liveness: &SupervisorProcessLiveness,
7055 snapshot: &SharedSnapshot,
7056 child: &mut Option<SupervisedChild>,
7057 failure: ReloadRegistrationFailure,
7058) -> Result<(), SuperviseError> {
7059 let ReloadRegistrationFailure {
7060 exit_report,
7061 reason,
7062 } = failure;
7063 match on_child_exit(
7064 spec,
7065 runtime.restart_policy,
7066 registry,
7067 snapshot,
7068 &runtime.terminal_ring,
7069 &runtime.spawn_events,
7070 &runtime.child_roster,
7071 exit_report,
7072 )
7073 .await
7074 {
7075 NextAction::Stop {
7076 registration_released,
7077 } => {
7078 if registration_released {
7079 process_liveness.untrack_if_current(&spec.module_id, snapshot);
7080 }
7081 }
7082 NextAction::Restart { schedule } => {
7083 let delay = schedule.map_or(runtime.restart_policy.delay_for_restart(0), |schedule| {
7084 schedule.delay
7085 });
7086 if let Some(schedule) = schedule {
7087 log_crash_respawn(&spec.module_id, schedule);
7088 }
7089 sleep(delay).await;
7090 if respawn_still_pending(snapshot) {
7094 if let Err(err) = wait_for_registration_release(
7095 registry,
7096 &spec.module_id,
7097 REGISTRY_RELEASE_TIMEOUT,
7098 )
7099 .await
7100 {
7101 fail_snapshot(snapshot, Some(&spec.module_id), None);
7102 process_liveness.untrack_if_current(&spec.module_id, snapshot);
7103 return Err(SuperviseError::ReloadFailed {
7104 module_id: spec.module_id.clone(),
7105 reason: format!(
7106 "{reason}; registration did not release before policy retry: {err}"
7107 ),
7108 });
7109 }
7110 process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
7111 match spawn_and_mark_running(spec, runtime, snapshot) {
7112 Ok(next_child) => {
7113 *child = Some(next_child);
7114 }
7115 Err(err) => {
7116 fail_snapshot(snapshot, Some(&spec.module_id), None);
7117 process_liveness.untrack_if_current(&spec.module_id, snapshot);
7118 return Err(SuperviseError::ReloadFailed {
7119 module_id: spec.module_id.clone(),
7120 reason: format!("{reason}; policy retry spawn failed: {err}"),
7121 });
7122 }
7123 }
7124 }
7125 }
7126 }
7127
7128 Err(SuperviseError::ReloadFailed {
7129 module_id: spec.module_id.clone(),
7130 reason,
7131 })
7132}
7133
7134async fn handle_reload_spawn_failure(
7135 spec: &ModuleSpec,
7136 runtime: &SupervisorRuntimeConfig,
7137 process_liveness: &SupervisorProcessLiveness,
7138 snapshot: &SharedSnapshot,
7139 child: &mut Option<SupervisedChild>,
7140 reason: String,
7141) -> Result<(), SuperviseError> {
7142 let mut should_retry = false;
7143 let now = Instant::now();
7144 update_snapshot(snapshot, Some(&spec.module_id), |state| {
7145 clear_current_process_facts(state);
7146 if daemon_will_restart(state, &runtime.restart_policy, now) {
7147 state.record_crash_restart(&runtime.restart_policy, now);
7148 state.state = ModuleState::Restarting;
7149 should_retry = true;
7150 } else if state.enabled {
7151 state.state = ModuleState::Failed;
7152 } else {
7153 state.state = ModuleState::Disabled;
7154 }
7155 })?;
7156
7157 if should_retry {
7158 sleep(runtime.restart_policy.backoff).await;
7159 if respawn_still_pending(snapshot) {
7163 process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
7164 match spawn_and_mark_running(spec, runtime, snapshot) {
7165 Ok(next_child) => {
7166 *child = Some(next_child);
7167 }
7168 Err(err) => {
7169 fail_snapshot(snapshot, Some(&spec.module_id), None);
7170 process_liveness.untrack_if_current(&spec.module_id, snapshot);
7171 return Err(SuperviseError::ReloadFailed {
7172 module_id: spec.module_id.clone(),
7173 reason: format!("{reason}; policy retry spawn failed: {err}"),
7174 });
7175 }
7176 }
7177 }
7178 } else {
7179 process_liveness.untrack_if_current(&spec.module_id, snapshot);
7180 }
7181
7182 Err(SuperviseError::ReloadFailed {
7183 module_id: spec.module_id.clone(),
7184 reason,
7185 })
7186}
7187
7188fn control_flags() -> Flags {
7189 Flags::new(false, Priority::Passive, false)
7190}
7191
7192#[allow(clippy::too_many_arguments)]
7193async fn drain_optional_child(
7194 module_id: &str,
7195 protocol: ModuleProtocol,
7196 stop_notice: StopNotice,
7197 registry: &Registry,
7198 snapshot: &SharedSnapshot,
7199 terminal_ring: &Arc<Mutex<TerminalRing>>,
7200 spawn_events: &SpawnEventFeed,
7201 child: &mut Option<SupervisedChild>,
7202 drain_timeout: Duration,
7203 final_state: ModuleState,
7204 enabled: Option<bool>,
7205) -> Result<(), SuperviseError> {
7206 if let Some(child) = child.take() {
7207 drain_child_to_state(
7208 module_id,
7209 protocol,
7210 stop_notice,
7211 registry,
7212 snapshot,
7213 terminal_ring,
7214 spawn_events,
7215 child,
7216 drain_timeout,
7217 final_state,
7218 enabled,
7219 )
7220 .await
7221 } else {
7222 update_snapshot(snapshot, Some(module_id), |state| {
7223 state.state = final_state;
7224 if let Some(enabled) = enabled {
7225 state.enabled = enabled;
7226 }
7227 clear_current_process_facts(state);
7228 })?;
7229 wait_for_registration_release(registry, module_id, REGISTRY_RELEASE_TIMEOUT).await
7230 }
7231}
7232
7233#[allow(clippy::too_many_arguments)]
7234async fn drain_child_to_state(
7235 module_id: &str,
7236 protocol: ModuleProtocol,
7237 stop_notice: StopNotice,
7238 registry: &Registry,
7239 snapshot: &SharedSnapshot,
7240 terminal_ring: &Arc<Mutex<TerminalRing>>,
7241 spawn_events: &SpawnEventFeed,
7242 mut child: SupervisedChild,
7243 drain_timeout: Duration,
7244 final_state: ModuleState,
7245 enabled: Option<bool>,
7246) -> Result<(), SuperviseError> {
7247 update_snapshot(snapshot, Some(module_id), |state| {
7248 state.state = ModuleState::Draining;
7249 state.draining_to_replace = final_state == ModuleState::Restarting;
7250 if let Some(enabled) = enabled {
7251 state.enabled = enabled;
7252 }
7253 })?;
7254
7255 if stop_notice != StopNotice::SentOverConnection {
7266 if protocol == ModuleProtocol::Subc && stop_notice == StopNotice::NoConnection {
7267 info!(
7268 module_id,
7269 pid = child.pid,
7270 budget_ms = u64::try_from(drain_timeout.as_millis()).unwrap_or(u64::MAX),
7271 "module has no connection yet; requesting stop by signal"
7272 );
7273 }
7274 request_graceful_stop(module_id, &child);
7275 }
7276
7277 let exit_report = match timeout(drain_timeout, child.wait()).await {
7278 Ok(Ok(status)) => classify_reaped_child_exit(snapshot, &child, &status),
7279 Ok(Err(source)) => {
7280 fail_snapshot(snapshot, Some(module_id), None);
7281 return Err(SuperviseError::Wait {
7282 module_id: module_id.to_string(),
7283 source,
7284 });
7285 }
7286 Err(_) => {
7287 warn!(
7300 module_id,
7301 pid = child.pid,
7302 budget_ms = u64::try_from(drain_timeout.as_millis()).unwrap_or(u64::MAX),
7303 reason = ?final_state,
7304 ?stop_notice,
7305 "drain budget expired before the module exited; killing it"
7306 );
7307 child.start_kill().map_err(|source| {
7308 fail_snapshot(snapshot, Some(module_id), None);
7309 SuperviseError::Kill {
7310 module_id: module_id.to_string(),
7311 source,
7312 }
7313 })?;
7314 let status = child.wait().await.map_err(|source| {
7315 fail_snapshot(snapshot, Some(module_id), None);
7316 SuperviseError::Wait {
7317 module_id: module_id.to_string(),
7318 source,
7319 }
7320 })?;
7321 classify_reaped_child_exit(snapshot, &child, &status)
7322 }
7323 };
7324
7325 update_snapshot(snapshot, Some(module_id), |state| {
7326 state.state = final_state;
7327 if let Some(enabled) = enabled {
7328 state.enabled = enabled;
7329 }
7330 clear_current_process_facts(state);
7331 state.last_exit = Some(exit_report.clone());
7332 if exit_report.kind == ExitKind::DeliberateSeverance {
7333 state.lifetime_restarts += 1;
7334 }
7335 })?;
7336 record_terminal(
7337 module_id,
7338 terminal_ring,
7339 spawn_events,
7340 &exit_report,
7341 terminal_disposition(final_state),
7342 );
7343 child.drain_stderr(module_id).await;
7344
7345 wait_for_registration_release(registry, module_id, REGISTRY_RELEASE_TIMEOUT).await
7346}
7347
7348#[cfg(unix)]
7368fn request_graceful_stop(module_id: &str, child: &SupervisedChild) {
7369 let Some(pid) = child
7370 .id()
7371 .and_then(|pid| i32::try_from(pid).ok())
7372 .and_then(rustix::process::Pid::from_raw)
7373 else {
7374 debug!(
7375 module_id,
7376 "no pid to signal for teardown; falling through to the drain wait"
7377 );
7378 return;
7379 };
7380 match rustix::process::kill_process(pid, rustix::process::Signal::TERM) {
7381 Ok(()) => debug!(
7382 module_id,
7383 "sent SIGTERM to a module nothing else asked to stop"
7384 ),
7385 Err(err) => debug!(
7386 module_id,
7387 error = %err,
7388 "SIGTERM to module failed; the drain wait and kill still apply"
7389 ),
7390 }
7391}
7392
7393#[cfg(not(unix))]
7401fn request_graceful_stop(module_id: &str, _child: &SupervisedChild) {
7402 debug!(
7403 module_id,
7404 "no graceful stop signal exists on this platform; teardown of a module nothing asked to stop waits, then kills"
7405 );
7406}
7407
7408fn terminal_disposition(final_state: ModuleState) -> TerminalDisposition {
7409 match final_state {
7410 ModuleState::Stopped => TerminalDisposition::Stopped,
7411 ModuleState::Disabled => TerminalDisposition::Disabled,
7412 ModuleState::Restarting => TerminalDisposition::Restarting,
7413 ModuleState::Failed => TerminalDisposition::Failed,
7414 ModuleState::Starting
7415 | ModuleState::Running
7416 | ModuleState::Unresponsive
7417 | ModuleState::Draining => {
7418 unreachable!("terminal exits only finish in terminal or restarting states")
7419 }
7420 }
7421}
7422
7423async fn wait_for_registration_release(
7426 registry: &Registry,
7427 module_id: &str,
7428 wait: Duration,
7429) -> Result<(), SuperviseError> {
7430 wait_for_slot_registration_release(
7431 registry,
7432 crate::registry::RegistrationSlot::Active(module_id),
7433 wait,
7434 )
7435 .await
7436}
7437
7438async fn wait_for_slot_registration_release(
7446 registry: &Registry,
7447 slot: crate::registry::RegistrationSlot<'_>,
7448 wait: Duration,
7449) -> Result<(), SuperviseError> {
7450 let deadline = Instant::now() + wait;
7451 let mut release_events = registration_release_events().subscribe();
7452 let still_active = |registration: &crate::registry::ModuleRegistration| {
7453 SuperviseError::RegistrationStillActive {
7454 module_id: registration.manifest.module_id.clone(),
7455 waited: wait,
7456 }
7457 };
7458 loop {
7459 let _observed_generation = *release_events.borrow_and_update();
7460 let Some(registration) = registry
7461 .registration(slot)
7462 .map_err(SuperviseError::Registry)?
7463 else {
7464 return Ok(());
7465 };
7466
7467 let now = Instant::now();
7468 if now >= deadline {
7469 return Err(still_active(®istration));
7470 }
7471
7472 let remaining = deadline.saturating_duration_since(now);
7473 match timeout(remaining, release_events.changed()).await {
7474 Ok(Ok(())) | Ok(Err(_)) => {}
7475 Err(_) => return Err(still_active(®istration)),
7476 }
7477 }
7478}
7479
7480#[cfg(test)]
7481mod slot_registration_wait_tests {
7482 use super::*;
7483 use crate::registry::{ConnectionId, RegistrationSlot};
7484 use subc_protocol::manifest::ModuleManifest;
7485
7486 const INCUMBENT: u64 = 1;
7487 const CANDIDATE: u64 = 2;
7488
7489 fn swapped_registry() -> Arc<Registry> {
7490 let registry = Arc::new(Registry::default());
7491 let manifest = ModuleManifest::builder("m", "0.1.0").build();
7492 registry
7493 .register_with_control_ops(
7494 manifest.clone(),
7495 1,
7496 ConnectionId::new(INCUMBENT),
7497 Vec::new(),
7498 )
7499 .unwrap();
7500 registry
7501 .register_candidate_with_control_ops(
7502 manifest,
7503 1,
7504 ConnectionId::new(CANDIDATE),
7505 Vec::new(),
7506 )
7507 .unwrap();
7508 registry
7509 }
7510
7511 #[tokio::test]
7515 async fn incumbent_release_is_awaited_by_connection_not_by_module_id() {
7516 let registry = swapped_registry();
7517 registry.promote_candidate("m").unwrap().unwrap();
7518
7519 assert!(matches!(
7520 wait_for_registration_release(®istry, "m", Duration::from_millis(50)).await,
7521 Err(SuperviseError::RegistrationStillActive { .. })
7522 ));
7523
7524 assert!(matches!(
7526 wait_for_slot_registration_release(
7527 ®istry,
7528 RegistrationSlot::Connection(ConnectionId::new(INCUMBENT)),
7529 Duration::from_millis(50),
7530 )
7531 .await,
7532 Err(SuperviseError::RegistrationStillActive { .. })
7533 ));
7534
7535 let releaser = Arc::clone(®istry);
7536 let release = tokio::spawn(async move {
7537 sleep(Duration::from_millis(20)).await;
7538 releaser
7539 .deregister_connection(ConnectionId::new(INCUMBENT))
7540 .unwrap();
7541 notify_registration_release();
7542 });
7543 wait_for_slot_registration_release(
7544 ®istry,
7545 RegistrationSlot::Connection(ConnectionId::new(INCUMBENT)),
7546 Duration::from_secs(5),
7547 )
7548 .await
7549 .expect("the incumbent's own registration is released");
7550 release.await.unwrap();
7551 assert!(registry.get_module("m").unwrap().is_some());
7552 }
7553
7554 #[tokio::test]
7557 async fn candidate_slot_wait_ignores_the_incumbents_registration() {
7558 let registry = swapped_registry();
7559 assert!(matches!(
7560 wait_for_slot_registration_release(
7561 ®istry,
7562 RegistrationSlot::Candidate("m"),
7563 Duration::from_millis(50),
7564 )
7565 .await,
7566 Err(SuperviseError::RegistrationStillActive { .. })
7567 ));
7568 registry
7569 .deregister_connection(ConnectionId::new(CANDIDATE))
7570 .unwrap();
7571 wait_for_slot_registration_release(
7572 ®istry,
7573 RegistrationSlot::Candidate("m"),
7574 Duration::from_millis(50),
7575 )
7576 .await
7577 .expect("a candidate slot with no candidate is released");
7578 assert!(registry
7579 .registration(RegistrationSlot::Active("m"))
7580 .unwrap()
7581 .is_some());
7582 }
7583}
7584
7585fn classify_exit(status: &ExitStatus) -> ExitReport {
7586 ExitReport {
7587 kind: if status.success() {
7588 ExitKind::Clean
7589 } else {
7590 ExitKind::Crash
7591 },
7592 code: status.code(),
7593 signal: exit_signal(status),
7594 at_ms: unix_ms_now(),
7595 }
7596}
7597
7598fn wait_error_exit_report() -> ExitReport {
7604 ExitReport {
7605 kind: ExitKind::Crash,
7606 code: None,
7607 signal: None,
7608 at_ms: unix_ms_now(),
7609 }
7610}
7611
7612#[cfg(unix)]
7613fn exit_signal(status: &ExitStatus) -> Option<i32> {
7614 use std::os::unix::process::ExitStatusExt;
7615
7616 status.signal()
7617}
7618
7619#[cfg(not(unix))]
7620fn exit_signal(_status: &ExitStatus) -> Option<i32> {
7621 None
7622}
7623
7624fn reset_restart_count(snapshot: &SharedSnapshot, module_id: &str) -> Result<(), SuperviseError> {
7630 update_snapshot(snapshot, Some(module_id), |state| {
7631 state.clear_crash_restarts();
7632 })
7633}
7634
7635fn set_running(
7636 snapshot: &SharedSnapshot,
7637 child: &SupervisedChild,
7638 module_id: &str,
7639 spawn_events: &SpawnEventFeed,
7640) -> Result<(), SuperviseError> {
7641 let mut state = snapshot.lock().map_err(|_| SuperviseError::StatePoisoned {
7642 module_id: Some(module_id.to_string()),
7643 })?;
7644 state.spawn_generation = spawn_events.emit_spawned(module_id, child.pid, child.spawned_at_ms);
7645 state.in_alternate_slot = false;
7648 state.configuration_updated_since_spawn = false;
7649 state.state = ModuleState::Running;
7650 state.enabled = true;
7651 state.process_alive = true;
7652 state.pid = child.id();
7653 state.spawned_at_ms = Some(child.spawned_at_ms);
7654 state.spawned_from = Some(child.spawned_from.clone());
7655 state.spawned_file_identity = child.spawned_file_identity;
7656 state.process_start_time = child.process_start_time;
7657 Ok(())
7658}
7659
7660fn clear_current_process_facts(state: &mut SupervisorSnapshot) {
7661 state.process_alive = false;
7662 state.pid = None;
7663 state.spawned_at_ms = None;
7664 state.spawned_from = None;
7665 state.spawned_file_identity = None;
7666 state.process_start_time = None;
7667 state.deliberate_severance = None;
7668}
7669
7670#[cfg(test)]
7671fn record_deliberate_severance(
7672 snapshot: &SharedSnapshot,
7673 identity: ProcessIdentity,
7674) -> Result<(), SuperviseError> {
7675 update_snapshot(snapshot, None, |state| {
7676 state.deliberate_severance = Some(identity);
7677 })
7678}
7679
7680fn apply_deliberate_severance_marker(
7681 snapshot: &SharedSnapshot,
7682 exited_identity: Option<ProcessIdentity>,
7683 mut exit_report: ExitReport,
7684) -> ExitReport {
7685 let marker = lock_snapshot(snapshot)
7686 .ok()
7687 .and_then(|mut state| state.deliberate_severance.take());
7688 if marker.is_some() && marker == exited_identity {
7689 exit_report.kind = ExitKind::DeliberateSeverance;
7690 }
7691 exit_report
7692}
7693
7694fn classify_reaped_child_exit(
7695 snapshot: &SharedSnapshot,
7696 child: &SupervisedChild,
7697 status: &ExitStatus,
7698) -> ExitReport {
7699 apply_deliberate_severance_marker(snapshot, child.process_identity(), classify_exit(status))
7700}
7701
7702fn fail_snapshot(
7703 snapshot: &SharedSnapshot,
7704 module_id: Option<&str>,
7705 last_exit: Option<ExitReport>,
7706) {
7707 if let Err(err) = update_snapshot(snapshot, module_id, |state| {
7708 state.state = ModuleState::Failed;
7709 clear_current_process_facts(state);
7710 if let Some(last_exit) = last_exit {
7711 state.last_exit = Some(last_exit);
7712 }
7713 }) {
7714 error!(error = %err, "failed to mark supervisor state failed");
7715 }
7716}
7717
7718fn update_snapshot(
7719 snapshot: &SharedSnapshot,
7720 module_id: Option<&str>,
7721 update: impl FnOnce(&mut SupervisorSnapshot),
7722) -> Result<(), SuperviseError> {
7723 let mut state = snapshot.lock().map_err(|_| SuperviseError::StatePoisoned {
7724 module_id: module_id.map(ToOwned::to_owned),
7725 })?;
7726 update(&mut state);
7727 Ok(())
7728}
7729
7730const SLOW_SNAPSHOT_LOCK_THRESHOLD: Duration = Duration::from_millis(250);
7731
7732fn lock_snapshot_for_control<'a>(
7733 snapshot: &'a SharedSnapshot,
7734 module_id: &str,
7735 caller: &'static str,
7736) -> Result<std::sync::MutexGuard<'a, SupervisorSnapshot>, SuperviseError> {
7737 let started_at = Instant::now();
7738 let guard = lock_snapshot(snapshot)?;
7739 let waited = started_at.elapsed();
7740 if waited >= SLOW_SNAPSHOT_LOCK_THRESHOLD {
7741 warn!(
7742 module_id = %module_id,
7743 waited_ms = waited.as_millis() as u64,
7744 caller = %caller,
7745 "slow snapshot lock"
7746 );
7747 }
7748 Ok(guard)
7749}
7750
7751fn lock_snapshot(
7752 snapshot: &SharedSnapshot,
7753) -> Result<std::sync::MutexGuard<'_, SupervisorSnapshot>, SuperviseError> {
7754 snapshot
7755 .lock()
7756 .map_err(|_| SuperviseError::StatePoisoned { module_id: None })
7757}
7758
7759#[cfg(test)]
7760mod terminal_history_tests {
7761 use std::{
7762 path::PathBuf,
7763 sync::Arc,
7764 time::{Duration, Instant},
7765 };
7766
7767 use tokio::time::sleep;
7768
7769 use super::{
7770 apply_deliberate_severance_marker, daemon_will_restart, drain_child_to_state,
7771 drained_after_quiescence_wait, handle_reload_spawn_failure, health_restart_child,
7772 lock_snapshot, on_child_exit, record_deliberate_severance, record_wait_error_terminal,
7773 reset_restart_count, spawn_and_mark_running, update_snapshot, wait_error_exit_report,
7774 ExitKind, ExitReport, ModuleProtocol, ModuleSpec, ModuleState, NextAction, ProcessIdentity,
7775 RestartPolicy, SpawnEventKind, StopNotice, SuperviseError, SupervisedModule, Supervisor,
7776 SupervisorHandle, SupervisorHealthStatus, SupervisorSnapshot,
7777 };
7778 use super::Instant as ClockInstant;
7783 use crate::{
7784 registry::Registry,
7785 terminal_ring::{TerminalRing, TerminalRingConfig},
7786 };
7787 use std::sync::Mutex;
7788 use subc_control::TerminalDisposition;
7789
7790 fn fake_aft_stub_path() -> PathBuf {
7795 let mut path = std::env::current_exe().expect("current_exe available in tests");
7796 path.pop();
7797 path.pop();
7798 path.push(if cfg!(windows) {
7799 "fake-aft-stub.exe"
7800 } else {
7801 "fake-aft-stub"
7802 });
7803 assert!(
7804 path.exists(),
7805 "fake-aft-stub not built at {}: run `cargo test -p subc-core` (which builds \
7806 [[bin]] targets) rather than `cargo test -p subc-core --lib` (which does not)",
7807 path.display()
7808 );
7809 path
7810 }
7811
7812 #[test]
7813 fn reserved_never_spawned_refuses_every_hello() {
7814 let supervisor = SupervisorHandle::default();
7819 supervisor.apply_identity_configuration(&ModuleSpec {
7820 module_id: "never-spawned".to_string(),
7821 program: PathBuf::from("/usr/bin/false"),
7822 args: Vec::new(),
7823 env: Vec::new(),
7824 reserved: true,
7825 reserved_prefixes: Vec::new(),
7826 protocol: ModuleProtocol::Subc,
7827 overlap: Default::default(),
7828 });
7829 assert!(
7830 supervisor
7831 .reserved_hello_rejection("never-spawned", Some("any-forged-nonce"))
7832 .is_some(),
7833 "forged nonce must refuse on a reserved never-spawned id"
7834 );
7835 assert!(
7836 supervisor
7837 .reserved_hello_rejection("never-spawned", None)
7838 .is_some(),
7839 "absent nonce must refuse on a reserved never-spawned id"
7840 );
7841 supervisor.set_spawn_nonce("never-spawned", "minted".to_string());
7843 supervisor.apply_identity_configuration(&ModuleSpec {
7844 module_id: "never-spawned".to_string(),
7845 program: PathBuf::from("/usr/bin/false"),
7846 args: Vec::new(),
7847 env: Vec::new(),
7848 reserved: true,
7849 reserved_prefixes: Vec::new(),
7850 protocol: ModuleProtocol::Subc,
7851 overlap: Default::default(),
7852 });
7853 assert!(supervisor
7854 .reserved_hello_rejection("never-spawned", Some("minted"))
7855 .is_none());
7856 assert!(supervisor
7857 .reserved_hello_rejection("never-spawned", Some("forged"))
7858 .is_some());
7859 }
7860
7861 fn seed_crash_restarts(state: &mut SupervisorSnapshot, count: u32) {
7864 let now = ClockInstant::now();
7865 for _ in 0..count {
7866 state.crash_restarts.push_back(now);
7867 }
7868 }
7869
7870 fn age_oldest_crash_restart_out_of_window(state: &mut SupervisorSnapshot, window: Duration) {
7874 let aged = state
7875 .crash_restarts
7876 .front()
7877 .expect("a crash restart must be recorded before it can be aged")
7878 .checked_sub(window + Duration::from_secs(1))
7879 .expect("the test clock is far enough from its origin to age an instant");
7880 state.crash_restarts[0] = aged;
7881 }
7882
7883 fn snapshot_with_restarts(enabled: bool, count: u32) -> SupervisorSnapshot {
7884 let mut state = SupervisorSnapshot::new(ModuleState::Running, enabled);
7885 seed_crash_restarts(&mut state, count);
7886 state
7887 }
7888
7889 #[test]
7890 fn daemon_owned_recovery_predicate_uses_the_pre_increment_budget() {
7891 let policy = RestartPolicy::new(3, Duration::ZERO);
7892 let now = ClockInstant::now();
7893 assert!(daemon_will_restart(
7894 &mut snapshot_with_restarts(true, 2),
7895 &policy,
7896 now
7897 ));
7898 assert!(!daemon_will_restart(
7899 &mut snapshot_with_restarts(true, 3),
7900 &policy,
7901 now
7902 ));
7903 assert!(!daemon_will_restart(
7904 &mut snapshot_with_restarts(false, 0),
7905 &policy,
7906 now
7907 ));
7908 }
7909
7910 #[test]
7911 fn crash_restart_backoff_escalates_with_in_window_count() {
7912 let policy = RestartPolicy::new(4, Duration::from_millis(100))
7913 .with_max_backoff(Duration::from_secs(30));
7914 let now = ClockInstant::now();
7915 let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
7916 let schedules = (0..4)
7917 .map(|_| {
7918 state
7919 .next_crash_restart(&policy, now)
7920 .expect("the test policy allows four crash restarts")
7921 })
7922 .collect::<Vec<_>>();
7923
7924 assert_eq!(
7925 schedules
7926 .iter()
7927 .map(|schedule| schedule.restart_in_window)
7928 .collect::<Vec<_>>(),
7929 vec![0, 1, 2, 3]
7930 );
7931 assert_eq!(
7932 schedules
7933 .iter()
7934 .map(|schedule| schedule.delay)
7935 .collect::<Vec<_>>(),
7936 vec![
7937 Duration::from_millis(100),
7938 Duration::from_secs(1),
7939 Duration::from_secs(10),
7940 Duration::from_secs(30),
7941 ]
7942 );
7943 }
7944
7945 #[test]
7946 fn crash_restart_backoff_resets_after_ring_clear() {
7947 let policy = RestartPolicy::new(3, Duration::from_millis(100));
7948 let now = ClockInstant::now();
7949 let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
7950 assert_eq!(
7951 state.next_crash_restart(&policy, now).unwrap().delay,
7952 Duration::from_millis(100)
7953 );
7954 assert_eq!(
7955 state.next_crash_restart(&policy, now).unwrap().delay,
7956 Duration::from_secs(1)
7957 );
7958
7959 state.clear_crash_restarts();
7960 let schedule = state
7961 .next_crash_restart(&policy, now)
7962 .expect("a cleared ring must allow another restart");
7963 assert_eq!(schedule.restart_in_window, 0);
7964 assert_eq!(schedule.delay, Duration::from_millis(100));
7965 }
7966
7967 #[test]
7968 fn crash_restart_backoff_ignores_aged_restarts() {
7969 let policy = RestartPolicy::new(3, Duration::from_millis(100));
7970 let now = ClockInstant::now();
7971 let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
7972 state
7973 .next_crash_restart(&policy, now)
7974 .expect("the first restart is allowed");
7975 state
7976 .next_crash_restart(&policy, now)
7977 .expect("the second restart is allowed");
7978 state.crash_restarts[0] = now
7979 .checked_sub(policy.window + Duration::from_secs(1))
7980 .expect("the fake clock can age a restart past the window");
7981
7982 let schedule = state
7983 .next_crash_restart(&policy, now)
7984 .expect("an aged restart must release its slot");
7985 assert_eq!(schedule.restart_in_window, 1);
7986 assert_eq!(schedule.delay, Duration::from_secs(1));
7987 assert_eq!(state.crash_restarts.len(), 2);
7988 }
7989
7990 #[test]
7994 fn a_budget_spent_before_the_window_no_longer_refuses() {
7995 let policy = RestartPolicy::new(3, Duration::ZERO);
7996 let mut state = snapshot_with_restarts(true, 3);
7997 let now = ClockInstant::now();
7998 assert!(!daemon_will_restart(&mut state, &policy, now));
7999
8000 assert!(daemon_will_restart(
8001 &mut state,
8002 &policy,
8003 now + policy.window + Duration::from_secs(1)
8004 ));
8005 assert!(
8006 state.crash_restarts.is_empty(),
8007 "reading the budget must drop the instants that left the window"
8008 );
8009 }
8010
8011 fn module_with_recovery_snapshot(
8012 state: ModuleState,
8013 enabled: bool,
8014 restart_count: u32,
8015 ) -> SupervisedModule {
8016 let registry = Arc::new(Registry::default());
8017 let supervisor =
8018 Supervisor::new(Arc::clone(®istry), RestartPolicy::new(3, Duration::ZERO));
8019 let module = supervisor
8020 .spawn(ModuleSpec {
8021 module_id: "recovery-snapshot".to_string(),
8022 program: fake_aft_stub_path(),
8023 args: Vec::new(),
8024 env: Vec::new(),
8025 reserved: false,
8026 reserved_prefixes: Vec::new(),
8027 protocol: ModuleProtocol::Subc,
8028 overlap: Default::default(),
8029 })
8030 .unwrap();
8031 update_snapshot(
8032 &module.inner.snapshot,
8033 Some("recovery-snapshot"),
8034 |snapshot| {
8035 snapshot.state = state;
8036 snapshot.enabled = enabled;
8037 seed_crash_restarts(snapshot, restart_count);
8038 },
8039 )
8040 .unwrap();
8041 module
8042 }
8043
8044 #[cfg(target_os = "linux")]
8045 #[tokio::test]
8046 async fn no_cgroup_placement_does_not_block_fake_aft_stub_spawn() {
8047 let supervisor = Supervisor::new(Arc::new(Registry::default()), RestartPolicy::default())
8048 .with_cgroup_placement(None);
8049 let result = supervisor.spawn(ModuleSpec {
8050 module_id: "no-cgroup-placement".to_string(),
8051 program: fake_aft_stub_path(),
8052 args: Vec::new(),
8053 env: Vec::new(),
8054 reserved: false,
8055 reserved_prefixes: Vec::new(),
8056 protocol: ModuleProtocol::Subc,
8057 overlap: Default::default(),
8058 });
8059
8060 assert!(
8061 result.is_ok(),
8062 "no delegation must not turn an otherwise valid spawn into a failure: {result:?}"
8063 );
8064 }
8065
8066 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
8067 async fn undecided_snapshot_uses_shared_restart_predicate() {
8068 assert!(module_with_recovery_snapshot(ModuleState::Running, true, 2)
8069 .will_recover_after_connection_loss()
8070 .unwrap());
8071 assert!(
8072 !module_with_recovery_snapshot(ModuleState::Running, true, 3)
8073 .will_recover_after_connection_loss()
8074 .unwrap()
8075 );
8076 }
8077
8078 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
8079 async fn restarting_snapshot_at_exhausted_budget_is_non_terminal() {
8080 assert!(
8081 module_with_recovery_snapshot(ModuleState::Restarting, true, 3)
8082 .will_recover_after_connection_loss()
8083 .unwrap()
8084 );
8085 }
8086
8087 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
8088 async fn terminal_phase_snapshots_are_terminal_before_budget_exhaustion() {
8089 assert!(!module_with_recovery_snapshot(ModuleState::Failed, true, 0)
8090 .will_recover_after_connection_loss()
8091 .unwrap());
8092 assert!(
8093 !module_with_recovery_snapshot(ModuleState::Disabled, true, 0)
8094 .will_recover_after_connection_loss()
8095 .unwrap()
8096 );
8097 }
8098
8099 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
8100 async fn warming_snapshot_is_limited_to_startup_phases() {
8101 for state in [
8102 ModuleState::Starting,
8103 ModuleState::Running,
8104 ModuleState::Restarting,
8105 ] {
8106 assert!(
8107 module_with_recovery_snapshot(state, true, 0)
8108 .is_warming()
8109 .unwrap(),
8110 "{state:?} should be warming"
8111 );
8112 }
8113 for state in [
8114 ModuleState::Unresponsive,
8115 ModuleState::Draining,
8116 ModuleState::Stopped,
8117 ModuleState::Failed,
8118 ModuleState::Disabled,
8119 ] {
8120 assert!(
8121 !module_with_recovery_snapshot(state, true, 0)
8122 .is_warming()
8123 .unwrap(),
8124 "{state:?} should not be warming"
8125 );
8126 }
8127 }
8128
8129 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
8130 async fn terminal_history_survives_respawn_and_keeps_both_crashes_in_order() {
8131 let registry = Arc::new(Registry::default());
8132 let supervisor =
8133 Supervisor::new(Arc::clone(®istry), RestartPolicy::new(1, Duration::ZERO));
8134 let module = supervisor
8135 .spawn(ModuleSpec {
8136 module_id: "terminal-history".to_string(),
8137 program: fake_aft_stub_path(),
8138 args: Vec::new(),
8139 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
8140 reserved: false,
8141 reserved_prefixes: Vec::new(),
8142 protocol: ModuleProtocol::Subc,
8143 overlap: Default::default(),
8144 })
8145 .unwrap();
8146
8147 let deadline = Instant::now() + Duration::from_secs(5);
8148 loop {
8149 let history = module.terminal_history();
8150 if history.entries.len() == 2 {
8151 assert_eq!(module.status().unwrap().state, ModuleState::Failed);
8152 assert_eq!(history.dropped, 0);
8153 assert_eq!(
8154 history
8155 .entries
8156 .iter()
8157 .map(|entry| entry.exit_code)
8158 .collect::<Vec<_>>(),
8159 vec![Some(23), Some(23)]
8160 );
8161 assert!(history.entries[0].at_ms <= history.entries[1].at_ms);
8162 return;
8163 }
8164 assert!(
8165 Instant::now() < deadline,
8166 "module did not retain two terminal exits: {history:?}"
8167 );
8168 sleep(Duration::from_millis(10)).await;
8169 }
8170 }
8171
8172 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
8176 async fn disable_during_crash_backoff_cancels_pending_respawn() {
8177 let backoff = Duration::from_secs(2);
8178 let supervisor = Supervisor::new(
8179 Arc::new(Registry::default()),
8180 RestartPolicy::new(10, backoff),
8181 );
8182 let module = supervisor
8183 .spawn(ModuleSpec {
8184 module_id: "disable-during-backoff".to_string(),
8185 program: fake_aft_stub_path(),
8186 args: Vec::new(),
8187 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
8188 reserved: false,
8189 reserved_prefixes: Vec::new(),
8190 protocol: ModuleProtocol::Subc,
8191 overlap: Default::default(),
8192 })
8193 .unwrap();
8194
8195 let deadline = Instant::now() + Duration::from_secs(5);
8197 loop {
8198 if module.status().unwrap().state == ModuleState::Restarting {
8199 break;
8200 }
8201 assert!(
8202 Instant::now() < deadline,
8203 "module never entered the crash backoff"
8204 );
8205 sleep(Duration::from_millis(10)).await;
8206 }
8207
8208 let started = Instant::now();
8209 module.set_enabled(false).await.unwrap();
8210 let waited = started.elapsed();
8211
8212 assert!(
8213 waited < backoff / 2,
8214 "disable waited {waited:?} behind the {backoff:?} crash backoff; the operator command must preempt the pending respawn"
8215 );
8216 assert_eq!(module.status().unwrap().state, ModuleState::Disabled);
8217
8218 sleep(backoff + Duration::from_millis(500)).await;
8220 let status = module.status().unwrap();
8221 assert_eq!(status.state, ModuleState::Disabled);
8222 assert_eq!(
8223 status.spawn_generation, 1,
8224 "module respawned after the operator disabled it"
8225 );
8226 }
8227
8228 #[cfg(unix)]
8231 fn protocol_none_sigterm_exits_clean_spec(
8232 module_id: &str,
8233 dir: &std::path::Path,
8234 ) -> (ModuleSpec, PathBuf, PathBuf) {
8235 let ready = dir.join("ready");
8236 let marker = dir.join("sigterm");
8237 let spec = ModuleSpec {
8238 module_id: module_id.to_string(),
8239 program: fake_aft_stub_path(),
8240 args: Vec::new(),
8241 env: vec![
8242 ("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string()),
8243 (
8244 "FAKE_AFT_SIGTERM_MARKER_PATH".to_string(),
8245 marker.display().to_string(),
8246 ),
8247 (
8248 "FAKE_AFT_NEVER_CONNECT_READY_PATH".to_string(),
8249 ready.display().to_string(),
8250 ),
8251 ],
8252 reserved: false,
8253 reserved_prefixes: Vec::new(),
8254 protocol: ModuleProtocol::None,
8255 overlap: Default::default(),
8256 };
8257 (spec, ready, marker)
8258 }
8259
8260 #[cfg(unix)]
8264 async fn wait_for_file(path: &std::path::Path) {
8265 let deadline = Instant::now() + Duration::from_secs(10);
8266 while !path.exists() {
8267 assert!(
8268 Instant::now() < deadline,
8269 "{} never appeared",
8270 path.display()
8271 );
8272 sleep(Duration::from_millis(10)).await;
8273 }
8274 }
8275
8276 #[cfg(unix)]
8280 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
8281 async fn protocol_none_unrequested_clean_exit_restarts_as_a_crash() {
8282 let dir = subc_test_support::TestTempDir::new("none-unrequested-clean-exit");
8283 let (spec, ready, marker) =
8284 protocol_none_sigterm_exits_clean_spec("none-unrequested-clean-exit", dir.path());
8285 let supervisor = Supervisor::new(
8286 Arc::new(Registry::default()),
8287 RestartPolicy::new(3, Duration::ZERO),
8288 );
8289 let module = supervisor.spawn(spec).unwrap();
8290 wait_for_file(&ready).await;
8291 let first_pid = module
8292 .status()
8293 .unwrap()
8294 .pid
8295 .expect("a running module reports its pid");
8296
8297 rustix::process::kill_process(
8298 rustix::process::Pid::from_raw(i32::try_from(first_pid).unwrap()).unwrap(),
8299 rustix::process::Signal::TERM,
8300 )
8301 .unwrap();
8302
8303 let deadline = Instant::now() + Duration::from_secs(10);
8304 let respawned = loop {
8305 let status = module.status().unwrap();
8306 if status.state == ModuleState::Running
8307 && status.pid.is_some_and(|pid| pid != first_pid)
8308 {
8309 break status;
8310 }
8311 assert!(
8312 Instant::now() < deadline,
8313 "protocol-none module was not respawned after an unrequested clean exit: {status:?}"
8314 );
8315 sleep(Duration::from_millis(10)).await;
8316 };
8317 assert_eq!(respawned.spawn_generation, 2);
8318 assert!(
8319 marker.exists(),
8320 "the child must have exited through its SIGTERM handler (exit 0), or this proves nothing about clean exits"
8321 );
8322
8323 let history = module.terminal_history();
8324 assert_eq!(history.entries.len(), 1, "{history:?}");
8325 let entry = &history.entries[0];
8326 assert_eq!(entry.exit_code, Some(0));
8327 assert_eq!(entry.exit_kind, subc_control::TerminalExitKind::Clean);
8328 assert_eq!(entry.disposition, TerminalDisposition::Restarting);
8329
8330 module.stop().await.unwrap();
8331 }
8332
8333 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
8337 async fn protocol_none_repeated_clean_exits_exhaust_the_restart_budget() {
8338 let supervisor = Supervisor::new(
8339 Arc::new(Registry::default()),
8340 RestartPolicy::new(1, Duration::ZERO),
8341 );
8342 let module = supervisor
8343 .spawn(ModuleSpec {
8344 module_id: "none-clean-exit-budget".to_string(),
8345 program: fake_aft_stub_path(),
8346 args: Vec::new(),
8347 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "0".to_string())],
8348 reserved: false,
8349 reserved_prefixes: Vec::new(),
8350 protocol: ModuleProtocol::None,
8351 overlap: Default::default(),
8352 })
8353 .unwrap();
8354
8355 let deadline = Instant::now() + Duration::from_secs(10);
8356 loop {
8357 let status = module.status().unwrap();
8358 if status.state == ModuleState::Failed {
8359 break;
8360 }
8361 assert!(
8362 Instant::now() < deadline,
8363 "module never exhausted its budget: {status:?} {:?}",
8364 module.terminal_history()
8365 );
8366 sleep(Duration::from_millis(10)).await;
8367 }
8368 let history = module.terminal_history();
8369 assert_eq!(
8370 history
8371 .entries
8372 .iter()
8373 .map(|entry| (entry.exit_code, entry.disposition.clone()))
8374 .collect::<Vec<_>>(),
8375 vec![
8376 (Some(0), TerminalDisposition::Restarting),
8377 (Some(0), TerminalDisposition::Failed),
8378 ]
8379 );
8380 let detail = history.entries[1]
8381 .disposition_detail
8382 .as_deref()
8383 .expect("a budget failure names the budget");
8384 assert!(detail.contains("max_restarts=1"), "{detail}");
8385 assert_eq!(module.status().unwrap().spawn_generation, 2);
8386 }
8387
8388 #[cfg(unix)]
8391 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
8392 async fn protocol_none_requested_stop_and_disable_do_not_respawn() {
8393 for disable in [false, true] {
8394 let label = if disable {
8395 "none-requested-disable"
8396 } else {
8397 "none-requested-stop"
8398 };
8399 let dir = subc_test_support::TestTempDir::new(label);
8400 let (spec, ready, marker) = protocol_none_sigterm_exits_clean_spec(label, dir.path());
8401 let supervisor = Supervisor::new(
8402 Arc::new(Registry::default()),
8403 RestartPolicy::new(3, Duration::ZERO),
8404 );
8405 let module = supervisor.spawn(spec).unwrap();
8406 wait_for_file(&ready).await;
8407
8408 if disable {
8409 module.set_enabled(false).await.unwrap();
8410 } else {
8411 module.stop().await.unwrap();
8412 }
8413 assert!(
8414 marker.exists(),
8415 "{label}: the child must have left through its SIGTERM handler with exit 0"
8416 );
8417
8418 sleep(Duration::from_millis(500)).await;
8421 let status = module.status().unwrap();
8422 let expected = if disable {
8423 ModuleState::Disabled
8424 } else {
8425 ModuleState::Stopped
8426 };
8427 assert_eq!(status.state, expected, "{label}");
8428 assert_eq!(
8429 status.spawn_generation, 1,
8430 "{label}: respawned after a requested stop"
8431 );
8432 let history = module.terminal_history();
8433 assert_eq!(history.entries.len(), 1, "{label}: {history:?}");
8434 assert_eq!(history.entries[0].exit_code, Some(0), "{label}");
8435 assert_ne!(
8436 history.entries[0].disposition,
8437 TerminalDisposition::Restarting,
8438 "{label}"
8439 );
8440 }
8441 }
8442
8443 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
8446 async fn subc_wire_clean_exit_is_still_a_stop() {
8447 let supervisor = Supervisor::new(
8448 Arc::new(Registry::default()),
8449 RestartPolicy::new(3, Duration::ZERO),
8450 );
8451 let module = supervisor
8452 .spawn(ModuleSpec {
8453 module_id: "wire-clean-exit".to_string(),
8454 program: fake_aft_stub_path(),
8455 args: Vec::new(),
8456 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "0".to_string())],
8457 reserved: false,
8458 reserved_prefixes: Vec::new(),
8459 protocol: ModuleProtocol::Subc,
8460 overlap: Default::default(),
8461 })
8462 .unwrap();
8463
8464 let deadline = Instant::now() + Duration::from_secs(10);
8465 while module.terminal_history().entries.is_empty() {
8466 assert!(Instant::now() < deadline, "module never exited");
8467 sleep(Duration::from_millis(10)).await;
8468 }
8469 sleep(Duration::from_millis(500)).await;
8471 let status = module.status().unwrap();
8472 assert_eq!(status.state, ModuleState::Stopped);
8473 assert_eq!(status.spawn_generation, 1);
8474 let history = module.terminal_history();
8475 assert_eq!(history.entries.len(), 1, "{history:?}");
8476 assert_eq!(history.entries[0].exit_code, Some(0));
8477 assert_eq!(history.entries[0].disposition, TerminalDisposition::Stopped);
8478 }
8479
8480 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
8484 async fn every_restart_increment_path_advances_lifetime_count() {
8485 let supervisor = Supervisor::new(
8486 Arc::new(Registry::default()),
8487 RestartPolicy::new(1, Duration::ZERO),
8488 );
8489 let runtime = supervisor.runtime_config();
8490 let spec = ModuleSpec {
8491 module_id: "lifetime-increment-path".to_string(),
8492 program: PathBuf::from("/unused/lifetime-increment-path"),
8493 args: Vec::new(),
8494 env: Vec::new(),
8495 reserved: false,
8496 reserved_prefixes: Vec::new(),
8497 protocol: ModuleProtocol::Subc,
8498 overlap: Default::default(),
8499 };
8500
8501 let crash_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
8502 assert!(matches!(
8503 on_child_exit(
8504 &spec,
8505 runtime.restart_policy,
8506 &supervisor.registry,
8507 &crash_snapshot,
8508 &runtime.terminal_ring,
8509 &runtime.spawn_events,
8510 &runtime.child_roster,
8511 ExitReport {
8512 kind: ExitKind::Crash,
8513 code: Some(1),
8514 signal: None,
8515 at_ms: 1,
8516 },
8517 )
8518 .await,
8519 NextAction::Restart { schedule: _ }
8520 ));
8521 let (crash_restarts, crash_lifetime) = {
8522 let state = lock_snapshot(&crash_snapshot).unwrap();
8523 (state.crash_restarts.len(), state.lifetime_restarts)
8524 };
8525 assert_eq!(crash_restarts, 1);
8526 assert_eq!(crash_lifetime, 1);
8527
8528 let health_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
8529 let mut health_child = None;
8530 assert!(matches!(
8531 health_restart_child(
8532 &spec,
8533 &runtime,
8534 &supervisor.registry,
8535 &supervisor.process_liveness,
8536 &health_snapshot,
8537 &mut health_child,
8538 SupervisorHealthStatus::Failing,
8539 None,
8540 2,
8541 )
8542 .await,
8543 Err(SuperviseError::Spawn { .. })
8544 ));
8545 let (health_restarts, health_lifetime) = {
8546 let state = lock_snapshot(&health_snapshot).unwrap();
8547 (state.crash_restarts.len(), state.lifetime_restarts)
8548 };
8549 assert_eq!(health_restarts, 1);
8550 assert_eq!(health_lifetime, 1);
8551
8552 let reload_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
8553 let mut reload_child = None;
8554 assert!(matches!(
8555 handle_reload_spawn_failure(
8556 &spec,
8557 &runtime,
8558 &supervisor.process_liveness,
8559 &reload_snapshot,
8560 &mut reload_child,
8561 "forced reload spawn failure".to_string(),
8562 )
8563 .await,
8564 Err(SuperviseError::ReloadFailed { .. })
8565 ));
8566 let (reload_restarts, reload_lifetime) = {
8567 let state = lock_snapshot(&reload_snapshot).unwrap();
8568 (state.crash_restarts.len(), state.lifetime_restarts)
8569 };
8570 assert_eq!(reload_restarts, 1);
8571 assert_eq!(reload_lifetime, 1);
8572 }
8573
8574 #[tokio::test]
8575 async fn deliberately_severed_live_child_records_lifetime_without_spending_restart_budget() {
8576 let supervisor = Supervisor::new(
8577 Arc::new(Registry::default()),
8578 RestartPolicy::new(3, Duration::ZERO),
8579 );
8580 let runtime = supervisor.runtime_config();
8581 let spec = ModuleSpec {
8582 module_id: "deliberately-severed".to_string(),
8583 program: PathBuf::from("/unused/deliberately-severed"),
8584 args: Vec::new(),
8585 env: Vec::new(),
8586 reserved: false,
8587 reserved_prefixes: Vec::new(),
8588 protocol: ModuleProtocol::Subc,
8589 overlap: Default::default(),
8590 };
8591 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
8592 let process = ProcessIdentity {
8593 pid: 41,
8594 start_time: 101,
8595 };
8596 record_deliberate_severance(&snapshot, process).unwrap();
8597 let exit_report = apply_deliberate_severance_marker(
8598 &snapshot,
8599 Some(process),
8600 ExitReport {
8601 kind: ExitKind::Crash,
8602 code: Some(1),
8603 signal: None,
8604 at_ms: 1,
8605 },
8606 );
8607 assert_eq!(exit_report.kind, ExitKind::DeliberateSeverance);
8608
8609 assert!(matches!(
8610 on_child_exit(
8611 &spec,
8612 runtime.restart_policy,
8613 &supervisor.registry,
8614 &snapshot,
8615 &runtime.terminal_ring,
8616 &runtime.spawn_events,
8617 &runtime.child_roster,
8618 exit_report,
8619 )
8620 .await,
8621 NextAction::Restart { schedule: _ }
8622 ));
8623 let state = lock_snapshot(&snapshot).unwrap();
8624 assert_eq!(state.lifetime_restarts, 1);
8625 assert_eq!(state.crash_restarts.len(), 0);
8626 }
8627
8628 #[tokio::test]
8629 async fn genuine_crash_spends_restart_budget_and_records_lifetime() {
8630 let supervisor = Supervisor::new(
8631 Arc::new(Registry::default()),
8632 RestartPolicy::new(3, Duration::ZERO),
8633 );
8634 let runtime = supervisor.runtime_config();
8635 let spec = ModuleSpec {
8636 module_id: "genuine-crash".to_string(),
8637 program: PathBuf::from("/unused/genuine-crash"),
8638 args: Vec::new(),
8639 env: Vec::new(),
8640 reserved: false,
8641 reserved_prefixes: Vec::new(),
8642 protocol: ModuleProtocol::Subc,
8643 overlap: Default::default(),
8644 };
8645 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
8646
8647 assert!(matches!(
8648 on_child_exit(
8649 &spec,
8650 runtime.restart_policy,
8651 &supervisor.registry,
8652 &snapshot,
8653 &runtime.terminal_ring,
8654 &runtime.spawn_events,
8655 &runtime.child_roster,
8656 ExitReport {
8657 kind: ExitKind::Crash,
8658 code: Some(1),
8659 signal: None,
8660 at_ms: 1,
8661 },
8662 )
8663 .await,
8664 NextAction::Restart { schedule: _ }
8665 ));
8666 let state = lock_snapshot(&snapshot).unwrap();
8667 assert_eq!(state.lifetime_restarts, 1);
8668 assert_eq!(state.crash_restarts.len(), 1);
8669 }
8670
8671 fn crash_exit_report(at_ms: u64) -> ExitReport {
8672 ExitReport {
8673 kind: ExitKind::Crash,
8674 code: Some(1),
8675 signal: None,
8676 at_ms,
8677 }
8678 }
8679
8680 fn windowed_crash_spec(module_id: &str) -> ModuleSpec {
8681 ModuleSpec {
8682 module_id: module_id.to_string(),
8683 program: PathBuf::from("/unused").join(module_id),
8684 args: Vec::new(),
8685 env: Vec::new(),
8686 reserved: false,
8687 reserved_prefixes: Vec::new(),
8688 protocol: ModuleProtocol::Subc,
8689 overlap: Default::default(),
8690 }
8691 }
8692
8693 #[tokio::test]
8699 async fn three_crashes_inside_the_window_stop_the_module_and_name_the_window() {
8700 let (logs, _guard) = crate::router::test_log::log_capture(tracing::Level::ERROR);
8701 let supervisor = Supervisor::new(
8702 Arc::new(Registry::default()),
8703 RestartPolicy::new(2, Duration::ZERO),
8704 );
8705 let runtime = supervisor.runtime_config();
8706 let spec = windowed_crash_spec("crash-loop-in-window");
8707 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
8708
8709 for attempt in 1..=2 {
8710 assert!(
8711 matches!(
8712 on_child_exit(
8713 &spec,
8714 runtime.restart_policy,
8715 &supervisor.registry,
8716 &snapshot,
8717 &runtime.terminal_ring,
8718 &runtime.spawn_events,
8719 &runtime.child_roster,
8720 crash_exit_report(attempt),
8721 )
8722 .await,
8723 NextAction::Restart { schedule: _ }
8724 ),
8725 "crash {attempt} is inside the budget and must respawn"
8726 );
8727 }
8728
8729 assert!(matches!(
8730 on_child_exit(
8731 &spec,
8732 runtime.restart_policy,
8733 &supervisor.registry,
8734 &snapshot,
8735 &runtime.terminal_ring,
8736 &runtime.spawn_events,
8737 &runtime.child_roster,
8738 crash_exit_report(3),
8739 )
8740 .await,
8741 NextAction::Stop { .. }
8742 ));
8743
8744 {
8745 let state = lock_snapshot(&snapshot).unwrap();
8746 assert_eq!(state.state, ModuleState::Failed);
8747 assert_eq!(state.crash_restarts.len(), 2);
8748 assert_eq!(state.lifetime_restarts, 2);
8749 }
8750
8751 let history = runtime
8752 .terminal_ring
8753 .lock()
8754 .expect("terminal ring is not poisoned")
8755 .snapshot();
8756 let last = history
8757 .entries
8758 .last()
8759 .expect("the refused crash is retained");
8760 assert_eq!(last.disposition, TerminalDisposition::Failed);
8761 assert_eq!(
8762 last.disposition_detail.as_deref(),
8763 Some("crash budget exhausted: max_restarts=2 within window_secs=600")
8764 );
8765
8766 let captured = crate::router::test_log::captured_logs(&logs);
8767 assert!(
8768 captured.contains("crash budget exhausted: max_restarts=2 within window_secs=600"),
8769 "the stop must be logged with its window: {captured}"
8770 );
8771 }
8772
8773 #[tokio::test]
8781 async fn a_crash_older_than_the_window_frees_its_slot_for_a_later_crash() {
8782 let supervisor = Supervisor::new(
8783 Arc::new(Registry::default()),
8784 RestartPolicy::new(2, Duration::ZERO),
8785 );
8786 let runtime = supervisor.runtime_config();
8787 let spec = windowed_crash_spec("crash-across-windows");
8788 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
8789
8790 for attempt in 1..=2 {
8791 assert!(matches!(
8792 on_child_exit(
8793 &spec,
8794 runtime.restart_policy,
8795 &supervisor.registry,
8796 &snapshot,
8797 &runtime.terminal_ring,
8798 &runtime.spawn_events,
8799 &runtime.child_roster,
8800 crash_exit_report(attempt),
8801 )
8802 .await,
8803 NextAction::Restart { schedule: _ }
8804 ));
8805 }
8806
8807 update_snapshot(&snapshot, Some(&spec.module_id), |state| {
8810 age_oldest_crash_restart_out_of_window(state, runtime.restart_policy.window);
8811 })
8812 .unwrap();
8813
8814 assert!(
8815 matches!(
8816 on_child_exit(
8817 &spec,
8818 runtime.restart_policy,
8819 &supervisor.registry,
8820 &snapshot,
8821 &runtime.terminal_ring,
8822 &runtime.spawn_events,
8823 &runtime.child_roster,
8824 crash_exit_report(3),
8825 )
8826 .await,
8827 NextAction::Restart { schedule: _ }
8828 ),
8829 "a crash older than the window must not hold a budget slot"
8830 );
8831
8832 let state = lock_snapshot(&snapshot).unwrap();
8833 assert_eq!(state.state, ModuleState::Restarting);
8834 assert_eq!(
8835 state.crash_restarts.len(),
8836 2,
8837 "the aged instant is dropped and the new one takes its place"
8838 );
8839 assert_eq!(
8840 state.lifetime_restarts, 3,
8841 "the ledger counts every restart, including the ones the window forgot"
8842 );
8843 }
8844
8845 #[tokio::test]
8850 async fn an_operator_restart_clears_the_ring_and_leaves_the_ledger_alone() {
8851 let supervisor = Supervisor::new(
8852 Arc::new(Registry::default()),
8853 RestartPolicy::new(2, Duration::ZERO),
8854 );
8855 let runtime = supervisor.runtime_config();
8856 let spec = windowed_crash_spec("operator-cleared-budget");
8857 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
8858
8859 for attempt in 1..=2 {
8860 assert!(matches!(
8861 on_child_exit(
8862 &spec,
8863 runtime.restart_policy,
8864 &supervisor.registry,
8865 &snapshot,
8866 &runtime.terminal_ring,
8867 &runtime.spawn_events,
8868 &runtime.child_roster,
8869 crash_exit_report(attempt),
8870 )
8871 .await,
8872 NextAction::Restart { schedule: _ }
8873 ));
8874 }
8875
8876 reset_restart_count(&snapshot, &spec.module_id).unwrap();
8877 {
8878 let state = lock_snapshot(&snapshot).unwrap();
8879 assert!(
8880 state.crash_restarts.is_empty(),
8881 "an operator restart returns the full budget"
8882 );
8883 assert_eq!(
8884 state.lifetime_restarts, 2,
8885 "clearing the budget must not unmake the crashes"
8886 );
8887 }
8888
8889 assert!(
8890 matches!(
8891 on_child_exit(
8892 &spec,
8893 runtime.restart_policy,
8894 &supervisor.registry,
8895 &snapshot,
8896 &runtime.terminal_ring,
8897 &runtime.spawn_events,
8898 &runtime.child_roster,
8899 crash_exit_report(3),
8900 )
8901 .await,
8902 NextAction::Restart { schedule: _ }
8903 ),
8904 "the cleared budget must be spendable again"
8905 );
8906 let state = lock_snapshot(&snapshot).unwrap();
8907 assert_eq!(state.crash_restarts.len(), 1);
8908 assert_eq!(state.lifetime_restarts, 3);
8909 }
8910
8911 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
8912 async fn severance_marker_for_a_dead_child_does_not_label_its_successor() {
8913 let severed = ProcessIdentity {
8914 pid: 41,
8915 start_time: 101,
8916 };
8917 let successor = ProcessIdentity {
8918 pid: 41,
8919 start_time: 202,
8920 };
8921 let module = module_with_recovery_snapshot(ModuleState::Running, true, 0);
8922 update_snapshot(&module.inner.snapshot, Some("recovery-snapshot"), |state| {
8923 state.pid = Some(successor.pid);
8924 state.process_start_time = Some(successor.start_time);
8925 })
8926 .unwrap();
8927 assert!(!module.record_deliberate_severance(severed).unwrap());
8928
8929 let exit_report = apply_deliberate_severance_marker(
8930 &module.inner.snapshot,
8931 Some(successor),
8932 ExitReport {
8933 kind: ExitKind::Crash,
8934 code: Some(1),
8935 signal: None,
8936 at_ms: 1,
8937 },
8938 );
8939
8940 assert_eq!(exit_report.kind, ExitKind::Crash);
8941 }
8942
8943 #[tokio::test]
8944 async fn drain_reap_marks_deliberate_severance_and_records_lifetime_without_budget() {
8945 let registry = Registry::default();
8946 let supervisor = Supervisor::new(
8947 Arc::new(Registry::default()),
8948 RestartPolicy::new(3, Duration::ZERO),
8949 );
8950 let runtime = supervisor.runtime_config();
8951 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
8952 let spec = ModuleSpec {
8953 module_id: "drain-deliberate-severance".to_string(),
8954 program: fake_aft_stub_path(),
8955 args: Vec::new(),
8956 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
8957 reserved: false,
8958 reserved_prefixes: Vec::new(),
8959 protocol: ModuleProtocol::Subc,
8960 overlap: Default::default(),
8961 };
8962 let mut child = spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap();
8963 let process = ProcessIdentity {
8964 pid: 41,
8965 start_time: 101,
8966 };
8967 child.process_identity = Some(process);
8968 update_snapshot(&snapshot, Some(&spec.module_id), |state| {
8969 state.pid = Some(process.pid);
8970 state.process_start_time = Some(process.start_time);
8971 })
8972 .unwrap();
8973 record_deliberate_severance(&snapshot, process).unwrap();
8974
8975 drain_child_to_state(
8976 &spec.module_id,
8977 spec.protocol,
8978 StopNotice::SentOverConnection,
8981 ®istry,
8982 &snapshot,
8983 &runtime.terminal_ring,
8984 &runtime.spawn_events,
8985 child,
8986 Duration::from_secs(1),
8987 ModuleState::Stopped,
8988 Some(false),
8989 )
8990 .await
8991 .unwrap();
8992
8993 let state = lock_snapshot(&snapshot).unwrap();
8994 assert_eq!(
8995 state.last_exit.as_ref().map(|exit| exit.kind),
8996 Some(ExitKind::DeliberateSeverance)
8997 );
8998 assert_eq!(state.lifetime_restarts, 1);
8999 assert_eq!(state.crash_restarts.len(), 0);
9000 drop(state);
9001 let history = runtime.terminal_ring.lock().unwrap().snapshot();
9002 assert_eq!(
9003 history.entries[0].exit_kind,
9004 subc_control::TerminalExitKind::DeliberateSeverance
9005 );
9006 }
9007
9008 #[tokio::test]
9009 async fn ordinary_drain_reap_does_not_record_a_lifetime_restart() {
9010 let registry = Registry::default();
9011 let supervisor = Supervisor::new(
9012 Arc::new(Registry::default()),
9013 RestartPolicy::new(3, Duration::ZERO),
9014 );
9015 let runtime = supervisor.runtime_config();
9016 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
9017 let spec = ModuleSpec {
9018 module_id: "ordinary-drain".to_string(),
9019 program: fake_aft_stub_path(),
9020 args: Vec::new(),
9021 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
9022 reserved: false,
9023 reserved_prefixes: Vec::new(),
9024 protocol: ModuleProtocol::Subc,
9025 overlap: Default::default(),
9026 };
9027 let child = spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap();
9028
9029 drain_child_to_state(
9030 &spec.module_id,
9031 spec.protocol,
9032 StopNotice::SentOverConnection,
9035 ®istry,
9036 &snapshot,
9037 &runtime.terminal_ring,
9038 &runtime.spawn_events,
9039 child,
9040 Duration::from_secs(1),
9041 ModuleState::Stopped,
9042 Some(false),
9043 )
9044 .await
9045 .unwrap();
9046
9047 let state = lock_snapshot(&snapshot).unwrap();
9048 assert_eq!(
9049 state.last_exit.as_ref().map(|exit| exit.kind),
9050 Some(ExitKind::Crash)
9051 );
9052 assert_eq!(state.lifetime_restarts, 0);
9053 assert_eq!(state.crash_restarts.len(), 0);
9054 }
9055
9056 #[test]
9057 fn fatal_connection_teardown_cannot_arm_a_marker_for_a_surviving_process() {
9058 assert!(!include_str!("server.rs")
9064 .contains("router.record_deliberate_connection_severance(ctx.connection_id)"));
9065 }
9066
9067 #[test]
9074 fn drained_after_quiescence_wait_passes_ok_through_and_forces_false_on_err() {
9075 assert!(drained_after_quiescence_wait(&Ok(true)));
9076 assert!(!drained_after_quiescence_wait(&Ok(false)));
9077 assert!(!drained_after_quiescence_wait(&Err(
9078 SuperviseError::StatePoisoned { module_id: None }
9079 )));
9080 }
9081
9082 #[test]
9091 fn wait_error_exit_report_records_a_failed_terminal_with_no_code_or_signal() {
9092 let ring = Arc::new(Mutex::new(TerminalRing::new(
9093 TerminalRingConfig::default(),
9094 0,
9095 )));
9096 record_wait_error_terminal("wait-error", &ring, &super::SpawnEventFeed::default());
9097
9098 let snapshot = ring.lock().unwrap().snapshot();
9099 assert_eq!(snapshot.entries.len(), 1);
9100 let entry = &snapshot.entries[0];
9101 assert_eq!(entry.exit_code, None);
9102 assert_eq!(entry.exit_signal, None);
9103 assert_eq!(entry.disposition, TerminalDisposition::Failed);
9104 }
9105
9106 #[test]
9107 fn wait_error_exit_path_preserves_spawn_event_density() {
9108 let feed = super::SpawnEventFeed::default();
9109 feed.configure_incarnation("wait-error-density".to_string());
9110 feed.emit_spawned("wait-error", 41, 1);
9111 let ring = Arc::new(Mutex::new(TerminalRing::new(
9112 TerminalRingConfig::default(),
9113 0,
9114 )));
9115
9116 record_wait_error_terminal("wait-error", &ring, &feed);
9117 feed.emit_spawned("after-wait-error", 42, 2);
9118
9119 let state = feed.0.lock().unwrap();
9120 let sequences = state
9121 .events
9122 .iter()
9123 .map(|event| event.cursor.seq)
9124 .collect::<Vec<_>>();
9125 assert_eq!(sequences, vec![1, 2, 3]);
9126 assert_eq!(state.events[1].kind, SpawnEventKind::Exited);
9127 assert_eq!(state.events[1].exit_code, None);
9128 assert_eq!(state.events[1].exit_signal, None);
9129 }
9130
9131 #[test]
9135 fn wait_error_exit_report_is_classified_as_a_crash() {
9136 assert_eq!(wait_error_exit_report().kind, ExitKind::Crash);
9137 }
9138}
9139
9140#[cfg(test)]
9141mod health_evidence_tests {
9142 use super::{HealthProbeError, HealthProbeEvidence};
9143 use std::collections::HashSet;
9144
9145 #[test]
9153 fn only_a_dead_lane_is_proof_of_death() {
9154 assert!(HealthProbeError::lane_dead("gone").is_proof_of_death());
9155 assert!(!HealthProbeError::no_answer("timed out").is_proof_of_death());
9159 assert!(!HealthProbeError::bad_answer("garbage").is_proof_of_death());
9160 assert!(!HealthProbeError::misconfigured("no table").is_proof_of_death());
9161 }
9162
9163 #[test]
9169 fn every_evidence_class_has_a_distinct_label() {
9170 let labels = [
9171 HealthProbeError::lane_dead("").label(),
9172 HealthProbeError::no_answer("").label(),
9173 HealthProbeError::bad_answer("").label(),
9174 HealthProbeError::misconfigured("").label(),
9175 ];
9176 let unique: HashSet<_> = labels.iter().collect();
9177 assert_eq!(unique.len(), labels.len(), "labels collided: {labels:?}");
9178 }
9179
9180 #[test]
9186 fn classification_preserves_the_original_message() {
9187 let err = HealthProbeError::no_answer("module did not answer within 5s");
9188 assert_eq!(err.to_string(), "module did not answer within 5s");
9189 assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
9190 }
9191}
9192
9193#[cfg(test)]
9194mod health_tombstone_tests {
9195 use std::{path::PathBuf, sync::Arc, time::Duration};
9196
9197 use subc_protocol::{
9198 manifest::Concurrency,
9199 session::{HealthStatus, ModuleControlResponse},
9200 };
9201 use tokio::sync::mpsc;
9202
9203 use super::{
9204 probe_module_health, HealthAction, HealthConfig, HealthProbeEvidence, ModuleProtocol,
9205 ModuleSpec, RestartPolicy, Supervisor, SupervisorRuntimeConfig,
9206 };
9207 use crate::{
9208 control::ControlHandler,
9209 forwarding::{ForwardingTable, ModuleControlRpcCompletion, ModuleControlRpcOutcome},
9210 registry::{ConnectionId, Registry},
9211 router::FrameSink,
9212 };
9213
9214 struct ProbeHarness {
9215 spec: ModuleSpec,
9216 runtime: SupervisorRuntimeConfig,
9217 forwarding: Arc<ForwardingTable>,
9218 module_connection: ConnectionId,
9219 module_rx: mpsc::Receiver<crate::router::OutboundFrame>,
9220 handler: ControlHandler,
9221 module: super::SupervisedModule,
9222 }
9223
9224 fn probe_harness() -> ProbeHarness {
9225 let registry = Arc::new(Registry::default());
9226 let forwarding = Arc::new(ForwardingTable::default());
9227 let supervisor_handle = super::SupervisorHandle::new();
9228 let health = HealthConfig {
9229 cadence: Duration::from_secs(30),
9230 deadline: Duration::from_secs(5),
9231 failure_threshold: 3,
9232 on_degraded: HealthAction::Report,
9233 on_failing: HealthAction::Report,
9234 critical: false,
9235 };
9236 let supervisor = Supervisor::new(Arc::clone(®istry), RestartPolicy::default())
9237 .with_forwarding(Arc::clone(&forwarding))
9238 .with_handle(supervisor_handle.clone())
9239 .with_health_config(health);
9240 let spec = ModuleSpec {
9241 module_id: "late-health-module".to_string(),
9242 program: PathBuf::from("disabled-module"),
9243 args: Vec::new(),
9244 env: Vec::new(),
9245 reserved: false,
9246 reserved_prefixes: Vec::new(),
9247 protocol: ModuleProtocol::Subc,
9248 overlap: Default::default(),
9249 };
9250 let module = supervisor
9251 .supervise_configured(spec.clone(), false)
9252 .unwrap();
9253 let runtime = supervisor.runtime_config();
9254 let handler = ControlHandler::with_forwarding(registry, Arc::clone(&forwarding))
9255 .with_supervisor(supervisor_handle);
9256 let module_connection = ConnectionId::new(700);
9257 let (module_tx, module_rx) = mpsc::channel(8);
9258 forwarding
9259 .register_module_connection(
9260 module_connection,
9261 spec.module_id.clone(),
9262 subc_protocol::PROTOCOL_VERSION,
9263 Concurrency::ModuleManaged,
9264 FrameSink::new(module_tx),
9265 )
9266 .unwrap();
9267
9268 ProbeHarness {
9269 spec,
9270 runtime,
9271 forwarding,
9272 module_connection,
9273 module_rx,
9274 handler,
9275 module,
9276 }
9277 }
9278
9279 async fn finish_after(
9280 harness: &mut ProbeHarness,
9281 stall: Duration,
9282 ) -> ModuleControlRpcCompletion {
9283 assert!(stall > harness.runtime.health.deadline);
9284 let deadline = harness.runtime.health.deadline;
9285 let probe = probe_module_health(&harness.spec.module_id, &harness.runtime, None);
9286 let answer = async {
9287 let frame = harness.module_rx.recv().await.expect("health.check frame");
9288 tokio::time::advance(deadline).await;
9289 tokio::task::yield_now().await;
9290 tokio::time::advance(stall - deadline).await;
9291 harness
9292 .forwarding
9293 .complete_module_control_rpc(
9294 harness.module_connection,
9295 frame.header.corr,
9296 Some("health.check"),
9297 ModuleControlRpcOutcome::Response(ModuleControlResponse::HealthCheck {
9298 status: HealthStatus::Ok,
9299 detail: None,
9300 metrics: None,
9301 }),
9302 )
9303 .unwrap()
9304 };
9305 let (probe_result, completion) = tokio::join!(probe, answer);
9306 let err = probe_result.expect_err("probe must miss its deadline");
9307 assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
9308 completion
9309 }
9310
9311 async fn time_out_without_answer(harness: &mut ProbeHarness) {
9312 let deadline = harness.runtime.health.deadline;
9313 let probe = probe_module_health(&harness.spec.module_id, &harness.runtime, None);
9314 let exhaust_deadline = async {
9315 let _frame = harness.module_rx.recv().await.expect("health.check frame");
9316 tokio::time::advance(deadline).await;
9317 tokio::task::yield_now().await;
9318 };
9319 let (probe_result, ()) = tokio::join!(probe, exhaust_deadline);
9320 let err = probe_result.expect_err("probe must miss its deadline");
9321 assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
9322 }
9323
9324 #[tokio::test(start_paused = true)]
9325 async fn late_health_answers_record_start_anchored_latency_for_two_stalls() {
9326 let mut harness = probe_harness();
9327
9328 let first = finish_after(&mut harness, Duration::from_secs(8)).await;
9329 let first_latency = match &first {
9330 ModuleControlRpcCompletion::LateHealthAnswer { latency, .. } => *latency,
9331 other => panic!("late answer was not retained: {other:?}"),
9332 };
9333 assert!(harness.handler.observe_module_control_completion(first));
9334
9335 let second = finish_after(&mut harness, Duration::from_secs(11)).await;
9336 let second_latency = match &second {
9337 ModuleControlRpcCompletion::LateHealthAnswer { latency, .. } => *latency,
9338 other => panic!("late answer was not retained: {other:?}"),
9339 };
9340 assert!(harness.handler.observe_module_control_completion(second));
9341
9342 assert_eq!(first_latency, Duration::from_secs(8));
9343 assert_eq!(
9344 second_latency - first_latency,
9345 Duration::from_secs(3),
9346 "latency must grow linearly with the additional stall"
9347 );
9348 let health = harness.module.status().unwrap().health;
9349 assert_eq!(health.late_answer_count, 2);
9350 assert_eq!(health.last_late_answer_latency_ms, Some(11_000));
9351 }
9352
9353 #[tokio::test(start_paused = true)]
9361 async fn late_answer_clears_the_consecutive_failure_streak() {
9362 let mut harness = probe_harness();
9363
9364 time_out_without_answer(&mut harness).await;
9366 harness
9367 .module
9368 .record_health_probe_failure_for_test("[no-answer] test miss")
9369 .unwrap();
9370 assert_eq!(
9371 harness.module.status().unwrap().health.consecutive_failures,
9372 1,
9373 "precondition: the miss must be on the streak before the late answer"
9374 );
9375
9376 let late = finish_after(&mut harness, Duration::from_secs(9)).await;
9378 assert!(matches!(
9379 late,
9380 ModuleControlRpcCompletion::LateHealthAnswer { .. }
9381 ));
9382 assert!(harness.handler.observe_module_control_completion(late));
9383
9384 let health = harness.module.status().unwrap().health;
9385 assert_eq!(
9386 health.consecutive_failures, 0,
9387 "a late answer is an answer: the streak must reset"
9388 );
9389 assert_eq!(health.late_answer_count, 1);
9390 }
9391
9392 #[tokio::test(start_paused = true)]
9393 async fn repeated_serial_probe_cycles_keep_one_tombstone_per_endpoint() {
9394 let mut harness = probe_harness();
9395
9396 for _ in 0..20 {
9397 time_out_without_answer(&mut harness).await;
9398 assert_eq!(
9399 harness.forwarding.health_probe_tombstone_count().unwrap(),
9400 1
9401 );
9402 }
9403 }
9404}
9405
9406#[cfg(test)]
9407mod child_env_tests {
9408 use super::{
9409 apply_child_env, apply_spawn_role, apply_wire_spawn_args, ModuleProtocol, ModuleSpec,
9410 SpawnRole, SupervisorHandle, SPAWN_ROLE_SWAP_CANDIDATE, SUBC_ARG, SUBC_LAUNCH_NONCE_ENV,
9411 SUBC_MODULE_ID_ENV, SUBC_SPAWN_ROLE_ENV,
9412 };
9413 use std::{ffi::OsStr, path::PathBuf};
9414 use tokio::process::Command;
9415
9416 fn spec(env: Vec<(String, String)>) -> ModuleSpec {
9417 ModuleSpec {
9418 module_id: "env-plan".to_string(),
9419 program: PathBuf::from("/nonexistent"),
9420 args: Vec::new(),
9421 env,
9422 reserved: false,
9423 reserved_prefixes: Vec::new(),
9424 protocol: ModuleProtocol::Subc,
9425 overlap: Default::default(),
9426 }
9427 }
9428
9429 #[test]
9443 fn ambient_ck_log_is_removed_and_a_configured_one_survives() {
9444 let mut command = Command::new("/nonexistent");
9445 apply_child_env(&mut command, &spec(Vec::new()));
9446 let removed = command
9447 .as_std()
9448 .get_envs()
9449 .any(|(key, value)| key == OsStr::new("CK_LOG") && value.is_none());
9450 assert!(
9451 removed,
9452 "ambient CK_LOG must be explicitly removed for an unconfigured module"
9453 );
9454
9455 let mut configured = Command::new("/nonexistent");
9456 apply_child_env(
9457 &mut configured,
9458 &spec(vec![("CK_LOG".to_string(), "debug".to_string())]),
9459 );
9460 let effective = configured
9461 .as_std()
9462 .get_envs()
9463 .filter(|(key, _)| *key == OsStr::new("CK_LOG"))
9464 .last()
9465 .map(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()));
9466 assert_eq!(
9467 effective,
9468 Some(Some("debug".to_string())),
9469 "a module's configured CK_LOG must survive the ambient removal"
9470 );
9471 }
9472
9473 #[test]
9482 fn protocol_none_spawn_carries_no_subc_argument_and_no_nonce() {
9483 let connection_file = std::path::Path::new("/run/subc-connection.json");
9484 let handle = SupervisorHandle::new();
9485
9486 let mut none_spec = spec(Vec::new());
9487 none_spec.protocol = ModuleProtocol::None;
9488 let mut none = Command::new("/nonexistent");
9489 let none_handoff =
9490 apply_wire_spawn_args(&mut none, &none_spec, Some(connection_file), Some(&handle))
9491 .expect("protocol-none spawn args apply");
9492 assert!(
9493 none_handoff.is_none(),
9494 "protocol:none spawn must not receive a nonce descriptor"
9495 );
9496 assert!(
9497 !none.as_std().get_envs().any(|(key, value)| key
9498 == OsStr::new(subc_os::LAUNCH_NONCE_FD_ENV)
9499 && value.is_some()),
9500 "protocol:none spawn must not name a nonce descriptor"
9501 );
9502 let none_args: Vec<String> = none
9503 .as_std()
9504 .get_args()
9505 .map(|a| a.to_string_lossy().into_owned())
9506 .collect();
9507 assert!(
9508 !none_args.iter().any(|a| a == SUBC_ARG),
9509 "protocol:none argv must not carry --subc; got {none_args:?}"
9510 );
9511 let none_has_nonce = none
9512 .as_std()
9513 .get_envs()
9514 .any(|(key, value)| key == OsStr::new(SUBC_LAUNCH_NONCE_ENV) && value.is_some());
9515 assert!(
9516 !none_has_nonce,
9517 "protocol:none spawn must not receive a launch nonce"
9518 );
9519 let none_has_module_id = none
9520 .as_std()
9521 .get_envs()
9522 .any(|(key, value)| key == OsStr::new(SUBC_MODULE_ID_ENV) && value.is_some());
9523 assert!(
9524 none_has_module_id,
9525 "SUBC_MODULE_ID is inert and stays on every path"
9526 );
9527 assert!(
9528 handle.spawn_nonce(&none_spec.module_id).is_none(),
9529 "no nonce record for a process that will never present one"
9530 );
9531
9532 let wire_spec = spec(Vec::new());
9534 let mut wire = Command::new("/nonexistent");
9535 let wire_handoff =
9536 apply_wire_spawn_args(&mut wire, &wire_spec, Some(connection_file), Some(&handle))
9537 .expect("subc-wire spawn args apply");
9538 let wire_fd_env = wire
9539 .as_std()
9540 .get_envs()
9541 .find(|(key, _)| *key == OsStr::new(subc_os::LAUNCH_NONCE_FD_ENV))
9542 .and_then(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()));
9543 #[cfg(unix)]
9544 assert_eq!(
9545 wire_fd_env,
9546 Some(wire_handoff.expect("a descriptor handoff").fd_env_value()),
9547 "a subc-wire spawn names the pipe it will receive at descriptor 3"
9548 );
9549 #[cfg(not(unix))]
9550 assert!(wire_handoff.is_none() && wire_fd_env.is_none());
9551 let wire_args: Vec<String> = wire
9552 .as_std()
9553 .get_args()
9554 .map(|a| a.to_string_lossy().into_owned())
9555 .collect();
9556 assert_eq!(
9557 wire_args,
9558 vec![
9559 SUBC_ARG.to_string(),
9560 connection_file.to_string_lossy().into_owned()
9561 ],
9562 "a subc-wire spawn still carries --subc <path>"
9563 );
9564 assert_eq!(
9565 wire.as_std()
9566 .get_envs()
9567 .any(|(key, value)| key == OsStr::new(SUBC_LAUNCH_NONCE_ENV) && value.is_some()),
9568 !cfg!(unix),
9569 "only Windows supplies the environment nonce"
9570 );
9571 assert!(handle.spawn_nonce(&wire_spec.module_id).is_some());
9572 }
9573
9574 #[test]
9584 fn plain_spawn_removes_the_spawn_role_even_when_the_spec_sets_it() {
9585 let role = |command: &Command| {
9586 command
9587 .as_std()
9588 .get_envs()
9589 .filter(|(key, _)| *key == OsStr::new(SUBC_SPAWN_ROLE_ENV))
9590 .last()
9591 .map(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()))
9592 };
9593 let forged = spec(vec![(
9594 SUBC_SPAWN_ROLE_ENV.to_string(),
9595 SPAWN_ROLE_SWAP_CANDIDATE.to_string(),
9596 )]);
9597
9598 let mut plain = Command::new("/nonexistent");
9599 apply_child_env(&mut plain, &forged);
9600 apply_spawn_role(&mut plain, SpawnRole::Plain);
9601 assert_eq!(
9602 role(&plain),
9603 Some(None),
9604 "a plain spawn must remove SUBC_SPAWN_ROLE, whatever the spec says"
9605 );
9606
9607 let mut candidate = Command::new("/nonexistent");
9608 apply_child_env(&mut candidate, &spec(Vec::new()));
9609 apply_spawn_role(&mut candidate, SpawnRole::SwapCandidate);
9610 assert_eq!(
9611 role(&candidate),
9612 Some(Some(SPAWN_ROLE_SWAP_CANDIDATE.to_string()))
9613 );
9614 }
9615
9616 #[test]
9622 fn daemon_private_capture_keys_are_not_passed_to_the_child() {
9623 let mut command = Command::new("/nonexistent");
9624 apply_child_env(
9625 &mut command,
9626 &spec(vec![
9627 (super::CAPTURE_KEEP_ENV.to_string(), "5".to_string()),
9628 ("KEPT".to_string(), "yes".to_string()),
9629 ]),
9630 );
9631 let keys: Vec<String> = command
9632 .as_std()
9633 .get_envs()
9634 .filter(|(_, value)| value.is_some())
9635 .map(|(key, _)| key.to_string_lossy().into_owned())
9636 .collect();
9637 assert!(keys.contains(&"KEPT".to_string()), "got {keys:?}");
9638 assert!(
9639 !keys.contains(&super::CAPTURE_KEEP_ENV.to_string()),
9640 "daemon-private capture key leaked to the child: {keys:?}"
9641 );
9642 }
9643}
9644
9645#[cfg(test)]
9646mod jitter_tests {
9647 use super::jittered_health_delay;
9648 use std::{collections::HashSet, time::Duration};
9649
9650 const FLEET: [&str; 14] = [
9659 "aft",
9660 "alfonso-core",
9661 "magic-context",
9662 "broca",
9663 "thalamus",
9664 "quota",
9665 "engram",
9666 "plexus",
9667 "cerebellum",
9668 "astrocyte",
9669 "synapse",
9670 "subc-mcp",
9671 "cortexkit-credentials",
9672 "subc-federation",
9673 ];
9674
9675 #[test]
9683 fn probe_delays_disperse_across_the_fleet() {
9684 let cadence = Duration::from_secs(30);
9685 let delays: HashSet<Duration> = FLEET
9686 .iter()
9687 .map(|id| jittered_health_delay(id, 0, cadence))
9688 .collect();
9689 assert_eq!(
9690 delays.len(),
9691 FLEET.len(),
9692 "every supervised module must land on its own probe offset"
9693 );
9694 }
9695
9696 #[test]
9702 fn jitter_only_delays_and_stays_within_one_tenth_of_cadence() {
9703 let cadence = Duration::from_secs(30);
9704 let span = cadence / 10;
9705 for id in FLEET {
9706 for probe_index in 0..8 {
9707 let delay = jittered_health_delay(id, probe_index, cadence);
9708 assert!(
9709 delay >= cadence,
9710 "{id}#{probe_index}: jitter must not shorten the cadence"
9711 );
9712 assert!(
9713 delay < cadence + span,
9714 "{id}#{probe_index}: jitter must stay inside one tenth of the cadence"
9715 );
9716 }
9717 }
9718 }
9719
9720 #[test]
9726 fn a_module_offset_is_stable_across_restarts() {
9727 let cadence = Duration::from_secs(30);
9728 for id in FLEET {
9729 assert_eq!(
9730 jittered_health_delay(id, 0, cadence),
9731 jittered_health_delay(id, 0, cadence),
9732 "{id}: the same module and probe index must produce the same offset"
9733 );
9734 }
9735 }
9736
9737 #[test]
9739 fn zero_cadence_yields_zero_delay() {
9740 assert_eq!(
9741 jittered_health_delay("aft", 0, Duration::ZERO),
9742 Duration::ZERO
9743 );
9744 }
9745}
9746
9747#[cfg(all(test, target_os = "linux"))]
9748mod cgroup_placement_tests {
9749 use super::{
9750 apply_cgroup_placement, remove_module_cgroup, ModuleProtocol, ModuleSpec, SuperviseError,
9751 SupervisedChild,
9752 };
9753 use crate::stderr_tail::{StderrRing, StderrTailConfig};
9754 use std::{
9755 fs, io,
9756 path::{Path, PathBuf},
9757 sync::{Arc, Mutex},
9758 };
9759 use subc_test_support::TestTempDir;
9760 use tokio::process::Command;
9761
9762 #[test]
9763 fn failed_parent_cgroup_open_is_a_cgroup_supervision_error() {
9764 let path = Path::new("/definitely-missing-subc-cgroup");
9765 let mut command = Command::new("true");
9766 let error = apply_cgroup_placement(
9767 &mut command,
9768 &ModuleSpec {
9769 module_id: "broken-cgroup".to_string(),
9770 program: PathBuf::from("true"),
9771 args: Vec::new(),
9772 env: Vec::new(),
9773 reserved: false,
9774 reserved_prefixes: Vec::new(),
9775 protocol: ModuleProtocol::Subc,
9776 overlap: Default::default(),
9777 },
9778 path,
9779 )
9780 .expect_err("a parent cgroup open failure must reject the supervised spawn");
9781 let reason = error.to_string();
9782
9783 assert!(
9784 matches!(error, SuperviseError::Cgroup { .. }),
9785 "parent cgroup open must be reported as a cgroup supervision error: {reason}"
9786 );
9787 assert!(
9788 reason.contains("/definitely-missing-subc-cgroup/cgroup.procs"),
9789 "parent cgroup open failure must name cgroup.procs: {reason}"
9790 );
9791 }
9792
9793 #[tokio::test]
9794 async fn reaping_a_child_removes_its_empty_module_cgroup() {
9795 let root = TestTempDir::new("supervisor-reap-cgroup");
9796 fs::write(root.join("cgroup.procs"), b"").expect("write scratch cgroup marker");
9797 let placement = subc_cgroup::prepare_at(&root)
9798 .expect("prepare scratch cgroup root")
9799 .expect("scratch root has a cgroup.procs marker");
9800 let module_id = "reaped-module";
9801 let module = placement
9802 .module_path(module_id)
9803 .expect("create scratch module cgroup");
9804 let child = Command::new("true")
9805 .spawn()
9806 .expect("spawn short-lived child");
9807 let pid = child.id().expect("spawned child has pid");
9808 let mut child = SupervisedChild {
9809 child,
9810 module_id: module_id.to_string(),
9811 cgroup_placement: Some(placement),
9812 stdout_pump: None,
9813 stderr_pump: None,
9814 stderr_ring: Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default()))),
9815 spawned_at_ms: 0,
9816 spawned_from: PathBuf::from("true"),
9817 spawned_file_identity: None,
9818 process_start_time: None,
9819 process_identity: None,
9820 pid,
9821 roster_guard: None,
9822 };
9823
9824 child.wait().await.expect("reap short-lived child");
9825
9826 assert!(
9827 !module.exists(),
9828 "reaping the supervised child must remove its empty cgroup"
9829 );
9830 }
9831
9832 #[test]
9833 fn non_empty_cgroup_removal_is_reported_without_blocking_teardown() {
9834 let root = TestTempDir::new("supervisor-non-empty-cgroup");
9835 fs::write(root.join("cgroup.procs"), b"").expect("write scratch cgroup marker");
9836 let placement = subc_cgroup::prepare_at(&root)
9837 .expect("prepare scratch cgroup root")
9838 .expect("scratch root has a cgroup.procs marker");
9839 let module = placement
9840 .module_path("surviving-module")
9841 .expect("create scratch module cgroup");
9842 fs::write(module.join("surviving-process"), b"still present")
9843 .expect("make scratch cgroup non-empty");
9844 let (logs, _guard) = crate::router::test_log::log_capture(tracing::Level::WARN);
9845
9846 remove_module_cgroup(&placement, "surviving-module");
9847
9848 let logs = crate::router::test_log::captured_logs(&logs);
9849 assert!(
9850 module.exists(),
9851 "failed removal must leave the cgroup intact"
9852 );
9853 assert!(
9854 logs.contains("could not remove module cgroup after process exit; continuing teardown")
9855 && logs.contains("surviving-module"),
9856 "best-effort removal must report the failure without returning it: {logs}"
9857 );
9858 }
9859
9860 #[test]
9861 fn cgroup_pre_exec_spawn_failure_names_the_cgroup_path() {
9862 let cgroup_path = PathBuf::from("/sys/fs/cgroup/subc-modules/broken-module");
9863 let reason = SuperviseError::Spawn {
9864 program: PathBuf::from("/bin/true"),
9865 source: io::Error::from_raw_os_error(13),
9866 cgroup_path: Some(cgroup_path.clone()),
9867 }
9868 .to_string();
9869
9870 assert!(
9871 reason.contains(&cgroup_path.display().to_string()),
9872 "a pre_exec spawn failure must name the cgroup path: {reason}"
9873 );
9874 }
9875}
9876
9877#[cfg(test)]
9878mod spawn_subscriber_lag_tests {
9879 use super::*;
9880
9881 #[tokio::test]
9886 async fn lagged_spawn_subscriber_receives_a_terminal_lagged_error_after_its_queued_frames() {
9887 let feed = SpawnEventFeed::default();
9888 feed.configure_incarnation("lag-incarnation".to_string());
9889 let (tx, mut rx) = mpsc::channel(1);
9892 feed.subscribe(ConnectionId::new(1), 7, 1, None, FrameSink::new(tx))
9893 .expect("subscribe");
9894 let emitted = SPAWN_SUBSCRIBER_BUFFER + 16;
9895 for index in 0..emitted {
9896 feed.emit_spawned(&format!("lag-module-{index}"), 1000, 0);
9897 tokio::task::yield_now().await;
9900 }
9901 assert_eq!(
9902 feed.subscriber_count(),
9903 0,
9904 "the lagged subscriber must be removed"
9905 );
9906
9907 let mut data = Vec::new();
9908 let mut last = None;
9909 loop {
9910 let next = tokio::time::timeout(Duration::from_secs(5), rx.recv())
9911 .await
9912 .expect("the forwarder must finish once the subscriber is dropped");
9913 let Some(outbound) = next else { break };
9914 let frame = outbound.frame;
9915 if frame.header.ty == FrameType::StreamData {
9916 assert!(last.is_none(), "no data may follow the terminal frame");
9917 let event: SpawnEvent = serde_json::from_slice(&frame.body).unwrap();
9918 data.push(event.cursor.seq);
9919 } else {
9920 assert!(last.is_none(), "exactly one terminal frame");
9921 last = Some(frame);
9922 }
9923 }
9924 assert!(!data.is_empty(), "queued frames drain before the terminal");
9925 for pair in data.windows(2) {
9926 assert_eq!(
9927 pair[1],
9928 pair[0] + 1,
9929 "queued frames arrive dense and in order"
9930 );
9931 }
9932 let terminal = last.expect("a lagged subscriber must receive a terminal frame");
9933 assert_eq!(terminal.header.ty, FrameType::Error);
9934 assert_eq!(terminal.header.corr, 7);
9935 let body: subc_protocol::ErrorBody = serde_json::from_slice(&terminal.body).unwrap();
9936 assert_eq!(body.code, SPAWN_SUBSCRIBER_LAGGED_CODE);
9937 let detail = body.detail.expect("lagged error carries detail");
9938 assert_eq!(
9939 detail["first_undelivered_cursor"]["seq"],
9940 data.last().unwrap() + 1,
9941 "the named cursor is the first event the subscriber did not receive"
9942 );
9943 assert_eq!(
9944 detail["first_undelivered_cursor"]["daemon_incarnation"],
9945 "lag-incarnation"
9946 );
9947 }
9948}
9949
9950#[cfg(test)]
9951mod terminal_history_read_concurrency_tests {
9952 use super::*;
9953 use crate::terminal_journal::read_pause;
9954 use std::sync::mpsc as std_mpsc;
9955 use subc_test_support::TestTempDir;
9956
9957 fn journaled_ring(
9958 journal: &Arc<crate::terminal_journal::TerminalJournal>,
9959 ) -> Arc<Mutex<TerminalRing>> {
9960 Arc::new(Mutex::new(
9961 TerminalRing::new(TerminalRingConfig::default(), 1)
9962 .with_journal(Some(Arc::clone(journal))),
9963 ))
9964 }
9965
9966 fn crash(at_ms: u64) -> ExitReport {
9967 ExitReport {
9968 kind: ExitKind::Crash,
9969 code: Some(1),
9970 signal: None,
9971 at_ms,
9972 }
9973 }
9974
9975 fn record_within(
9978 module_id: &'static str,
9979 ring: &Arc<Mutex<TerminalRing>>,
9980 at_ms: u64,
9981 bound: Duration,
9982 ) -> bool {
9983 let ring = Arc::clone(ring);
9984 let (done, done_rx) = std_mpsc::channel();
9985 std::thread::spawn(move || {
9986 record_terminal(
9987 module_id,
9988 &ring,
9989 &SpawnEventFeed::default(),
9990 &crash(at_ms),
9991 TerminalDisposition::Restarting,
9992 );
9993 let _ = done.send(());
9994 });
9995 done_rx.recv_timeout(bound).is_ok()
9996 }
9997
9998 #[test]
10003 fn exits_recorded_during_a_paused_history_read_are_not_blocked_or_half_merged() {
10004 let dir = TestTempDir::new("terminal-history-concurrent-read");
10005 let path = dir.join("terminals.jsonl");
10006 let journal = Arc::new(crate::terminal_journal::TerminalJournal::open(
10007 path.clone(),
10008 "daemon".into(),
10009 ));
10010 let reader_ring = journaled_ring(&journal);
10011 let other_ring = journaled_ring(&journal);
10012 assert!(record_within(
10013 "reader-module",
10014 &reader_ring,
10015 10,
10016 Duration::from_secs(5)
10017 ));
10018
10019 let (started, release) = read_pause::install(&path);
10020 let reading = {
10021 let ring = Arc::clone(&reader_ring);
10022 std::thread::spawn(move || durable_terminal_history_of(&ring, "reader-module"))
10023 };
10024 started
10025 .recv_timeout(Duration::from_secs(5))
10026 .expect("the history read reached its pause");
10027
10028 let bound = Duration::from_secs(1);
10029 assert!(
10030 record_within("other-module", &other_ring, 20, bound),
10031 "another module's exit waited on a history read (journal writer held)"
10032 );
10033 assert!(
10034 record_within("reader-module", &reader_ring, 30, bound),
10035 "the read module's own exit waited on its history read (ring held)"
10036 );
10037
10038 drop(release);
10039 let paused = reading.join().unwrap();
10040 assert_eq!(
10041 paused.entries.iter().map(|e| e.at_ms).collect::<Vec<_>>(),
10042 vec![10],
10043 "an exit recorded after the read began lands in neither half of it"
10044 );
10045 assert_eq!(paused.journal_skipped_lines, 0);
10046 assert_eq!(paused.journal_read_errors, 0);
10047
10048 let after = durable_terminal_history_of(&reader_ring, "reader-module");
10049 assert_eq!(
10050 after.entries.iter().map(|e| e.at_ms).collect::<Vec<_>>(),
10051 vec![10, 30],
10052 "the next read merges ring and journal with no duplicate"
10053 );
10054 assert_eq!(after.journal_skipped_lines, 0);
10055 }
10056}
10057
10058#[cfg(test)]
10063mod stderr_settle_tests {
10064 use std::{
10065 future::Future,
10066 io,
10067 pin::Pin,
10068 sync::{Arc, Mutex},
10069 task::{Context, Poll},
10070 time::Duration,
10071 };
10072
10073 use tokio::{
10074 io::{AsyncRead, ReadBuf},
10075 sync::oneshot,
10076 time::Instant,
10077 };
10078
10079 use super::{settle_stderr_pump, StderrPump};
10080 use crate::stderr_tail::{
10081 pump_stderr_to, untimed, CaptureState, OutputSink, StderrRing, StderrTailConfig, TailEntry,
10082 };
10083
10084 const BOUND: Duration = Duration::from_millis(250);
10085
10086 struct HeldReader {
10090 before: Option<Vec<u8>>,
10091 gate: Option<oneshot::Receiver<()>>,
10092 after: io::Cursor<Vec<u8>>,
10093 }
10094
10095 impl AsyncRead for HeldReader {
10096 fn poll_read(
10097 mut self: Pin<&mut Self>,
10098 cx: &mut Context<'_>,
10099 buf: &mut ReadBuf<'_>,
10100 ) -> Poll<io::Result<()>> {
10101 if let Some(bytes) = self.before.take() {
10102 buf.put_slice(&bytes);
10103 return Poll::Ready(Ok(()));
10104 }
10105 if let Some(gate) = self.gate.as_mut() {
10106 match Pin::new(gate).poll(cx) {
10107 Poll::Pending => return Poll::Pending,
10108 Poll::Ready(_) => self.gate = None,
10109 }
10110 }
10111 Pin::new(&mut self.after).poll_read(cx, buf)
10112 }
10113 }
10114
10115 struct DiscardSink;
10116
10117 impl OutputSink for DiscardSink {
10118 fn write_line(&mut self, _line: &[u8]) {}
10119 }
10120
10121 fn line(text: &str) -> TailEntry {
10122 TailEntry::Line {
10123 text: text.to_string(),
10124 truncated: false,
10125 at_ms: None,
10126 }
10127 }
10128
10129 fn lock(ring: &Arc<Mutex<StderrRing>>) -> std::sync::MutexGuard<'_, StderrRing> {
10130 ring.lock().unwrap()
10131 }
10132
10133 fn held_pump(
10137 ring: &Arc<Mutex<StderrRing>>,
10138 before: &str,
10139 after: &str,
10140 ) -> (StderrPump, oneshot::Sender<()>) {
10141 let generation = lock(ring).begin_process();
10142 let (release, gate) = oneshot::channel();
10143 let reader = HeldReader {
10144 before: Some(before.as_bytes().to_vec()),
10145 gate: Some(gate),
10146 after: io::Cursor::new(after.as_bytes().to_vec()),
10147 };
10148 let task = tokio::spawn(pump_stderr_to(
10149 reader,
10150 Arc::clone(ring),
10151 generation,
10152 DiscardSink,
10153 ));
10154 (StderrPump { task, generation }, release)
10155 }
10156
10157 async fn wait_until(ring: &Arc<Mutex<StderrRing>>, done: impl Fn(&StderrRing) -> bool) {
10158 for _ in 0..1000 {
10159 if done(&lock(ring)) {
10160 return;
10161 }
10162 tokio::time::sleep(Duration::from_millis(1)).await;
10163 }
10164 panic!(
10165 "ring never reached the expected state: {:?}",
10166 lock(ring).snapshot(None, None)
10167 );
10168 }
10169
10170 #[tokio::test(start_paused = true)]
10171 async fn a_crash_line_the_reader_had_not_reached_by_the_bound_is_kept_before_the_restart() {
10172 let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
10173 let (pump, release) = held_pump(&ring, "booting\n", "config error: missing storage\n");
10174
10175 settle_stderr_pump("crasher", &ring, pump, BOUND).await;
10176 let before_release = lock(&ring).snapshot(None, None);
10177 assert!(
10178 matches!(before_release.capture, CaptureState::Incomplete { .. }),
10179 "a reader that has not reached EOF cannot claim a whole tail: {before_release:?}"
10180 );
10181
10182 let next = lock(&ring).begin_process();
10185 lock(&ring).push_line_from(next, "next process booting");
10186 release.send(()).unwrap();
10187 wait_until(&ring, |ring| {
10188 ring.snapshot(None, None).capture == CaptureState::Captured
10189 })
10190 .await;
10191
10192 assert_eq!(
10193 untimed(lock(&ring).snapshot(None, None).entries),
10194 vec![
10195 line("booting"),
10196 line("config error: missing storage"),
10197 TailEntry::ProcessStart,
10198 line("next process booting"),
10199 ],
10200 "the crash's last line must survive a slow reader and stay in the crashed process's section"
10201 );
10202 }
10203
10204 #[tokio::test(start_paused = true)]
10205 async fn a_pipe_held_open_by_a_descendant_reads_incomplete_without_delaying_the_restart_past_the_bound(
10206 ) {
10207 let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
10208 let (pump, _held) = held_pump(&ring, "parent exiting\n", "");
10211
10212 let started = Instant::now();
10213 settle_stderr_pump("orphaning", &ring, pump, BOUND).await;
10214 assert_eq!(
10215 started.elapsed(),
10216 BOUND,
10217 "the restart must wait exactly the bound for a pipe that stays open, no longer"
10218 );
10219
10220 let next = lock(&ring).begin_process();
10221 lock(&ring).push_line_from(next, "next process booting");
10222 tokio::time::sleep(Duration::from_secs(60)).await;
10223
10224 let snapshot = lock(&ring).snapshot(None, None);
10225 match &snapshot.capture {
10226 CaptureState::Incomplete { reason } => assert!(
10227 reason.contains("had not reached EOF") && reason.contains("250ms"),
10228 "the reason must say what is missing and after how long: {reason}"
10229 ),
10230 other => panic!("expected Incomplete while the pipe is held open, got {other:?}"),
10231 }
10232 assert_eq!(
10233 untimed(snapshot.entries),
10234 vec![
10235 line("parent exiting"),
10236 TailEntry::ProcessStart,
10237 line("next process booting"),
10238 ]
10239 );
10240 }
10241
10242 #[tokio::test(start_paused = true)]
10243 async fn a_reader_that_reaches_eof_within_the_bound_leaves_the_tail_captured() {
10244 let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
10245 let (pump, release) = held_pump(&ring, "one\n", "two\n");
10246 release.send(()).unwrap();
10247
10248 settle_stderr_pump("clean", &ring, pump, BOUND).await;
10249
10250 let snapshot = lock(&ring).snapshot(None, None);
10251 assert_eq!(snapshot.capture, CaptureState::Captured);
10252 assert_eq!(untimed(snapshot.entries), vec![line("one"), line("two")]);
10253 }
10254}
10255
10256#[cfg(all(test, windows))]
10270mod job_containment_tests {
10271 use super::*;
10272 use std::{
10273 path::{Path, PathBuf},
10274 sync::{Arc, Mutex},
10275 time::{Duration, Instant},
10276 };
10277 use subc_test_support::TestTempDir;
10278
10279 fn stub_path() -> PathBuf {
10285 let mut path = std::env::current_exe().expect("current_exe available in tests");
10286 path.pop();
10287 path.pop();
10288 path.push("fake-aft-stub.exe");
10289 assert!(
10290 path.exists(),
10291 "fake-aft-stub not built at {}: run `cargo test -p subc-core` (which builds \
10292 [[bin]] targets) rather than `cargo test -p subc-core --lib` (which does not)",
10293 path.display()
10294 );
10295 path
10296 }
10297
10298 fn read_grandchild_pid(path: &Path) -> u32 {
10300 let deadline = Instant::now() + Duration::from_secs(10);
10301 loop {
10302 if let Ok(contents) = std::fs::read_to_string(path) {
10303 if let Ok(pid) = contents.trim().parse() {
10304 return pid;
10305 }
10306 }
10307 assert!(
10308 Instant::now() < deadline,
10309 "the stub never recorded a grandchild pid at {}",
10310 path.display()
10311 );
10312 std::thread::sleep(Duration::from_millis(10));
10313 }
10314 }
10315
10316 struct Fixture {
10319 _dir: TestTempDir,
10320 module_id: String,
10321 grandchild: u32,
10322 child: Option<SupervisedChild>,
10323 registry: Arc<Registry>,
10324 snapshot: Arc<Mutex<SupervisorSnapshot>>,
10325 terminal_ring: Arc<Mutex<TerminalRing>>,
10326 spawn_events: SpawnEventFeed,
10327 }
10328
10329 fn fixture(label: &str, module_id: &str) -> Fixture {
10330 let dir = TestTempDir::new(label);
10331 let pid_file = dir.join("grandchild.pid");
10332 let supervisor = Supervisor::new(
10333 Arc::new(Registry::default()),
10334 RestartPolicy::new(3, Duration::ZERO),
10335 );
10336 let runtime = supervisor.runtime_config();
10337 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
10338 let spec = ModuleSpec {
10339 module_id: module_id.to_string(),
10340 program: stub_path(),
10341 args: Vec::new(),
10345 env: vec![
10346 ("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string()),
10347 (
10348 "FAKE_AFT_GRANDCHILD_PID_FILE".to_string(),
10349 pid_file.display().to_string(),
10350 ),
10351 ],
10352 reserved: false,
10353 reserved_prefixes: Vec::new(),
10354 protocol: ModuleProtocol::Subc,
10355 overlap: Default::default(),
10356 };
10357 let child = spawn_and_mark_running(&spec, &runtime, &snapshot)
10358 .expect("spawn the supervised fixture");
10359 let grandchild = read_grandchild_pid(&pid_file);
10360 Fixture {
10361 _dir: dir,
10362 module_id: module_id.to_string(),
10363 grandchild,
10364 child: Some(child),
10365 registry: Arc::new(Registry::default()),
10366 snapshot,
10367 terminal_ring: Arc::clone(&runtime.terminal_ring),
10368 spawn_events: SpawnEventFeed::default(),
10369 }
10370 }
10371
10372 impl Fixture {
10373 async fn drain(&mut self) {
10375 let child = self
10376 .child
10377 .take()
10378 .expect("the fixture child is still present");
10379 drain_child_to_state(
10380 &self.module_id,
10381 ModuleProtocol::Subc,
10382 StopNotice::NotSent,
10385 &self.registry,
10386 &self.snapshot,
10387 &self.terminal_ring,
10388 &self.spawn_events,
10389 child,
10390 Duration::from_millis(500),
10391 ModuleState::Stopped,
10392 Some(false),
10393 )
10394 .await
10395 .expect("drain the supervised fixture");
10396 }
10397 }
10398
10399 #[tokio::test]
10405 async fn teardown_reaps_the_grandchild() {
10406 let mut fixture = fixture("teardown-grandchild", "tree-teardown");
10407 let grandchild = fixture.grandchild;
10408
10409 assert!(
10410 subc_jobobject::process_exists(grandchild),
10411 "grandchild {grandchild} must be alive before teardown, or this proves nothing"
10412 );
10413
10414 fixture.drain().await;
10415
10416 assert!(
10417 subc_jobobject::wait_for_process_exit(grandchild, Duration::from_secs(10)),
10418 "grandchild {grandchild} outlived module teardown: the tree was not contained"
10419 );
10420 }
10421
10422 #[test]
10435 fn an_uncontained_grandchild_survives_a_direct_child_kill() {
10436 let dir = TestTempDir::new("teardown-uncontained");
10437 let pid_file = dir.join("grandchild.pid");
10438 let mut child = std::process::Command::new(stub_path())
10439 .env("FAKE_AFT_NEVER_CONNECT", "1")
10440 .env(
10441 "FAKE_AFT_GRANDCHILD_PID_FILE",
10442 pid_file.display().to_string(),
10443 )
10444 .stdin(std::process::Stdio::null())
10445 .stdout(std::process::Stdio::null())
10446 .stderr(std::process::Stdio::null())
10447 .spawn()
10448 .expect("spawn the uncontained fixture");
10449 let grandchild = read_grandchild_pid(&pid_file);
10450
10451 child.kill().expect("kill the direct child");
10453 let _ = child.wait();
10454
10455 assert!(
10456 subc_jobobject::process_exists(grandchild),
10457 "grandchild {grandchild} died with the direct child, so this control no longer \
10458 distinguishes contained from uncontained teardown and the regression test is \
10459 passing vacuously"
10460 );
10461
10462 kill_tree(grandchild);
10465 }
10466
10467 #[tokio::test]
10476 async fn dropping_containment_reaps_the_grandchild() {
10477 let mut fixture = fixture("drop-containment", "tree-drop");
10478 let grandchild = fixture.grandchild;
10479
10480 assert!(subc_jobobject::process_exists(grandchild));
10481
10482 fixture.child.as_mut().expect("child present").job = None;
10484
10485 assert!(
10486 subc_jobobject::wait_for_process_exit(grandchild, Duration::from_secs(10)),
10487 "grandchild {grandchild} survived the containment handle closing, so a daemon \
10488 crash would leave the tree behind"
10489 );
10490 }
10491
10492 fn kill_tree(pid: u32) {
10494 let _ = std::process::Command::new("taskkill.exe")
10495 .args(["/PID", &pid.to_string(), "/T", "/F"])
10496 .stdin(std::process::Stdio::null())
10497 .stdout(std::process::Stdio::null())
10498 .stderr(std::process::Stdio::null())
10499 .status();
10500 assert!(
10501 subc_jobobject::wait_for_process_exit(pid, Duration::from_secs(10)),
10502 "could not clean up grandchild {pid}"
10503 );
10504 }
10505}
10506
10507#[cfg(all(test, unix))]
10511mod launch_nonce_descriptor_tests {
10512 use super::{ChildRoster, ModuleProtocol, ModuleSpec, SupervisorHandle};
10513 use crate::stderr_tail::{StderrRing, StderrTailConfig};
10514 use std::{
10515 path::PathBuf,
10516 sync::{Arc, Mutex},
10517 time::{Duration, Instant},
10518 };
10519 use subc_test_support::TestTempDir;
10520
10521 async fn probe(role: super::SpawnRole) {
10522 let scratch = TestTempDir::new("launch-nonce-descriptor");
10523 let fd_copy = scratch.join("from-descriptor");
10524 let env_copy = scratch.join("environment");
10525 let script = format!(
10526 "env > '{env}.tmp' && mv '{env}.tmp' '{env}'; cat <&3 > '{fd}.tmp' && mv '{fd}.tmp' '{fd}'; sleep 30",
10527 fd = fd_copy.display(), env = env_copy.display(),
10528 );
10529 let xdg = |name: &str| (name.to_string(), scratch.join(name).display().to_string());
10530 let spec = ModuleSpec {
10531 module_id: "nonce-descriptor-probe".to_string(),
10532 program: PathBuf::from("/bin/sh"),
10533 args: vec!["-c".to_string(), script],
10534 env: vec![
10535 xdg("XDG_DATA_HOME"),
10536 xdg("XDG_RUNTIME_DIR"),
10537 xdg("XDG_CONFIG_HOME"),
10538 ("SUBC_LAUNCH_NONCE".to_string(), "must-not-leak".to_string()),
10539 ],
10540 reserved: true,
10541 reserved_prefixes: Vec::new(),
10542 protocol: ModuleProtocol::Subc,
10543 overlap: Default::default(),
10544 };
10545 let handle = SupervisorHandle::new();
10546 let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
10547 let roster = ChildRoster::default();
10548 let child = super::spawn_child_in_slot(
10549 &spec,
10550 None,
10551 Some(&handle),
10552 &ring,
10553 None,
10554 &roster,
10555 #[cfg(target_os = "linux")]
10556 None,
10557 role,
10558 matches!(role, super::SpawnRole::SwapCandidate),
10559 )
10560 .expect("spawn probe");
10561 let deadline = Instant::now() + Duration::from_secs(10);
10562 while !(fd_copy.exists() && env_copy.exists()) {
10563 assert!(Instant::now() < deadline, "probe never wrote its copies");
10564 tokio::time::sleep(Duration::from_millis(20)).await;
10565 }
10566 let nonce = std::fs::read_to_string(fd_copy).unwrap();
10567 assert!(!nonce.is_empty());
10568 let environment = std::fs::read_to_string(env_copy).unwrap();
10569 assert!(environment
10570 .lines()
10571 .any(|line| line.starts_with("SUBC_LAUNCH_NONCE_FD=3:")));
10572 let copy = environment
10573 .lines()
10574 .find_map(|line| line.strip_prefix("SUBC_LAUNCH_NONCE="));
10575 assert_eq!(
10576 copy, None,
10577 "Unix children must never receive the environment nonce"
10578 );
10579 if matches!(role, super::SpawnRole::Plain) {
10580 assert_eq!(
10581 handle.spawn_nonce(&spec.module_id).as_deref(),
10582 Some(nonce.as_str())
10583 );
10584 }
10585 drop(child);
10586 }
10587
10588 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10589 async fn a_spawned_module_receives_its_nonce_only_on_descriptor_3() {
10590 probe(super::SpawnRole::Plain).await;
10591 }
10592
10593 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
10594 async fn swap_candidate_receives_its_nonce_only_on_descriptor_3() {
10595 probe(super::SpawnRole::SwapCandidate).await;
10596 }
10597}