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<(), SuperviseError> {
5650 apply_wire_spawn_args_for_role(
5651 command,
5652 spec,
5653 connection_file_path,
5654 handle,
5655 SpawnRole::Plain,
5656 )
5657}
5658
5659fn apply_wire_spawn_args_for_role(
5668 command: &mut Command,
5669 spec: &ModuleSpec,
5670 connection_file_path: Option<&std::path::Path>,
5671 handle: Option<&SupervisorHandle>,
5672 role: SpawnRole,
5673) -> Result<(), SuperviseError> {
5674 command.env(SUBC_MODULE_ID_ENV, &spec.module_id);
5675 if spec.protocol == ModuleProtocol::None {
5676 return Ok(());
5677 }
5678 if let Some(connection_file_path) = connection_file_path {
5679 command.arg(SUBC_ARG).arg(connection_file_path);
5680 }
5681
5682 let nonce = generate_launch_nonce()?;
5686 if let Some(handle) = handle {
5687 match role {
5688 SpawnRole::Plain => {
5689 handle.set_spawn_nonce(&spec.module_id, nonce.clone());
5690 if spec.reserved {
5691 handle.set_reserved_nonce(&spec.module_id, nonce.clone());
5692 }
5693 }
5694 SpawnRole::SwapCandidate => handle.open_swap(&spec.module_id, nonce.clone()),
5695 }
5696 }
5697 command.env(SUBC_LAUNCH_NONCE_ENV, nonce);
5698 Ok(())
5699}
5700
5701fn apply_child_env(command: &mut Command, spec: &ModuleSpec) {
5702 command.env_remove(CK_LOG_ENV);
5703 command.env_remove(SUBC_SPAWN_ROLE_ENV);
5710 for (key, value) in &spec.env {
5711 if matches!(
5715 key.as_str(),
5716 CAPTURE_MAX_FILE_MB_ENV | CAPTURE_KEEP_ENV | CAPTURE_MAX_AGE_DAYS_ENV
5717 ) || key == SUBC_SPAWN_ROLE_ENV
5718 {
5719 continue;
5720 }
5721 command.env(key, value);
5722 }
5723}
5724
5725#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5728enum SpawnRole {
5729 Plain,
5730 SwapCandidate,
5731}
5732
5733fn apply_spawn_role(command: &mut Command, role: SpawnRole) {
5736 if role == SpawnRole::SwapCandidate {
5737 command.env(SUBC_SPAWN_ROLE_ENV, SPAWN_ROLE_SWAP_CANDIDATE);
5738 }
5739}
5740
5741fn spawn_child(
5742 spec: &ModuleSpec,
5743 connection_file_path: Option<&std::path::Path>,
5744 handle: Option<&SupervisorHandle>,
5745 ring: &Arc<Mutex<StderrRing>>,
5746 capture_logs_dir: Option<&std::path::Path>,
5747 roster: &ChildRoster,
5748 #[cfg(target_os = "linux")] cgroup_placement: Option<&subc_cgroup::Placement>,
5749) -> Result<SupervisedChild, SuperviseError> {
5750 spawn_child_in_slot(
5751 spec,
5752 connection_file_path,
5753 handle,
5754 ring,
5755 capture_logs_dir,
5756 roster,
5757 #[cfg(target_os = "linux")]
5758 cgroup_placement,
5759 SpawnRole::Plain,
5760 false,
5761 )
5762}
5763
5764#[allow(clippy::too_many_arguments)]
5777fn spawn_child_in_slot(
5778 spec: &ModuleSpec,
5779 connection_file_path: Option<&std::path::Path>,
5780 handle: Option<&SupervisorHandle>,
5781 ring: &Arc<Mutex<StderrRing>>,
5782 capture_logs_dir: Option<&std::path::Path>,
5783 roster: &ChildRoster,
5784 #[cfg(target_os = "linux")] cgroup_placement: Option<&subc_cgroup::Placement>,
5785 role: SpawnRole,
5786 alternate_slot: bool,
5787) -> Result<SupervisedChild, SuperviseError> {
5788 if roster.is_closed() {
5789 return Err(SuperviseError::Spawn {
5790 program: spec.program.clone(),
5791 source: io::Error::other("the daemon is shutting down; not starting a new process"),
5792 cgroup_path: None,
5793 });
5794 }
5795 #[cfg(target_os = "linux")]
5796 let cgroup_name = swap::cgroup_name(&spec.module_id, alternate_slot);
5797 #[cfg(not(target_os = "linux"))]
5798 let _ = alternate_slot;
5799 let mut command = Command::new(&spec.program);
5800 command.args(&spec.args);
5801 apply_child_env(&mut command, spec);
5831 apply_spawn_role(&mut command, role);
5832 apply_wire_spawn_args_for_role(&mut command, spec, connection_file_path, handle, role)?;
5833
5834 #[cfg(target_os = "linux")]
5835 let cgroup_path = cgroup_placement
5836 .map(|placement| placement.module_path(&cgroup_name))
5837 .transpose()
5838 .map_err(|source| SuperviseError::Cgroup {
5839 module_id: spec.module_id.clone(),
5840 source,
5841 })?;
5842 #[cfg(not(target_os = "linux"))]
5843 let cgroup_path: Option<PathBuf> = None;
5844 #[cfg(target_os = "linux")]
5845 if let Some(path) = &cgroup_path {
5846 if let Err(error) = apply_cgroup_placement(&mut command, spec, path) {
5847 if let Some(placement) = cgroup_placement {
5848 remove_module_cgroup(placement, &cgroup_name);
5849 }
5850 return Err(error);
5851 }
5852 }
5853
5854 let output_sink = if let Some(logs_dir) = capture_logs_dir {
5855 let path = logs_dir.join(format!("{}.stderr.log", spec.module_id));
5856 match ChildOutputSink::open(&path, capture_retention(spec)) {
5857 Ok(sink) => sink,
5858 Err(error) => {
5859 warn!(
5860 module_id = %spec.module_id,
5861 path = %path.display(),
5862 error = %error,
5863 "could not open child output capture file; forwarding to stderr"
5864 );
5865 ChildOutputSink::Stderr
5866 }
5867 }
5868 } else {
5869 ChildOutputSink::Stderr
5870 };
5871
5872 command.stdout(Stdio::piped());
5873 command.stderr(Stdio::piped());
5874 command.kill_on_drop(true);
5875 #[cfg(unix)]
5892 command.process_group(0);
5893 command.stdin(Stdio::null());
5894
5895 #[cfg(windows)]
5900 subc_jobobject::suspend_on_create_async(&mut command);
5901 let mut child = match command.spawn() {
5902 Ok(child) => child,
5903 Err(source) => {
5904 #[cfg(target_os = "linux")]
5905 if let Some(placement) = cgroup_placement {
5906 remove_module_cgroup(placement, &cgroup_name);
5907 }
5908 return Err(SuperviseError::Spawn {
5909 program: spec.program.clone(),
5910 source,
5911 cgroup_path,
5912 });
5913 }
5914 };
5915
5916 #[cfg(windows)]
5918 let job = contain_spawned_child(&child, spec)?;
5919 let spawned_at_ms = unix_ms_now();
5920 let spawned_from = spec.program.clone();
5921 let spawned_file_identity = spawned_file_identity(&spawned_from);
5922 let pid = child.id().ok_or_else(|| SuperviseError::Spawn {
5923 program: spec.program.clone(),
5924 source: io::Error::other("spawned child exposed no live pid"),
5925 cgroup_path: cgroup_path.clone(),
5926 })?;
5927 let process_start_time = crate::provenance::process_start_time(pid);
5928 let process_identity = process_start_time.map(|start_time| ProcessIdentity { pid, start_time });
5929 #[cfg(target_os = "linux")]
5933 let recorded_cgroup_name = cgroup_path.as_ref().map(|_| cgroup_name.clone());
5934 #[cfg(not(target_os = "linux"))]
5935 let recorded_cgroup_name = None;
5936 let roster_guard = roster.admit(
5937 spec.module_id.clone(),
5938 pid,
5939 spec.protocol,
5940 process_start_time,
5941 crate::child_roster::RecordedIdentity {
5942 start_time: subc_os::start_time(pid),
5943 executable: spawned_file_identity.map(|identity| {
5944 crate::live_children::ExecutableIdentity {
5945 device: identity.device,
5946 inode: identity.inode,
5947 }
5948 }),
5949 cgroup_name: recorded_cgroup_name,
5950 },
5951 );
5952 if roster.is_closed() {
5961 if let Err(error) = child.start_kill() {
5962 debug!(module_id = %spec.module_id, pid, %error, "kill of a process spawned during daemon shutdown failed; it may already have exited");
5963 }
5964 drop(roster_guard);
5965 return Err(SuperviseError::Spawn {
5966 program: spec.program.clone(),
5967 source: io::Error::other(
5968 "the daemon began shutting down while this process was starting; ended it",
5969 ),
5970 cgroup_path,
5971 });
5972 }
5973
5974 let stdout_pump = match child.stdout.take() {
5975 Some(stdout) => Some(tokio::spawn(pump_stdout_to(stdout, output_sink.clone()))),
5976 None => {
5977 warn!(
5978 module_id = %spec.module_id,
5979 "spawned child exposed no stdout pipe; file capture will be incomplete"
5980 );
5981 None
5982 }
5983 };
5984 let stderr_pump = match child.stderr.take() {
5985 Some(stderr) => {
5986 let generation = ring
5987 .lock()
5988 .unwrap_or_else(|poisoned| poisoned.into_inner())
5989 .begin_process();
5990 Some(StderrPump {
5991 task: tokio::spawn(pump_stderr_to(
5992 stderr,
5993 Arc::clone(ring),
5994 generation,
5995 output_sink,
5996 )),
5997 generation,
5998 })
5999 }
6000 None => {
6001 ring.lock()
6005 .unwrap_or_else(|poisoned| poisoned.into_inner())
6006 .mark_not_captured("stderr pipe was not available on spawn");
6007 warn!(
6008 module_id = %spec.module_id,
6009 "spawned child exposed no stderr pipe; tail will be unavailable"
6010 );
6011 None
6012 }
6013 };
6014
6015 Ok(SupervisedChild {
6016 child,
6017 #[cfg(target_os = "linux")]
6018 module_id: cgroup_name,
6019 #[cfg(target_os = "linux")]
6020 cgroup_placement: cgroup_placement.cloned(),
6021 #[cfg(windows)]
6022 job,
6023 stdout_pump,
6024 stderr_pump,
6025 stderr_ring: Arc::clone(ring),
6026 spawned_at_ms,
6027 spawned_from,
6028 spawned_file_identity,
6029 process_start_time,
6030 process_identity,
6031 pid,
6032 roster_guard: Some(roster_guard),
6033 })
6034}
6035
6036#[cfg(windows)]
6050fn contain_spawned_child(
6051 child: &Child,
6052 spec: &ModuleSpec,
6053) -> Result<Option<subc_jobobject::JobObject>, SuperviseError> {
6054 let module_id = spec.module_id.as_str();
6055 let Some(pid) = child.id() else {
6056 warn!(
6059 module_id,
6060 "spawned child had already exited before containment; no job object attached"
6061 );
6062 return Ok(None);
6063 };
6064
6065 let job = match subc_jobobject::JobObject::new() {
6066 Ok(job) => job,
6067 Err(source) => {
6068 warn!(
6069 module_id,
6070 error = %source,
6071 "could not create a job object; this module's helper processes will not be \
6072 reaped on teardown"
6073 );
6074 resume_suspended_child(pid, spec)?;
6077 return Ok(None);
6078 }
6079 };
6080
6081 if let Err(source) = job.assign(child) {
6082 warn!(
6083 module_id,
6084 error = %source,
6085 "could not assign the child to its job object; this module's helper processes \
6086 will not be reaped on teardown"
6087 );
6088 resume_suspended_child(pid, spec)?;
6089 return Ok(None);
6090 }
6091
6092 resume_suspended_child(pid, spec)?;
6093 Ok(Some(job))
6094}
6095
6096#[cfg(windows)]
6101fn resume_suspended_child(pid: u32, spec: &ModuleSpec) -> Result<(), SuperviseError> {
6102 if let Err(source) = subc_jobobject::resume_main_thread(pid) {
6103 let _ = std::process::Command::new("taskkill.exe")
6107 .args(["/PID", &pid.to_string(), "/T", "/F"])
6108 .stdin(Stdio::null())
6109 .stdout(Stdio::null())
6110 .stderr(Stdio::null())
6111 .status();
6112 return Err(SuperviseError::Spawn {
6113 program: spec.program.clone(),
6114 source,
6115 cgroup_path: None,
6116 });
6117 }
6118 Ok(())
6119}
6120
6121#[cfg(target_os = "linux")]
6122fn remove_module_cgroup(placement: &subc_cgroup::Placement, module_id: &str) {
6123 match placement.remove_module(module_id) {
6124 Ok(()) => debug!(module_id, "removed module cgroup after process exit"),
6125 Err(error) => warn!(
6126 module_id,
6127 error = %error,
6128 "could not remove module cgroup after process exit; continuing teardown"
6129 ),
6130 }
6131}
6132
6133#[cfg(target_os = "linux")]
6134fn apply_cgroup_placement(
6135 command: &mut Command,
6136 spec: &ModuleSpec,
6137 path: &std::path::Path,
6138) -> Result<(), SuperviseError> {
6139 subc_cgroup::apply(command, path).map_err(|source| SuperviseError::Cgroup {
6140 module_id: spec.module_id.clone(),
6141 source,
6142 })
6143}
6144
6145fn capture_retention(spec: &ModuleSpec) -> Retention {
6146 let defaults = Retention::default();
6147 let value = |name: &str| {
6148 spec.env
6149 .iter()
6150 .rev()
6151 .find_map(|(key, value)| (key == name).then_some(value.as_str()))
6152 };
6153 Retention {
6154 max_file_mb: value(CAPTURE_MAX_FILE_MB_ENV)
6155 .and_then(|value| value.parse().ok())
6156 .unwrap_or(defaults.max_file_mb),
6157 keep: value(CAPTURE_KEEP_ENV)
6158 .and_then(|value| value.parse().ok())
6159 .unwrap_or(defaults.keep),
6160 max_age_days: value(CAPTURE_MAX_AGE_DAYS_ENV)
6161 .and_then(|value| value.parse().ok())
6162 .unwrap_or(defaults.max_age_days),
6163 }
6164}
6165
6166fn generate_launch_nonce() -> Result<String, SuperviseError> {
6169 let mut bytes = [0u8; 32];
6170 getrandom::getrandom(&mut bytes).map_err(|source| SuperviseError::LaunchNonce {
6171 reason: source.to_string(),
6172 })?;
6173 let mut hex = String::with_capacity(64);
6174 for b in bytes {
6175 use std::fmt::Write;
6176 let _ = write!(hex, "{b:02x}");
6177 }
6178 Ok(hex)
6179}
6180
6181fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
6184 if a.len() != b.len() {
6185 return false;
6186 }
6187 let mut diff = 0u8;
6188 for (x, y) in a.iter().zip(b.iter()) {
6189 diff |= x ^ y;
6190 }
6191 diff == 0
6192}
6193
6194fn spawn_and_mark_running(
6195 spec: &ModuleSpec,
6196 runtime: &SupervisorRuntimeConfig,
6197 snapshot: &SharedSnapshot,
6198) -> Result<SupervisedChild, SuperviseError> {
6199 let child = spawn_child(
6200 spec,
6201 runtime.connection_file_path.as_deref(),
6202 runtime.supervisor_handle.as_ref(),
6203 &runtime.stderr_ring,
6204 runtime.capture_logs_dir.as_deref(),
6205 &runtime.child_roster,
6206 #[cfg(target_os = "linux")]
6207 runtime.cgroup_placement.as_ref(),
6208 )?;
6209 set_running(snapshot, &child, &spec.module_id, &runtime.spawn_events)?;
6210 Ok(child)
6211}
6212
6213enum RegistrationWaitOutcome {
6214 Registered,
6215 Exited(ExitReport),
6216 TimedOut,
6217}
6218
6219struct ReloadRegistrationFailure {
6220 exit_report: ExitReport,
6221 reason: String,
6222}
6223
6224#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6225enum BusyGaugeObservation {
6226 Quiescent,
6227 Busy,
6228 Omitted,
6229}
6230
6231fn busy_gauge_observation(metrics: Option<&Value>, gauges: &[String]) -> BusyGaugeObservation {
6232 let Some(metrics) = metrics.and_then(Value::as_object) else {
6233 return BusyGaugeObservation::Omitted;
6234 };
6235 let mut sum = 0u128;
6236 for gauge in gauges {
6237 let Some(value) = metrics.get(gauge) else {
6238 return BusyGaugeObservation::Omitted;
6239 };
6240 let Some(value) = value.as_u64() else {
6241 return BusyGaugeObservation::Busy;
6242 };
6243 sum = sum.saturating_add(u128::from(value));
6244 }
6245 if sum == 0 {
6246 BusyGaugeObservation::Quiescent
6247 } else {
6248 BusyGaugeObservation::Busy
6249 }
6250}
6251
6252fn declared_busy_gauges(
6253 registry: &Registry,
6254 module_id: &str,
6255) -> Result<Vec<String>, SuperviseError> {
6256 busy_gauges_of(
6257 registry
6258 .get_module(module_id)
6259 .map_err(SuperviseError::Registry)?,
6260 )
6261}
6262
6263fn declared_busy_gauges_for_connection(
6267 registry: &Registry,
6268 connection_id: ConnectionId,
6269) -> Result<Vec<String>, SuperviseError> {
6270 busy_gauges_of(
6271 registry
6272 .get_module_by_connection(connection_id)
6273 .map_err(SuperviseError::Registry)?,
6274 )
6275}
6276
6277fn busy_gauges_of(
6278 registration: Option<crate::registry::ModuleRegistration>,
6279) -> Result<Vec<String>, SuperviseError> {
6280 let Some(registration) = registration else {
6281 return Ok(Vec::new());
6282 };
6283 let Some(self_signals) = registration.manifest.self_signals else {
6284 return Ok(Vec::new());
6285 };
6286
6287 let mut gauges = Vec::new();
6288 for declaration in self_signals {
6289 if declaration.kind != SelfSignalKind::Busy {
6290 continue;
6291 }
6292 match declaration.anchored_to {
6293 SignalAnchor::HealthGauges { gauges: declared } if !declared.is_empty() => {
6294 gauges.extend(declared)
6295 }
6296 _ => {
6297 gauges.push(String::new());
6300 }
6301 }
6302 }
6303 Ok(gauges)
6304}
6305
6306async fn wait_for_forwarding_quiescence(
6311 forwarding: &ForwardingTable,
6312 module_id: &str,
6313 runtime: &SupervisorRuntimeConfig,
6314 endpoint: crate::ModuleEndpointId,
6315 deadline: Instant,
6316 busy_gauges: &[String],
6317 scope: DrainScope,
6318) -> Result<bool, SuperviseError> {
6319 let mut gauges_quiescent = busy_gauges.is_empty();
6320 let mut next_probe_at = Instant::now();
6321 let mut omission_counted = false;
6322
6323 loop {
6324 let now = Instant::now();
6325 if !busy_gauges.is_empty() && now >= next_probe_at && now < deadline {
6326 let report = match scope {
6327 DrainScope::Active => probe_module_health(module_id, runtime, Some(deadline)).await,
6328 DrainScope::Endpoint(endpoint) => {
6329 probe_endpoint_health(endpoint, runtime, Some(deadline)).await
6330 }
6331 };
6332 gauges_quiescent = match report {
6333 Ok(report) => match busy_gauge_observation(report.metrics.as_ref(), busy_gauges) {
6334 BusyGaugeObservation::Quiescent => true,
6335 BusyGaugeObservation::Busy => false,
6336 BusyGaugeObservation::Omitted => {
6337 if !omission_counted {
6338 forwarding
6339 .counters()
6340 .increment_drains_with_undeclared_gauge();
6341 omission_counted = true;
6342 }
6343 false
6344 }
6345 },
6346 Err(err) => {
6347 warn!(
6348 module_id,
6349 error = %err,
6350 "drain health.check did not produce declared busy gauges; treating module as busy"
6351 );
6352 false
6353 }
6354 };
6355 next_probe_at = Instant::now() + runtime.health.cadence.max(REGISTRY_RELEASE_POLL);
6356 }
6357
6358 let in_flight = forwarding
6359 .endpoint_in_flight_count(endpoint)
6360 .map_err(SuperviseError::Forwarding)?;
6361 if in_flight == 0 && gauges_quiescent {
6362 return Ok(true);
6363 }
6364
6365 let now = Instant::now();
6366 if now >= deadline {
6367 return Ok(false);
6368 }
6369 let mut wait = deadline
6370 .saturating_duration_since(now)
6371 .min(REGISTRY_RELEASE_POLL);
6372 if !busy_gauges.is_empty() {
6373 wait = wait.min(next_probe_at.saturating_duration_since(now));
6374 }
6375 sleep(wait).await;
6376 }
6377}
6378
6379fn drained_after_quiescence_wait(wait_result: &Result<bool, SuperviseError>) -> bool {
6387 match wait_result {
6388 Ok(drained) => *drained,
6389 Err(_) => false,
6390 }
6391}
6392
6393fn send_route_goodbyes(forwarding: &ForwardingTable, released_routes: Vec<GoodbyeTarget>) {
6394 for released in released_routes {
6395 let frame = match Frame::build_with_version(
6396 released.negotiated_ver,
6397 FrameType::Goodbye,
6398 control_flags(),
6399 released.channel,
6400 released.epoch,
6401 0,
6402 Vec::new(),
6403 ) {
6404 Ok(frame) => frame,
6405 Err(err) => {
6406 warn!(
6407 route_channel = released.channel,
6408 error = %err,
6409 "failed to build supervisor drain route GOODBYE frame"
6410 );
6411 continue;
6412 }
6413 };
6414 if !released.close_on_delivery_failure() {
6415 crate::forwarding::send_module_route_goodbye(
6416 &forwarding.counters(),
6417 &released.sink,
6418 frame,
6419 released.module_id.as_deref(),
6420 "supervisor drain",
6421 );
6422 continue;
6423 }
6424 if let Err(err) = released.sink.try_send(frame) {
6425 warn!(
6426 target_connection_id = released.connection_id.get(),
6427 route_channel = released.channel,
6428 error = %err,
6429 "supervisor drain route GOODBYE was not delivered to client; closing target connection"
6430 );
6431 let _ = forwarding.escalate_client_delivery_failure(
6432 released.connection_id,
6433 released.channel,
6434 released.epoch,
6435 CloseReason::new(
6436 "route_goodbye_delivery_failed",
6437 format!(
6438 "failed to enqueue supervisor drain route GOODBYE for channel {}: {err}",
6439 released.channel
6440 ),
6441 ),
6442 crate::forwarding::UndeliveredFrame {
6443 module_id: released.module_id.as_deref(),
6444 sink: &released.sink,
6445 },
6446 );
6447 }
6448 }
6449}
6450
6451fn send_module_draining(
6452 module_id: &str,
6453 reason: RouteCloseReason,
6454 deadline_ms: u64,
6455 target: &ModuleDrainTarget,
6456) {
6457 let body = match serde_json::to_vec(&ModuleControlCommand::Draining {
6458 reason,
6459 deadline_ms,
6460 }) {
6461 Ok(body) => body,
6462 Err(err) => {
6463 warn!(
6464 module_id,
6465 error = %err,
6466 "failed to encode module draining command"
6467 );
6468 return;
6469 }
6470 };
6471 let frame = match Frame::build_with_version(
6472 target.negotiated_ver,
6473 FrameType::Push,
6474 control_flags(),
6475 0,
6476 0,
6477 0,
6478 body,
6479 ) {
6480 Ok(frame) => frame,
6481 Err(err) => {
6482 warn!(
6483 module_id,
6484 error = %err,
6485 "failed to build module draining command frame"
6486 );
6487 return;
6488 }
6489 };
6490 if let Err(err) = target.sink.try_send(frame) {
6491 warn!(
6492 module_id,
6493 target_connection_id = target.endpoint.connection_id.get(),
6494 error = %err,
6495 "module draining command was not delivered to peer"
6496 );
6497 }
6498}
6499
6500fn module_goodbye_frame(module_id: &str, negotiated_ver: u8) -> Option<Frame> {
6502 match Frame::build_with_version(
6503 negotiated_ver,
6504 FrameType::Goodbye,
6505 control_flags(),
6506 0,
6507 0,
6508 0,
6509 Vec::new(),
6510 ) {
6511 Ok(frame) => Some(frame),
6512 Err(err) => {
6513 warn!(
6514 module_id,
6515 error = %err,
6516 "failed to build module GOODBYE frame"
6517 );
6518 None
6519 }
6520 }
6521}
6522
6523#[cfg(unix)]
6537async fn send_module_goodbyes_for_daemon_shutdown(
6538 forwarding: &Arc<ForwardingTable>,
6539 reason: &CloseReason,
6540 wait_for_flush: bool,
6541) {
6542 const GOODBYE_BUDGET: Duration = Duration::from_millis(500);
6543 let targets = match forwarding.module_connections() {
6544 Ok(targets) => targets,
6545 Err(err) => {
6546 warn!(error = %err, "could not list module connections for shutdown GOODBYE");
6547 return;
6548 }
6549 };
6550 let deadline = Instant::now() + GOODBYE_BUDGET;
6551 let mut sends = tokio::task::JoinSet::new();
6552 for target in targets {
6553 let Some(frame) = module_goodbye_frame(&target.module_id, target.negotiated_ver) else {
6554 continue;
6555 };
6556 if !wait_for_flush {
6557 if let Err(err) = target.sink.try_send(frame) {
6558 debug!(
6559 module_id = %target.module_id,
6560 error = %err,
6561 "shutdown module GOODBYE was not queued"
6562 );
6563 }
6564 continue;
6565 }
6566 let forwarding = Arc::clone(forwarding);
6567 let reason = reason.clone();
6568 sends.spawn(async move {
6569 match timeout_at(deadline, target.sink.send_flushed(frame)).await {
6570 Ok(Ok(())) => {}
6571 Ok(Err(err)) => debug!(
6572 module_id = %target.module_id,
6573 error = %err,
6574 "module connection closed before its shutdown GOODBYE was written"
6575 ),
6576 Err(_) => warn!(
6577 module_id = %target.module_id,
6578 budget = ?GOODBYE_BUDGET,
6579 "shutdown module GOODBYE was not written within its budget; closing anyway"
6580 ),
6581 }
6582 forwarding.request_connection_close(target.endpoint.connection_id, reason);
6583 });
6584 }
6585 while sends.join_next().await.is_some() {}
6587}
6588
6589fn send_module_goodbye(module_id: &str, forwarding: &ForwardingTable, target: &ModuleDrainTarget) {
6590 let Some(frame) = module_goodbye_frame(module_id, target.negotiated_ver) else {
6591 return;
6592 };
6593 if let Err(err) = target.sink.try_send(frame) {
6594 warn!(
6595 module_id,
6596 target_connection_id = target.endpoint.connection_id.get(),
6597 error = %err,
6598 "supervisor drain module GOODBYE was not delivered to peer; closing module connection"
6599 );
6600 forwarding.request_connection_close(
6601 target.endpoint.connection_id,
6602 CloseReason::new(
6603 "module_goodbye_delivery_failed",
6604 format!("failed to enqueue supervisor drain module GOODBYE for module '{module_id}': {err}"),
6605 ),
6606 );
6607 }
6608}
6609
6610#[derive(Clone, Copy)]
6611struct ForwardingDrainContext<'a> {
6612 spec: &'a ModuleSpec,
6613 runtime: &'a SupervisorRuntimeConfig,
6614 registry: &'a Registry,
6615 scope: DrainScope,
6616}
6617
6618#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6620enum DrainScope {
6621 Active,
6624 Endpoint(crate::ModuleEndpointId),
6629}
6630
6631#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6639enum StopNotice {
6640 SentOverConnection,
6643 NoConnection,
6647 NotSent,
6651}
6652
6653async fn begin_forwarding_drain(
6654 spec: &ModuleSpec,
6655 runtime: &SupervisorRuntimeConfig,
6656 registry: &Registry,
6657 snapshot: &SharedSnapshot,
6658 enabled: Option<bool>,
6659 reason: RouteCloseReason,
6660) -> Result<StopNotice, SuperviseError> {
6661 let Some(forwarding) = runtime.forwarding.as_ref() else {
6662 return Err(SuperviseError::ReloadUnavailable {
6663 module_id: spec.module_id.clone(),
6664 reason: "supervisor was not configured with a forwarding table".to_string(),
6665 });
6666 };
6667
6668 begin_forwarding_drain_with(
6669 forwarding,
6670 ForwardingDrainContext {
6671 spec,
6672 runtime,
6673 registry,
6674 scope: DrainScope::Active,
6675 },
6676 snapshot,
6677 enabled,
6678 reason,
6679 runtime.drain_timeout,
6680 )
6681 .await
6682}
6683
6684async fn begin_forwarding_drain_if_configured(
6685 spec: &ModuleSpec,
6686 runtime: &SupervisorRuntimeConfig,
6687 registry: &Registry,
6688 snapshot: &SharedSnapshot,
6689 enabled: Option<bool>,
6690 reason: RouteCloseReason,
6691) -> Result<StopNotice, SuperviseError> {
6692 begin_forwarding_drain_with_timeout(
6693 spec,
6694 runtime,
6695 registry,
6696 snapshot,
6697 enabled,
6698 reason,
6699 runtime.drain_timeout,
6700 )
6701 .await
6702}
6703
6704async fn begin_forwarding_drain_with_timeout(
6708 spec: &ModuleSpec,
6709 runtime: &SupervisorRuntimeConfig,
6710 registry: &Registry,
6711 snapshot: &SharedSnapshot,
6712 enabled: Option<bool>,
6713 reason: RouteCloseReason,
6714 drain_timeout: Duration,
6715) -> Result<StopNotice, SuperviseError> {
6716 let Some(forwarding) = runtime.forwarding.as_ref() else {
6717 return Ok(StopNotice::NotSent);
6718 };
6719
6720 begin_forwarding_drain_with(
6721 forwarding,
6722 ForwardingDrainContext {
6723 spec,
6724 runtime,
6725 registry,
6726 scope: DrainScope::Active,
6727 },
6728 snapshot,
6729 enabled,
6730 reason,
6731 drain_timeout,
6732 )
6733 .await
6734}
6735
6736async fn begin_forwarding_drain_with(
6737 forwarding: &ForwardingTable,
6738 context: ForwardingDrainContext<'_>,
6739 snapshot: &SharedSnapshot,
6740 enabled: Option<bool>,
6741 reason: RouteCloseReason,
6742 drain_timeout: Duration,
6743) -> Result<StopNotice, SuperviseError> {
6744 let ForwardingDrainContext {
6745 spec,
6746 runtime,
6747 registry,
6748 scope,
6749 } = context;
6750 debug_assert_ne!(reason, RouteCloseReason::Crash);
6751 let terminal = matches!(reason, RouteCloseReason::Disable);
6752 let drain_started_at = Instant::now();
6753 let drain_deadline = drain_started_at + drain_timeout;
6754 let deadline_ms =
6755 unix_ms_now().saturating_add(u64::try_from(drain_timeout.as_millis()).unwrap_or(u64::MAX));
6756 let busy_gauges = match scope {
6757 DrainScope::Active => declared_busy_gauges(registry, &spec.module_id)?,
6758 DrainScope::Endpoint(endpoint) => {
6759 declared_busy_gauges_for_connection(registry, endpoint.connection_id)?
6760 }
6761 };
6762
6763 let gate_started = Instant::now();
6766 let drain_target = match scope {
6767 DrainScope::Active => forwarding.begin_module_drain(&spec.module_id, reason),
6768 DrainScope::Endpoint(endpoint) => forwarding.begin_endpoint_drain(endpoint, reason),
6769 }
6770 .map_err(SuperviseError::Forwarding)?;
6771 info!(
6776 module_id = %spec.module_id,
6777 ?reason,
6778 gate_ms = u64::try_from(gate_started.elapsed().as_millis()).unwrap_or(u64::MAX),
6779 connected = drain_target.is_some(),
6780 "module drain began; route admission closed"
6781 );
6782 if scope == DrainScope::Active {
6783 update_snapshot(snapshot, Some(&spec.module_id), |state| {
6784 state.state = ModuleState::Draining;
6785 state.draining_to_replace =
6786 matches!(reason, RouteCloseReason::Restart | RouteCloseReason::Reload);
6787 if let Some(enabled) = enabled {
6788 state.enabled = enabled;
6789 }
6790 })?;
6791 }
6792
6793 let Some(target) = drain_target.as_ref() else {
6794 return Ok(StopNotice::NoConnection);
6798 };
6799 {
6800 send_module_draining(&spec.module_id, reason, deadline_ms, target);
6801 let routes = forwarding
6802 .endpoint_routes(target.endpoint)
6803 .map_err(SuperviseError::Forwarding)?;
6804 let routes_notified = routes.len();
6805 crate::control::send_route_control_pushes(
6806 forwarding,
6807 routes.clone(),
6808 ClientControlPush::RouteClosing {
6809 module_id: spec.module_id.clone(),
6810 reason,
6811 },
6812 );
6813 send_route_goodbyes(forwarding, target.abandoned_bindings.clone());
6814
6815 let wait_result = wait_for_forwarding_quiescence(
6821 forwarding,
6822 &spec.module_id,
6823 runtime,
6824 target.endpoint,
6825 drain_deadline,
6826 &busy_gauges,
6827 scope,
6828 )
6829 .await;
6830 let drained = drained_after_quiescence_wait(&wait_result);
6831 if let Err(err) = &wait_result {
6832 error!(
6833 module_id = %spec.module_id,
6834 ?reason,
6835 error = %err,
6836 "forwarding quiescence wait failed after route.closing; forcing route.closed(drained: false) so the client is not left waiting on an unfulfilled promise"
6837 );
6838 } else if !drained {
6839 let holdouts = forwarding
6845 .endpoint_drain_holdouts(target.endpoint)
6846 .unwrap_or_default();
6847 warn!(
6848 module_id = %spec.module_id,
6849 waited = ?drain_timeout,
6850 ?reason,
6851 held_requests = holdouts.requests,
6852 held_routes = holdouts.routes,
6853 total_routes = holdouts.total_routes,
6854 top_connections = ?holdouts.top_connections,
6855 held = %holdouts
6858 .held
6859 .iter()
6860 .map(|(channel, corr)| format!("{channel}:{corr}"))
6861 .collect::<Vec<_>>()
6862 .join(","),
6863 "route drain timed out before request quiescence; forcing teardown"
6864 );
6865 }
6866 crate::control::send_route_control_pushes(
6867 forwarding,
6868 routes,
6869 ClientControlPush::RouteClosed {
6870 module_id: spec.module_id.clone(),
6871 reason,
6872 drained,
6873 abandoned: target.abandoned_bindings.len() as u32,
6874 excluded_subscriptions: target.excluded_subscriptions,
6875 terminal: Some(terminal),
6876 },
6877 );
6878 wait_result?;
6879
6880 let released_routes = match forwarding.release_module_endpoint_routes(target.endpoint) {
6886 Ok(routes) => routes,
6887 Err(err) => {
6888 warn!(
6889 module_id = %spec.module_id,
6890 ?reason,
6891 error = %err,
6892 "failed to release module endpoint routes after route.closed; module GOODBYE will still be sent"
6893 );
6894 send_module_goodbye(&spec.module_id, forwarding, target);
6895 return Err(SuperviseError::Forwarding(err));
6896 }
6897 };
6898 let route_goodbye_count = released_routes.len();
6899 send_route_goodbyes(forwarding, released_routes);
6900 send_module_goodbye(&spec.module_id, forwarding, target);
6901
6902 info!(
6908 module_id = %spec.module_id,
6909 ?reason,
6910 routes_notified,
6911 route_goodbyes = route_goodbye_count,
6912 abandoned_reservations = target.abandoned_bindings.len(),
6913 excluded_subscriptions = target.excluded_subscriptions,
6914 drained,
6915 "module drain complete; consumers notified via route.closing/route.closed pushes and per-route GOODBYE frames"
6916 );
6917 }
6918
6919 Ok(StopNotice::SentOverConnection)
6920}
6921
6922async fn wait_for_registration_after_reload(
6925 registry: &Registry,
6926 module_id: &str,
6927 snapshot: &SharedSnapshot,
6928 child: &mut SupervisedChild,
6929 wait: Duration,
6930) -> Result<RegistrationWaitOutcome, SuperviseError> {
6931 wait_for_slot_registration(
6932 registry,
6933 crate::registry::RegistrationSlot::Active(module_id),
6934 module_id,
6935 snapshot,
6936 child,
6937 wait,
6938 )
6939 .await
6940}
6941
6942async fn wait_for_slot_registration(
6950 registry: &Registry,
6951 slot: crate::registry::RegistrationSlot<'_>,
6952 module_id: &str,
6953 snapshot: &SharedSnapshot,
6954 child: &mut SupervisedChild,
6955 wait: Duration,
6956) -> Result<RegistrationWaitOutcome, SuperviseError> {
6957 let deadline = Instant::now() + wait;
6958 loop {
6959 if registry
6960 .registration(slot)
6961 .map_err(SuperviseError::Registry)?
6962 .is_some()
6963 {
6964 return Ok(RegistrationWaitOutcome::Registered);
6965 }
6966
6967 let now = Instant::now();
6968 if now >= deadline {
6969 return Ok(RegistrationWaitOutcome::TimedOut);
6970 }
6971 let remaining = deadline.saturating_duration_since(now);
6972 let poll = remaining.min(REGISTRY_RELEASE_POLL);
6973
6974 tokio::select! {
6975 wait_result = child.wait() => {
6976 let status = wait_result.map_err(|source| SuperviseError::Wait {
6977 module_id: module_id.to_string(),
6978 source,
6979 })?;
6980 return Ok(RegistrationWaitOutcome::Exited(classify_reaped_child_exit(
6981 snapshot,
6982 child,
6983 &status,
6984 )));
6985 }
6986 _ = sleep(poll) => {}
6987 }
6988 }
6989}
6990
6991fn registration_failure_exit_report(mut exit_report: ExitReport) -> ExitReport {
6992 if exit_report.kind != ExitKind::DeliberateSeverance {
6995 exit_report.kind = ExitKind::Crash;
6996 }
6997 exit_report
6998}
6999
7000async fn handle_reload_child_registration_failure(
7001 spec: &ModuleSpec,
7002 runtime: &SupervisorRuntimeConfig,
7003 registry: &Registry,
7004 process_liveness: &SupervisorProcessLiveness,
7005 snapshot: &SharedSnapshot,
7006 child: &mut Option<SupervisedChild>,
7007 failure: ReloadRegistrationFailure,
7008) -> Result<(), SuperviseError> {
7009 let ReloadRegistrationFailure {
7010 exit_report,
7011 reason,
7012 } = failure;
7013 match on_child_exit(
7014 spec,
7015 runtime.restart_policy,
7016 registry,
7017 snapshot,
7018 &runtime.terminal_ring,
7019 &runtime.spawn_events,
7020 &runtime.child_roster,
7021 exit_report,
7022 )
7023 .await
7024 {
7025 NextAction::Stop {
7026 registration_released,
7027 } => {
7028 if registration_released {
7029 process_liveness.untrack_if_current(&spec.module_id, snapshot);
7030 }
7031 }
7032 NextAction::Restart { schedule } => {
7033 let delay = schedule.map_or(runtime.restart_policy.delay_for_restart(0), |schedule| {
7034 schedule.delay
7035 });
7036 if let Some(schedule) = schedule {
7037 log_crash_respawn(&spec.module_id, schedule);
7038 }
7039 sleep(delay).await;
7040 if respawn_still_pending(snapshot) {
7044 if let Err(err) = wait_for_registration_release(
7045 registry,
7046 &spec.module_id,
7047 REGISTRY_RELEASE_TIMEOUT,
7048 )
7049 .await
7050 {
7051 fail_snapshot(snapshot, Some(&spec.module_id), None);
7052 process_liveness.untrack_if_current(&spec.module_id, snapshot);
7053 return Err(SuperviseError::ReloadFailed {
7054 module_id: spec.module_id.clone(),
7055 reason: format!(
7056 "{reason}; registration did not release before policy retry: {err}"
7057 ),
7058 });
7059 }
7060 process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
7061 match spawn_and_mark_running(spec, runtime, snapshot) {
7062 Ok(next_child) => {
7063 *child = Some(next_child);
7064 }
7065 Err(err) => {
7066 fail_snapshot(snapshot, Some(&spec.module_id), None);
7067 process_liveness.untrack_if_current(&spec.module_id, snapshot);
7068 return Err(SuperviseError::ReloadFailed {
7069 module_id: spec.module_id.clone(),
7070 reason: format!("{reason}; policy retry spawn failed: {err}"),
7071 });
7072 }
7073 }
7074 }
7075 }
7076 }
7077
7078 Err(SuperviseError::ReloadFailed {
7079 module_id: spec.module_id.clone(),
7080 reason,
7081 })
7082}
7083
7084async fn handle_reload_spawn_failure(
7085 spec: &ModuleSpec,
7086 runtime: &SupervisorRuntimeConfig,
7087 process_liveness: &SupervisorProcessLiveness,
7088 snapshot: &SharedSnapshot,
7089 child: &mut Option<SupervisedChild>,
7090 reason: String,
7091) -> Result<(), SuperviseError> {
7092 let mut should_retry = false;
7093 let now = Instant::now();
7094 update_snapshot(snapshot, Some(&spec.module_id), |state| {
7095 clear_current_process_facts(state);
7096 if daemon_will_restart(state, &runtime.restart_policy, now) {
7097 state.record_crash_restart(&runtime.restart_policy, now);
7098 state.state = ModuleState::Restarting;
7099 should_retry = true;
7100 } else if state.enabled {
7101 state.state = ModuleState::Failed;
7102 } else {
7103 state.state = ModuleState::Disabled;
7104 }
7105 })?;
7106
7107 if should_retry {
7108 sleep(runtime.restart_policy.backoff).await;
7109 if respawn_still_pending(snapshot) {
7113 process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
7114 match spawn_and_mark_running(spec, runtime, snapshot) {
7115 Ok(next_child) => {
7116 *child = Some(next_child);
7117 }
7118 Err(err) => {
7119 fail_snapshot(snapshot, Some(&spec.module_id), None);
7120 process_liveness.untrack_if_current(&spec.module_id, snapshot);
7121 return Err(SuperviseError::ReloadFailed {
7122 module_id: spec.module_id.clone(),
7123 reason: format!("{reason}; policy retry spawn failed: {err}"),
7124 });
7125 }
7126 }
7127 }
7128 } else {
7129 process_liveness.untrack_if_current(&spec.module_id, snapshot);
7130 }
7131
7132 Err(SuperviseError::ReloadFailed {
7133 module_id: spec.module_id.clone(),
7134 reason,
7135 })
7136}
7137
7138fn control_flags() -> Flags {
7139 Flags::new(false, Priority::Passive, false)
7140}
7141
7142#[allow(clippy::too_many_arguments)]
7143async fn drain_optional_child(
7144 module_id: &str,
7145 protocol: ModuleProtocol,
7146 stop_notice: StopNotice,
7147 registry: &Registry,
7148 snapshot: &SharedSnapshot,
7149 terminal_ring: &Arc<Mutex<TerminalRing>>,
7150 spawn_events: &SpawnEventFeed,
7151 child: &mut Option<SupervisedChild>,
7152 drain_timeout: Duration,
7153 final_state: ModuleState,
7154 enabled: Option<bool>,
7155) -> Result<(), SuperviseError> {
7156 if let Some(child) = child.take() {
7157 drain_child_to_state(
7158 module_id,
7159 protocol,
7160 stop_notice,
7161 registry,
7162 snapshot,
7163 terminal_ring,
7164 spawn_events,
7165 child,
7166 drain_timeout,
7167 final_state,
7168 enabled,
7169 )
7170 .await
7171 } else {
7172 update_snapshot(snapshot, Some(module_id), |state| {
7173 state.state = final_state;
7174 if let Some(enabled) = enabled {
7175 state.enabled = enabled;
7176 }
7177 clear_current_process_facts(state);
7178 })?;
7179 wait_for_registration_release(registry, module_id, REGISTRY_RELEASE_TIMEOUT).await
7180 }
7181}
7182
7183#[allow(clippy::too_many_arguments)]
7184async fn drain_child_to_state(
7185 module_id: &str,
7186 protocol: ModuleProtocol,
7187 stop_notice: StopNotice,
7188 registry: &Registry,
7189 snapshot: &SharedSnapshot,
7190 terminal_ring: &Arc<Mutex<TerminalRing>>,
7191 spawn_events: &SpawnEventFeed,
7192 mut child: SupervisedChild,
7193 drain_timeout: Duration,
7194 final_state: ModuleState,
7195 enabled: Option<bool>,
7196) -> Result<(), SuperviseError> {
7197 update_snapshot(snapshot, Some(module_id), |state| {
7198 state.state = ModuleState::Draining;
7199 state.draining_to_replace = final_state == ModuleState::Restarting;
7200 if let Some(enabled) = enabled {
7201 state.enabled = enabled;
7202 }
7203 })?;
7204
7205 if stop_notice != StopNotice::SentOverConnection {
7216 if protocol == ModuleProtocol::Subc && stop_notice == StopNotice::NoConnection {
7217 info!(
7218 module_id,
7219 pid = child.pid,
7220 budget_ms = u64::try_from(drain_timeout.as_millis()).unwrap_or(u64::MAX),
7221 "module has no connection yet; requesting stop by signal"
7222 );
7223 }
7224 request_graceful_stop(module_id, &child);
7225 }
7226
7227 let exit_report = match timeout(drain_timeout, child.wait()).await {
7228 Ok(Ok(status)) => classify_reaped_child_exit(snapshot, &child, &status),
7229 Ok(Err(source)) => {
7230 fail_snapshot(snapshot, Some(module_id), None);
7231 return Err(SuperviseError::Wait {
7232 module_id: module_id.to_string(),
7233 source,
7234 });
7235 }
7236 Err(_) => {
7237 warn!(
7250 module_id,
7251 pid = child.pid,
7252 budget_ms = u64::try_from(drain_timeout.as_millis()).unwrap_or(u64::MAX),
7253 reason = ?final_state,
7254 ?stop_notice,
7255 "drain budget expired before the module exited; killing it"
7256 );
7257 child.start_kill().map_err(|source| {
7258 fail_snapshot(snapshot, Some(module_id), None);
7259 SuperviseError::Kill {
7260 module_id: module_id.to_string(),
7261 source,
7262 }
7263 })?;
7264 let status = child.wait().await.map_err(|source| {
7265 fail_snapshot(snapshot, Some(module_id), None);
7266 SuperviseError::Wait {
7267 module_id: module_id.to_string(),
7268 source,
7269 }
7270 })?;
7271 classify_reaped_child_exit(snapshot, &child, &status)
7272 }
7273 };
7274
7275 update_snapshot(snapshot, Some(module_id), |state| {
7276 state.state = final_state;
7277 if let Some(enabled) = enabled {
7278 state.enabled = enabled;
7279 }
7280 clear_current_process_facts(state);
7281 state.last_exit = Some(exit_report.clone());
7282 if exit_report.kind == ExitKind::DeliberateSeverance {
7283 state.lifetime_restarts += 1;
7284 }
7285 })?;
7286 record_terminal(
7287 module_id,
7288 terminal_ring,
7289 spawn_events,
7290 &exit_report,
7291 terminal_disposition(final_state),
7292 );
7293 child.drain_stderr(module_id).await;
7294
7295 wait_for_registration_release(registry, module_id, REGISTRY_RELEASE_TIMEOUT).await
7296}
7297
7298#[cfg(unix)]
7318fn request_graceful_stop(module_id: &str, child: &SupervisedChild) {
7319 let Some(pid) = child
7320 .id()
7321 .and_then(|pid| i32::try_from(pid).ok())
7322 .and_then(rustix::process::Pid::from_raw)
7323 else {
7324 debug!(
7325 module_id,
7326 "no pid to signal for teardown; falling through to the drain wait"
7327 );
7328 return;
7329 };
7330 match rustix::process::kill_process(pid, rustix::process::Signal::TERM) {
7331 Ok(()) => debug!(
7332 module_id,
7333 "sent SIGTERM to a module nothing else asked to stop"
7334 ),
7335 Err(err) => debug!(
7336 module_id,
7337 error = %err,
7338 "SIGTERM to module failed; the drain wait and kill still apply"
7339 ),
7340 }
7341}
7342
7343#[cfg(not(unix))]
7351fn request_graceful_stop(module_id: &str, _child: &SupervisedChild) {
7352 debug!(
7353 module_id,
7354 "no graceful stop signal exists on this platform; teardown of a module nothing asked to stop waits, then kills"
7355 );
7356}
7357
7358fn terminal_disposition(final_state: ModuleState) -> TerminalDisposition {
7359 match final_state {
7360 ModuleState::Stopped => TerminalDisposition::Stopped,
7361 ModuleState::Disabled => TerminalDisposition::Disabled,
7362 ModuleState::Restarting => TerminalDisposition::Restarting,
7363 ModuleState::Failed => TerminalDisposition::Failed,
7364 ModuleState::Starting
7365 | ModuleState::Running
7366 | ModuleState::Unresponsive
7367 | ModuleState::Draining => {
7368 unreachable!("terminal exits only finish in terminal or restarting states")
7369 }
7370 }
7371}
7372
7373async fn wait_for_registration_release(
7376 registry: &Registry,
7377 module_id: &str,
7378 wait: Duration,
7379) -> Result<(), SuperviseError> {
7380 wait_for_slot_registration_release(
7381 registry,
7382 crate::registry::RegistrationSlot::Active(module_id),
7383 wait,
7384 )
7385 .await
7386}
7387
7388async fn wait_for_slot_registration_release(
7396 registry: &Registry,
7397 slot: crate::registry::RegistrationSlot<'_>,
7398 wait: Duration,
7399) -> Result<(), SuperviseError> {
7400 let deadline = Instant::now() + wait;
7401 let mut release_events = registration_release_events().subscribe();
7402 let still_active = |registration: &crate::registry::ModuleRegistration| {
7403 SuperviseError::RegistrationStillActive {
7404 module_id: registration.manifest.module_id.clone(),
7405 waited: wait,
7406 }
7407 };
7408 loop {
7409 let _observed_generation = *release_events.borrow_and_update();
7410 let Some(registration) = registry
7411 .registration(slot)
7412 .map_err(SuperviseError::Registry)?
7413 else {
7414 return Ok(());
7415 };
7416
7417 let now = Instant::now();
7418 if now >= deadline {
7419 return Err(still_active(®istration));
7420 }
7421
7422 let remaining = deadline.saturating_duration_since(now);
7423 match timeout(remaining, release_events.changed()).await {
7424 Ok(Ok(())) | Ok(Err(_)) => {}
7425 Err(_) => return Err(still_active(®istration)),
7426 }
7427 }
7428}
7429
7430#[cfg(test)]
7431mod slot_registration_wait_tests {
7432 use super::*;
7433 use crate::registry::{ConnectionId, RegistrationSlot};
7434 use subc_protocol::manifest::ModuleManifest;
7435
7436 const INCUMBENT: u64 = 1;
7437 const CANDIDATE: u64 = 2;
7438
7439 fn swapped_registry() -> Arc<Registry> {
7440 let registry = Arc::new(Registry::default());
7441 let manifest = ModuleManifest::builder("m", "0.1.0").build();
7442 registry
7443 .register_with_control_ops(
7444 manifest.clone(),
7445 1,
7446 ConnectionId::new(INCUMBENT),
7447 Vec::new(),
7448 )
7449 .unwrap();
7450 registry
7451 .register_candidate_with_control_ops(
7452 manifest,
7453 1,
7454 ConnectionId::new(CANDIDATE),
7455 Vec::new(),
7456 )
7457 .unwrap();
7458 registry
7459 }
7460
7461 #[tokio::test]
7465 async fn incumbent_release_is_awaited_by_connection_not_by_module_id() {
7466 let registry = swapped_registry();
7467 registry.promote_candidate("m").unwrap().unwrap();
7468
7469 assert!(matches!(
7470 wait_for_registration_release(®istry, "m", Duration::from_millis(50)).await,
7471 Err(SuperviseError::RegistrationStillActive { .. })
7472 ));
7473
7474 assert!(matches!(
7476 wait_for_slot_registration_release(
7477 ®istry,
7478 RegistrationSlot::Connection(ConnectionId::new(INCUMBENT)),
7479 Duration::from_millis(50),
7480 )
7481 .await,
7482 Err(SuperviseError::RegistrationStillActive { .. })
7483 ));
7484
7485 let releaser = Arc::clone(®istry);
7486 let release = tokio::spawn(async move {
7487 sleep(Duration::from_millis(20)).await;
7488 releaser
7489 .deregister_connection(ConnectionId::new(INCUMBENT))
7490 .unwrap();
7491 notify_registration_release();
7492 });
7493 wait_for_slot_registration_release(
7494 ®istry,
7495 RegistrationSlot::Connection(ConnectionId::new(INCUMBENT)),
7496 Duration::from_secs(5),
7497 )
7498 .await
7499 .expect("the incumbent's own registration is released");
7500 release.await.unwrap();
7501 assert!(registry.get_module("m").unwrap().is_some());
7502 }
7503
7504 #[tokio::test]
7507 async fn candidate_slot_wait_ignores_the_incumbents_registration() {
7508 let registry = swapped_registry();
7509 assert!(matches!(
7510 wait_for_slot_registration_release(
7511 ®istry,
7512 RegistrationSlot::Candidate("m"),
7513 Duration::from_millis(50),
7514 )
7515 .await,
7516 Err(SuperviseError::RegistrationStillActive { .. })
7517 ));
7518 registry
7519 .deregister_connection(ConnectionId::new(CANDIDATE))
7520 .unwrap();
7521 wait_for_slot_registration_release(
7522 ®istry,
7523 RegistrationSlot::Candidate("m"),
7524 Duration::from_millis(50),
7525 )
7526 .await
7527 .expect("a candidate slot with no candidate is released");
7528 assert!(registry
7529 .registration(RegistrationSlot::Active("m"))
7530 .unwrap()
7531 .is_some());
7532 }
7533}
7534
7535fn classify_exit(status: &ExitStatus) -> ExitReport {
7536 ExitReport {
7537 kind: if status.success() {
7538 ExitKind::Clean
7539 } else {
7540 ExitKind::Crash
7541 },
7542 code: status.code(),
7543 signal: exit_signal(status),
7544 at_ms: unix_ms_now(),
7545 }
7546}
7547
7548fn wait_error_exit_report() -> ExitReport {
7554 ExitReport {
7555 kind: ExitKind::Crash,
7556 code: None,
7557 signal: None,
7558 at_ms: unix_ms_now(),
7559 }
7560}
7561
7562#[cfg(unix)]
7563fn exit_signal(status: &ExitStatus) -> Option<i32> {
7564 use std::os::unix::process::ExitStatusExt;
7565
7566 status.signal()
7567}
7568
7569#[cfg(not(unix))]
7570fn exit_signal(_status: &ExitStatus) -> Option<i32> {
7571 None
7572}
7573
7574fn reset_restart_count(snapshot: &SharedSnapshot, module_id: &str) -> Result<(), SuperviseError> {
7580 update_snapshot(snapshot, Some(module_id), |state| {
7581 state.clear_crash_restarts();
7582 })
7583}
7584
7585fn set_running(
7586 snapshot: &SharedSnapshot,
7587 child: &SupervisedChild,
7588 module_id: &str,
7589 spawn_events: &SpawnEventFeed,
7590) -> Result<(), SuperviseError> {
7591 let mut state = snapshot.lock().map_err(|_| SuperviseError::StatePoisoned {
7592 module_id: Some(module_id.to_string()),
7593 })?;
7594 state.spawn_generation = spawn_events.emit_spawned(module_id, child.pid, child.spawned_at_ms);
7595 state.in_alternate_slot = false;
7598 state.configuration_updated_since_spawn = false;
7599 state.state = ModuleState::Running;
7600 state.enabled = true;
7601 state.process_alive = true;
7602 state.pid = child.id();
7603 state.spawned_at_ms = Some(child.spawned_at_ms);
7604 state.spawned_from = Some(child.spawned_from.clone());
7605 state.spawned_file_identity = child.spawned_file_identity;
7606 state.process_start_time = child.process_start_time;
7607 Ok(())
7608}
7609
7610fn clear_current_process_facts(state: &mut SupervisorSnapshot) {
7611 state.process_alive = false;
7612 state.pid = None;
7613 state.spawned_at_ms = None;
7614 state.spawned_from = None;
7615 state.spawned_file_identity = None;
7616 state.process_start_time = None;
7617 state.deliberate_severance = None;
7618}
7619
7620#[cfg(test)]
7621fn record_deliberate_severance(
7622 snapshot: &SharedSnapshot,
7623 identity: ProcessIdentity,
7624) -> Result<(), SuperviseError> {
7625 update_snapshot(snapshot, None, |state| {
7626 state.deliberate_severance = Some(identity);
7627 })
7628}
7629
7630fn apply_deliberate_severance_marker(
7631 snapshot: &SharedSnapshot,
7632 exited_identity: Option<ProcessIdentity>,
7633 mut exit_report: ExitReport,
7634) -> ExitReport {
7635 let marker = lock_snapshot(snapshot)
7636 .ok()
7637 .and_then(|mut state| state.deliberate_severance.take());
7638 if marker.is_some() && marker == exited_identity {
7639 exit_report.kind = ExitKind::DeliberateSeverance;
7640 }
7641 exit_report
7642}
7643
7644fn classify_reaped_child_exit(
7645 snapshot: &SharedSnapshot,
7646 child: &SupervisedChild,
7647 status: &ExitStatus,
7648) -> ExitReport {
7649 apply_deliberate_severance_marker(snapshot, child.process_identity(), classify_exit(status))
7650}
7651
7652fn fail_snapshot(
7653 snapshot: &SharedSnapshot,
7654 module_id: Option<&str>,
7655 last_exit: Option<ExitReport>,
7656) {
7657 if let Err(err) = update_snapshot(snapshot, module_id, |state| {
7658 state.state = ModuleState::Failed;
7659 clear_current_process_facts(state);
7660 if let Some(last_exit) = last_exit {
7661 state.last_exit = Some(last_exit);
7662 }
7663 }) {
7664 error!(error = %err, "failed to mark supervisor state failed");
7665 }
7666}
7667
7668fn update_snapshot(
7669 snapshot: &SharedSnapshot,
7670 module_id: Option<&str>,
7671 update: impl FnOnce(&mut SupervisorSnapshot),
7672) -> Result<(), SuperviseError> {
7673 let mut state = snapshot.lock().map_err(|_| SuperviseError::StatePoisoned {
7674 module_id: module_id.map(ToOwned::to_owned),
7675 })?;
7676 update(&mut state);
7677 Ok(())
7678}
7679
7680const SLOW_SNAPSHOT_LOCK_THRESHOLD: Duration = Duration::from_millis(250);
7681
7682fn lock_snapshot_for_control<'a>(
7683 snapshot: &'a SharedSnapshot,
7684 module_id: &str,
7685 caller: &'static str,
7686) -> Result<std::sync::MutexGuard<'a, SupervisorSnapshot>, SuperviseError> {
7687 let started_at = Instant::now();
7688 let guard = lock_snapshot(snapshot)?;
7689 let waited = started_at.elapsed();
7690 if waited >= SLOW_SNAPSHOT_LOCK_THRESHOLD {
7691 warn!(
7692 module_id = %module_id,
7693 waited_ms = waited.as_millis() as u64,
7694 caller = %caller,
7695 "slow snapshot lock"
7696 );
7697 }
7698 Ok(guard)
7699}
7700
7701fn lock_snapshot(
7702 snapshot: &SharedSnapshot,
7703) -> Result<std::sync::MutexGuard<'_, SupervisorSnapshot>, SuperviseError> {
7704 snapshot
7705 .lock()
7706 .map_err(|_| SuperviseError::StatePoisoned { module_id: None })
7707}
7708
7709#[cfg(test)]
7710mod terminal_history_tests {
7711 use std::{
7712 path::PathBuf,
7713 sync::Arc,
7714 time::{Duration, Instant},
7715 };
7716
7717 use tokio::time::sleep;
7718
7719 use super::{
7720 apply_deliberate_severance_marker, daemon_will_restart, drain_child_to_state,
7721 drained_after_quiescence_wait, handle_reload_spawn_failure, health_restart_child,
7722 lock_snapshot, on_child_exit, record_deliberate_severance, record_wait_error_terminal,
7723 reset_restart_count, spawn_and_mark_running, update_snapshot, wait_error_exit_report,
7724 ExitKind, ExitReport, ModuleProtocol, ModuleSpec, ModuleState, NextAction, ProcessIdentity,
7725 RestartPolicy, SpawnEventKind, StopNotice, SuperviseError, SupervisedModule, Supervisor,
7726 SupervisorHandle, SupervisorHealthStatus, SupervisorSnapshot,
7727 };
7728 use super::Instant as ClockInstant;
7733 use crate::{
7734 registry::Registry,
7735 terminal_ring::{TerminalRing, TerminalRingConfig},
7736 };
7737 use std::sync::Mutex;
7738 use subc_control::TerminalDisposition;
7739
7740 fn fake_aft_stub_path() -> PathBuf {
7745 let mut path = std::env::current_exe().expect("current_exe available in tests");
7746 path.pop();
7747 path.pop();
7748 path.push(if cfg!(windows) {
7749 "fake-aft-stub.exe"
7750 } else {
7751 "fake-aft-stub"
7752 });
7753 assert!(
7754 path.exists(),
7755 "fake-aft-stub not built at {}: run `cargo test -p subc-core` (which builds \
7756 [[bin]] targets) rather than `cargo test -p subc-core --lib` (which does not)",
7757 path.display()
7758 );
7759 path
7760 }
7761
7762 #[test]
7763 fn reserved_never_spawned_refuses_every_hello() {
7764 let supervisor = SupervisorHandle::default();
7769 supervisor.apply_identity_configuration(&ModuleSpec {
7770 module_id: "never-spawned".to_string(),
7771 program: PathBuf::from("/usr/bin/false"),
7772 args: Vec::new(),
7773 env: Vec::new(),
7774 reserved: true,
7775 reserved_prefixes: Vec::new(),
7776 protocol: ModuleProtocol::Subc,
7777 overlap: Default::default(),
7778 });
7779 assert!(
7780 supervisor
7781 .reserved_hello_rejection("never-spawned", Some("any-forged-nonce"))
7782 .is_some(),
7783 "forged nonce must refuse on a reserved never-spawned id"
7784 );
7785 assert!(
7786 supervisor
7787 .reserved_hello_rejection("never-spawned", None)
7788 .is_some(),
7789 "absent nonce must refuse on a reserved never-spawned id"
7790 );
7791 supervisor.set_spawn_nonce("never-spawned", "minted".to_string());
7793 supervisor.apply_identity_configuration(&ModuleSpec {
7794 module_id: "never-spawned".to_string(),
7795 program: PathBuf::from("/usr/bin/false"),
7796 args: Vec::new(),
7797 env: Vec::new(),
7798 reserved: true,
7799 reserved_prefixes: Vec::new(),
7800 protocol: ModuleProtocol::Subc,
7801 overlap: Default::default(),
7802 });
7803 assert!(supervisor
7804 .reserved_hello_rejection("never-spawned", Some("minted"))
7805 .is_none());
7806 assert!(supervisor
7807 .reserved_hello_rejection("never-spawned", Some("forged"))
7808 .is_some());
7809 }
7810
7811 fn seed_crash_restarts(state: &mut SupervisorSnapshot, count: u32) {
7814 let now = ClockInstant::now();
7815 for _ in 0..count {
7816 state.crash_restarts.push_back(now);
7817 }
7818 }
7819
7820 fn age_oldest_crash_restart_out_of_window(state: &mut SupervisorSnapshot, window: Duration) {
7824 let aged = state
7825 .crash_restarts
7826 .front()
7827 .expect("a crash restart must be recorded before it can be aged")
7828 .checked_sub(window + Duration::from_secs(1))
7829 .expect("the test clock is far enough from its origin to age an instant");
7830 state.crash_restarts[0] = aged;
7831 }
7832
7833 fn snapshot_with_restarts(enabled: bool, count: u32) -> SupervisorSnapshot {
7834 let mut state = SupervisorSnapshot::new(ModuleState::Running, enabled);
7835 seed_crash_restarts(&mut state, count);
7836 state
7837 }
7838
7839 #[test]
7840 fn daemon_owned_recovery_predicate_uses_the_pre_increment_budget() {
7841 let policy = RestartPolicy::new(3, Duration::ZERO);
7842 let now = ClockInstant::now();
7843 assert!(daemon_will_restart(
7844 &mut snapshot_with_restarts(true, 2),
7845 &policy,
7846 now
7847 ));
7848 assert!(!daemon_will_restart(
7849 &mut snapshot_with_restarts(true, 3),
7850 &policy,
7851 now
7852 ));
7853 assert!(!daemon_will_restart(
7854 &mut snapshot_with_restarts(false, 0),
7855 &policy,
7856 now
7857 ));
7858 }
7859
7860 #[test]
7861 fn crash_restart_backoff_escalates_with_in_window_count() {
7862 let policy = RestartPolicy::new(4, Duration::from_millis(100))
7863 .with_max_backoff(Duration::from_secs(30));
7864 let now = ClockInstant::now();
7865 let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
7866 let schedules = (0..4)
7867 .map(|_| {
7868 state
7869 .next_crash_restart(&policy, now)
7870 .expect("the test policy allows four crash restarts")
7871 })
7872 .collect::<Vec<_>>();
7873
7874 assert_eq!(
7875 schedules
7876 .iter()
7877 .map(|schedule| schedule.restart_in_window)
7878 .collect::<Vec<_>>(),
7879 vec![0, 1, 2, 3]
7880 );
7881 assert_eq!(
7882 schedules
7883 .iter()
7884 .map(|schedule| schedule.delay)
7885 .collect::<Vec<_>>(),
7886 vec![
7887 Duration::from_millis(100),
7888 Duration::from_secs(1),
7889 Duration::from_secs(10),
7890 Duration::from_secs(30),
7891 ]
7892 );
7893 }
7894
7895 #[test]
7896 fn crash_restart_backoff_resets_after_ring_clear() {
7897 let policy = RestartPolicy::new(3, Duration::from_millis(100));
7898 let now = ClockInstant::now();
7899 let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
7900 assert_eq!(
7901 state.next_crash_restart(&policy, now).unwrap().delay,
7902 Duration::from_millis(100)
7903 );
7904 assert_eq!(
7905 state.next_crash_restart(&policy, now).unwrap().delay,
7906 Duration::from_secs(1)
7907 );
7908
7909 state.clear_crash_restarts();
7910 let schedule = state
7911 .next_crash_restart(&policy, now)
7912 .expect("a cleared ring must allow another restart");
7913 assert_eq!(schedule.restart_in_window, 0);
7914 assert_eq!(schedule.delay, Duration::from_millis(100));
7915 }
7916
7917 #[test]
7918 fn crash_restart_backoff_ignores_aged_restarts() {
7919 let policy = RestartPolicy::new(3, Duration::from_millis(100));
7920 let now = ClockInstant::now();
7921 let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
7922 state
7923 .next_crash_restart(&policy, now)
7924 .expect("the first restart is allowed");
7925 state
7926 .next_crash_restart(&policy, now)
7927 .expect("the second restart is allowed");
7928 state.crash_restarts[0] = now
7929 .checked_sub(policy.window + Duration::from_secs(1))
7930 .expect("the fake clock can age a restart past the window");
7931
7932 let schedule = state
7933 .next_crash_restart(&policy, now)
7934 .expect("an aged restart must release its slot");
7935 assert_eq!(schedule.restart_in_window, 1);
7936 assert_eq!(schedule.delay, Duration::from_secs(1));
7937 assert_eq!(state.crash_restarts.len(), 2);
7938 }
7939
7940 #[test]
7944 fn a_budget_spent_before_the_window_no_longer_refuses() {
7945 let policy = RestartPolicy::new(3, Duration::ZERO);
7946 let mut state = snapshot_with_restarts(true, 3);
7947 let now = ClockInstant::now();
7948 assert!(!daemon_will_restart(&mut state, &policy, now));
7949
7950 assert!(daemon_will_restart(
7951 &mut state,
7952 &policy,
7953 now + policy.window + Duration::from_secs(1)
7954 ));
7955 assert!(
7956 state.crash_restarts.is_empty(),
7957 "reading the budget must drop the instants that left the window"
7958 );
7959 }
7960
7961 fn module_with_recovery_snapshot(
7962 state: ModuleState,
7963 enabled: bool,
7964 restart_count: u32,
7965 ) -> SupervisedModule {
7966 let registry = Arc::new(Registry::default());
7967 let supervisor =
7968 Supervisor::new(Arc::clone(®istry), RestartPolicy::new(3, Duration::ZERO));
7969 let module = supervisor
7970 .spawn(ModuleSpec {
7971 module_id: "recovery-snapshot".to_string(),
7972 program: fake_aft_stub_path(),
7973 args: Vec::new(),
7974 env: Vec::new(),
7975 reserved: false,
7976 reserved_prefixes: Vec::new(),
7977 protocol: ModuleProtocol::Subc,
7978 overlap: Default::default(),
7979 })
7980 .unwrap();
7981 update_snapshot(
7982 &module.inner.snapshot,
7983 Some("recovery-snapshot"),
7984 |snapshot| {
7985 snapshot.state = state;
7986 snapshot.enabled = enabled;
7987 seed_crash_restarts(snapshot, restart_count);
7988 },
7989 )
7990 .unwrap();
7991 module
7992 }
7993
7994 #[cfg(target_os = "linux")]
7995 #[tokio::test]
7996 async fn no_cgroup_placement_does_not_block_fake_aft_stub_spawn() {
7997 let supervisor = Supervisor::new(Arc::new(Registry::default()), RestartPolicy::default())
7998 .with_cgroup_placement(None);
7999 let result = supervisor.spawn(ModuleSpec {
8000 module_id: "no-cgroup-placement".to_string(),
8001 program: fake_aft_stub_path(),
8002 args: Vec::new(),
8003 env: Vec::new(),
8004 reserved: false,
8005 reserved_prefixes: Vec::new(),
8006 protocol: ModuleProtocol::Subc,
8007 overlap: Default::default(),
8008 });
8009
8010 assert!(
8011 result.is_ok(),
8012 "no delegation must not turn an otherwise valid spawn into a failure: {result:?}"
8013 );
8014 }
8015
8016 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
8017 async fn undecided_snapshot_uses_shared_restart_predicate() {
8018 assert!(module_with_recovery_snapshot(ModuleState::Running, true, 2)
8019 .will_recover_after_connection_loss()
8020 .unwrap());
8021 assert!(
8022 !module_with_recovery_snapshot(ModuleState::Running, true, 3)
8023 .will_recover_after_connection_loss()
8024 .unwrap()
8025 );
8026 }
8027
8028 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
8029 async fn restarting_snapshot_at_exhausted_budget_is_non_terminal() {
8030 assert!(
8031 module_with_recovery_snapshot(ModuleState::Restarting, true, 3)
8032 .will_recover_after_connection_loss()
8033 .unwrap()
8034 );
8035 }
8036
8037 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
8038 async fn terminal_phase_snapshots_are_terminal_before_budget_exhaustion() {
8039 assert!(!module_with_recovery_snapshot(ModuleState::Failed, true, 0)
8040 .will_recover_after_connection_loss()
8041 .unwrap());
8042 assert!(
8043 !module_with_recovery_snapshot(ModuleState::Disabled, true, 0)
8044 .will_recover_after_connection_loss()
8045 .unwrap()
8046 );
8047 }
8048
8049 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
8050 async fn warming_snapshot_is_limited_to_startup_phases() {
8051 for state in [
8052 ModuleState::Starting,
8053 ModuleState::Running,
8054 ModuleState::Restarting,
8055 ] {
8056 assert!(
8057 module_with_recovery_snapshot(state, true, 0)
8058 .is_warming()
8059 .unwrap(),
8060 "{state:?} should be warming"
8061 );
8062 }
8063 for state in [
8064 ModuleState::Unresponsive,
8065 ModuleState::Draining,
8066 ModuleState::Stopped,
8067 ModuleState::Failed,
8068 ModuleState::Disabled,
8069 ] {
8070 assert!(
8071 !module_with_recovery_snapshot(state, true, 0)
8072 .is_warming()
8073 .unwrap(),
8074 "{state:?} should not be warming"
8075 );
8076 }
8077 }
8078
8079 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
8080 async fn terminal_history_survives_respawn_and_keeps_both_crashes_in_order() {
8081 let registry = Arc::new(Registry::default());
8082 let supervisor =
8083 Supervisor::new(Arc::clone(®istry), RestartPolicy::new(1, Duration::ZERO));
8084 let module = supervisor
8085 .spawn(ModuleSpec {
8086 module_id: "terminal-history".to_string(),
8087 program: fake_aft_stub_path(),
8088 args: Vec::new(),
8089 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
8090 reserved: false,
8091 reserved_prefixes: Vec::new(),
8092 protocol: ModuleProtocol::Subc,
8093 overlap: Default::default(),
8094 })
8095 .unwrap();
8096
8097 let deadline = Instant::now() + Duration::from_secs(5);
8098 loop {
8099 let history = module.terminal_history();
8100 if history.entries.len() == 2 {
8101 assert_eq!(module.status().unwrap().state, ModuleState::Failed);
8102 assert_eq!(history.dropped, 0);
8103 assert_eq!(
8104 history
8105 .entries
8106 .iter()
8107 .map(|entry| entry.exit_code)
8108 .collect::<Vec<_>>(),
8109 vec![Some(23), Some(23)]
8110 );
8111 assert!(history.entries[0].at_ms <= history.entries[1].at_ms);
8112 return;
8113 }
8114 assert!(
8115 Instant::now() < deadline,
8116 "module did not retain two terminal exits: {history:?}"
8117 );
8118 sleep(Duration::from_millis(10)).await;
8119 }
8120 }
8121
8122 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
8126 async fn disable_during_crash_backoff_cancels_pending_respawn() {
8127 let backoff = Duration::from_secs(2);
8128 let supervisor = Supervisor::new(
8129 Arc::new(Registry::default()),
8130 RestartPolicy::new(10, backoff),
8131 );
8132 let module = supervisor
8133 .spawn(ModuleSpec {
8134 module_id: "disable-during-backoff".to_string(),
8135 program: fake_aft_stub_path(),
8136 args: Vec::new(),
8137 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
8138 reserved: false,
8139 reserved_prefixes: Vec::new(),
8140 protocol: ModuleProtocol::Subc,
8141 overlap: Default::default(),
8142 })
8143 .unwrap();
8144
8145 let deadline = Instant::now() + Duration::from_secs(5);
8147 loop {
8148 if module.status().unwrap().state == ModuleState::Restarting {
8149 break;
8150 }
8151 assert!(
8152 Instant::now() < deadline,
8153 "module never entered the crash backoff"
8154 );
8155 sleep(Duration::from_millis(10)).await;
8156 }
8157
8158 let started = Instant::now();
8159 module.set_enabled(false).await.unwrap();
8160 let waited = started.elapsed();
8161
8162 assert!(
8163 waited < backoff / 2,
8164 "disable waited {waited:?} behind the {backoff:?} crash backoff; the operator command must preempt the pending respawn"
8165 );
8166 assert_eq!(module.status().unwrap().state, ModuleState::Disabled);
8167
8168 sleep(backoff + Duration::from_millis(500)).await;
8170 let status = module.status().unwrap();
8171 assert_eq!(status.state, ModuleState::Disabled);
8172 assert_eq!(
8173 status.spawn_generation, 1,
8174 "module respawned after the operator disabled it"
8175 );
8176 }
8177
8178 #[cfg(unix)]
8181 fn protocol_none_sigterm_exits_clean_spec(
8182 module_id: &str,
8183 dir: &std::path::Path,
8184 ) -> (ModuleSpec, PathBuf, PathBuf) {
8185 let ready = dir.join("ready");
8186 let marker = dir.join("sigterm");
8187 let spec = ModuleSpec {
8188 module_id: module_id.to_string(),
8189 program: fake_aft_stub_path(),
8190 args: Vec::new(),
8191 env: vec![
8192 ("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string()),
8193 (
8194 "FAKE_AFT_SIGTERM_MARKER_PATH".to_string(),
8195 marker.display().to_string(),
8196 ),
8197 (
8198 "FAKE_AFT_NEVER_CONNECT_READY_PATH".to_string(),
8199 ready.display().to_string(),
8200 ),
8201 ],
8202 reserved: false,
8203 reserved_prefixes: Vec::new(),
8204 protocol: ModuleProtocol::None,
8205 overlap: Default::default(),
8206 };
8207 (spec, ready, marker)
8208 }
8209
8210 #[cfg(unix)]
8214 async fn wait_for_file(path: &std::path::Path) {
8215 let deadline = Instant::now() + Duration::from_secs(10);
8216 while !path.exists() {
8217 assert!(
8218 Instant::now() < deadline,
8219 "{} never appeared",
8220 path.display()
8221 );
8222 sleep(Duration::from_millis(10)).await;
8223 }
8224 }
8225
8226 #[cfg(unix)]
8230 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
8231 async fn protocol_none_unrequested_clean_exit_restarts_as_a_crash() {
8232 let dir = subc_test_support::TestTempDir::new("none-unrequested-clean-exit");
8233 let (spec, ready, marker) =
8234 protocol_none_sigterm_exits_clean_spec("none-unrequested-clean-exit", dir.path());
8235 let supervisor = Supervisor::new(
8236 Arc::new(Registry::default()),
8237 RestartPolicy::new(3, Duration::ZERO),
8238 );
8239 let module = supervisor.spawn(spec).unwrap();
8240 wait_for_file(&ready).await;
8241 let first_pid = module
8242 .status()
8243 .unwrap()
8244 .pid
8245 .expect("a running module reports its pid");
8246
8247 rustix::process::kill_process(
8248 rustix::process::Pid::from_raw(i32::try_from(first_pid).unwrap()).unwrap(),
8249 rustix::process::Signal::TERM,
8250 )
8251 .unwrap();
8252
8253 let deadline = Instant::now() + Duration::from_secs(10);
8254 let respawned = loop {
8255 let status = module.status().unwrap();
8256 if status.state == ModuleState::Running
8257 && status.pid.is_some_and(|pid| pid != first_pid)
8258 {
8259 break status;
8260 }
8261 assert!(
8262 Instant::now() < deadline,
8263 "protocol-none module was not respawned after an unrequested clean exit: {status:?}"
8264 );
8265 sleep(Duration::from_millis(10)).await;
8266 };
8267 assert_eq!(respawned.spawn_generation, 2);
8268 assert!(
8269 marker.exists(),
8270 "the child must have exited through its SIGTERM handler (exit 0), or this proves nothing about clean exits"
8271 );
8272
8273 let history = module.terminal_history();
8274 assert_eq!(history.entries.len(), 1, "{history:?}");
8275 let entry = &history.entries[0];
8276 assert_eq!(entry.exit_code, Some(0));
8277 assert_eq!(entry.exit_kind, subc_control::TerminalExitKind::Clean);
8278 assert_eq!(entry.disposition, TerminalDisposition::Restarting);
8279
8280 module.stop().await.unwrap();
8281 }
8282
8283 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
8287 async fn protocol_none_repeated_clean_exits_exhaust_the_restart_budget() {
8288 let supervisor = Supervisor::new(
8289 Arc::new(Registry::default()),
8290 RestartPolicy::new(1, Duration::ZERO),
8291 );
8292 let module = supervisor
8293 .spawn(ModuleSpec {
8294 module_id: "none-clean-exit-budget".to_string(),
8295 program: fake_aft_stub_path(),
8296 args: Vec::new(),
8297 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "0".to_string())],
8298 reserved: false,
8299 reserved_prefixes: Vec::new(),
8300 protocol: ModuleProtocol::None,
8301 overlap: Default::default(),
8302 })
8303 .unwrap();
8304
8305 let deadline = Instant::now() + Duration::from_secs(10);
8306 loop {
8307 let status = module.status().unwrap();
8308 if status.state == ModuleState::Failed {
8309 break;
8310 }
8311 assert!(
8312 Instant::now() < deadline,
8313 "module never exhausted its budget: {status:?} {:?}",
8314 module.terminal_history()
8315 );
8316 sleep(Duration::from_millis(10)).await;
8317 }
8318 let history = module.terminal_history();
8319 assert_eq!(
8320 history
8321 .entries
8322 .iter()
8323 .map(|entry| (entry.exit_code, entry.disposition.clone()))
8324 .collect::<Vec<_>>(),
8325 vec![
8326 (Some(0), TerminalDisposition::Restarting),
8327 (Some(0), TerminalDisposition::Failed),
8328 ]
8329 );
8330 let detail = history.entries[1]
8331 .disposition_detail
8332 .as_deref()
8333 .expect("a budget failure names the budget");
8334 assert!(detail.contains("max_restarts=1"), "{detail}");
8335 assert_eq!(module.status().unwrap().spawn_generation, 2);
8336 }
8337
8338 #[cfg(unix)]
8341 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
8342 async fn protocol_none_requested_stop_and_disable_do_not_respawn() {
8343 for disable in [false, true] {
8344 let label = if disable {
8345 "none-requested-disable"
8346 } else {
8347 "none-requested-stop"
8348 };
8349 let dir = subc_test_support::TestTempDir::new(label);
8350 let (spec, ready, marker) = protocol_none_sigterm_exits_clean_spec(label, dir.path());
8351 let supervisor = Supervisor::new(
8352 Arc::new(Registry::default()),
8353 RestartPolicy::new(3, Duration::ZERO),
8354 );
8355 let module = supervisor.spawn(spec).unwrap();
8356 wait_for_file(&ready).await;
8357
8358 if disable {
8359 module.set_enabled(false).await.unwrap();
8360 } else {
8361 module.stop().await.unwrap();
8362 }
8363 assert!(
8364 marker.exists(),
8365 "{label}: the child must have left through its SIGTERM handler with exit 0"
8366 );
8367
8368 sleep(Duration::from_millis(500)).await;
8371 let status = module.status().unwrap();
8372 let expected = if disable {
8373 ModuleState::Disabled
8374 } else {
8375 ModuleState::Stopped
8376 };
8377 assert_eq!(status.state, expected, "{label}");
8378 assert_eq!(
8379 status.spawn_generation, 1,
8380 "{label}: respawned after a requested stop"
8381 );
8382 let history = module.terminal_history();
8383 assert_eq!(history.entries.len(), 1, "{label}: {history:?}");
8384 assert_eq!(history.entries[0].exit_code, Some(0), "{label}");
8385 assert_ne!(
8386 history.entries[0].disposition,
8387 TerminalDisposition::Restarting,
8388 "{label}"
8389 );
8390 }
8391 }
8392
8393 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
8396 async fn subc_wire_clean_exit_is_still_a_stop() {
8397 let supervisor = Supervisor::new(
8398 Arc::new(Registry::default()),
8399 RestartPolicy::new(3, Duration::ZERO),
8400 );
8401 let module = supervisor
8402 .spawn(ModuleSpec {
8403 module_id: "wire-clean-exit".to_string(),
8404 program: fake_aft_stub_path(),
8405 args: Vec::new(),
8406 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "0".to_string())],
8407 reserved: false,
8408 reserved_prefixes: Vec::new(),
8409 protocol: ModuleProtocol::Subc,
8410 overlap: Default::default(),
8411 })
8412 .unwrap();
8413
8414 let deadline = Instant::now() + Duration::from_secs(10);
8415 while module.terminal_history().entries.is_empty() {
8416 assert!(Instant::now() < deadline, "module never exited");
8417 sleep(Duration::from_millis(10)).await;
8418 }
8419 sleep(Duration::from_millis(500)).await;
8421 let status = module.status().unwrap();
8422 assert_eq!(status.state, ModuleState::Stopped);
8423 assert_eq!(status.spawn_generation, 1);
8424 let history = module.terminal_history();
8425 assert_eq!(history.entries.len(), 1, "{history:?}");
8426 assert_eq!(history.entries[0].exit_code, Some(0));
8427 assert_eq!(history.entries[0].disposition, TerminalDisposition::Stopped);
8428 }
8429
8430 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
8434 async fn every_restart_increment_path_advances_lifetime_count() {
8435 let supervisor = Supervisor::new(
8436 Arc::new(Registry::default()),
8437 RestartPolicy::new(1, Duration::ZERO),
8438 );
8439 let runtime = supervisor.runtime_config();
8440 let spec = ModuleSpec {
8441 module_id: "lifetime-increment-path".to_string(),
8442 program: PathBuf::from("/unused/lifetime-increment-path"),
8443 args: Vec::new(),
8444 env: Vec::new(),
8445 reserved: false,
8446 reserved_prefixes: Vec::new(),
8447 protocol: ModuleProtocol::Subc,
8448 overlap: Default::default(),
8449 };
8450
8451 let crash_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
8452 assert!(matches!(
8453 on_child_exit(
8454 &spec,
8455 runtime.restart_policy,
8456 &supervisor.registry,
8457 &crash_snapshot,
8458 &runtime.terminal_ring,
8459 &runtime.spawn_events,
8460 &runtime.child_roster,
8461 ExitReport {
8462 kind: ExitKind::Crash,
8463 code: Some(1),
8464 signal: None,
8465 at_ms: 1,
8466 },
8467 )
8468 .await,
8469 NextAction::Restart { schedule: _ }
8470 ));
8471 let (crash_restarts, crash_lifetime) = {
8472 let state = lock_snapshot(&crash_snapshot).unwrap();
8473 (state.crash_restarts.len(), state.lifetime_restarts)
8474 };
8475 assert_eq!(crash_restarts, 1);
8476 assert_eq!(crash_lifetime, 1);
8477
8478 let health_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
8479 let mut health_child = None;
8480 assert!(matches!(
8481 health_restart_child(
8482 &spec,
8483 &runtime,
8484 &supervisor.registry,
8485 &supervisor.process_liveness,
8486 &health_snapshot,
8487 &mut health_child,
8488 SupervisorHealthStatus::Failing,
8489 None,
8490 2,
8491 )
8492 .await,
8493 Err(SuperviseError::Spawn { .. })
8494 ));
8495 let (health_restarts, health_lifetime) = {
8496 let state = lock_snapshot(&health_snapshot).unwrap();
8497 (state.crash_restarts.len(), state.lifetime_restarts)
8498 };
8499 assert_eq!(health_restarts, 1);
8500 assert_eq!(health_lifetime, 1);
8501
8502 let reload_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
8503 let mut reload_child = None;
8504 assert!(matches!(
8505 handle_reload_spawn_failure(
8506 &spec,
8507 &runtime,
8508 &supervisor.process_liveness,
8509 &reload_snapshot,
8510 &mut reload_child,
8511 "forced reload spawn failure".to_string(),
8512 )
8513 .await,
8514 Err(SuperviseError::ReloadFailed { .. })
8515 ));
8516 let (reload_restarts, reload_lifetime) = {
8517 let state = lock_snapshot(&reload_snapshot).unwrap();
8518 (state.crash_restarts.len(), state.lifetime_restarts)
8519 };
8520 assert_eq!(reload_restarts, 1);
8521 assert_eq!(reload_lifetime, 1);
8522 }
8523
8524 #[tokio::test]
8525 async fn deliberately_severed_live_child_records_lifetime_without_spending_restart_budget() {
8526 let supervisor = Supervisor::new(
8527 Arc::new(Registry::default()),
8528 RestartPolicy::new(3, Duration::ZERO),
8529 );
8530 let runtime = supervisor.runtime_config();
8531 let spec = ModuleSpec {
8532 module_id: "deliberately-severed".to_string(),
8533 program: PathBuf::from("/unused/deliberately-severed"),
8534 args: Vec::new(),
8535 env: Vec::new(),
8536 reserved: false,
8537 reserved_prefixes: Vec::new(),
8538 protocol: ModuleProtocol::Subc,
8539 overlap: Default::default(),
8540 };
8541 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
8542 let process = ProcessIdentity {
8543 pid: 41,
8544 start_time: 101,
8545 };
8546 record_deliberate_severance(&snapshot, process).unwrap();
8547 let exit_report = apply_deliberate_severance_marker(
8548 &snapshot,
8549 Some(process),
8550 ExitReport {
8551 kind: ExitKind::Crash,
8552 code: Some(1),
8553 signal: None,
8554 at_ms: 1,
8555 },
8556 );
8557 assert_eq!(exit_report.kind, ExitKind::DeliberateSeverance);
8558
8559 assert!(matches!(
8560 on_child_exit(
8561 &spec,
8562 runtime.restart_policy,
8563 &supervisor.registry,
8564 &snapshot,
8565 &runtime.terminal_ring,
8566 &runtime.spawn_events,
8567 &runtime.child_roster,
8568 exit_report,
8569 )
8570 .await,
8571 NextAction::Restart { schedule: _ }
8572 ));
8573 let state = lock_snapshot(&snapshot).unwrap();
8574 assert_eq!(state.lifetime_restarts, 1);
8575 assert_eq!(state.crash_restarts.len(), 0);
8576 }
8577
8578 #[tokio::test]
8579 async fn genuine_crash_spends_restart_budget_and_records_lifetime() {
8580 let supervisor = Supervisor::new(
8581 Arc::new(Registry::default()),
8582 RestartPolicy::new(3, Duration::ZERO),
8583 );
8584 let runtime = supervisor.runtime_config();
8585 let spec = ModuleSpec {
8586 module_id: "genuine-crash".to_string(),
8587 program: PathBuf::from("/unused/genuine-crash"),
8588 args: Vec::new(),
8589 env: Vec::new(),
8590 reserved: false,
8591 reserved_prefixes: Vec::new(),
8592 protocol: ModuleProtocol::Subc,
8593 overlap: Default::default(),
8594 };
8595 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
8596
8597 assert!(matches!(
8598 on_child_exit(
8599 &spec,
8600 runtime.restart_policy,
8601 &supervisor.registry,
8602 &snapshot,
8603 &runtime.terminal_ring,
8604 &runtime.spawn_events,
8605 &runtime.child_roster,
8606 ExitReport {
8607 kind: ExitKind::Crash,
8608 code: Some(1),
8609 signal: None,
8610 at_ms: 1,
8611 },
8612 )
8613 .await,
8614 NextAction::Restart { schedule: _ }
8615 ));
8616 let state = lock_snapshot(&snapshot).unwrap();
8617 assert_eq!(state.lifetime_restarts, 1);
8618 assert_eq!(state.crash_restarts.len(), 1);
8619 }
8620
8621 fn crash_exit_report(at_ms: u64) -> ExitReport {
8622 ExitReport {
8623 kind: ExitKind::Crash,
8624 code: Some(1),
8625 signal: None,
8626 at_ms,
8627 }
8628 }
8629
8630 fn windowed_crash_spec(module_id: &str) -> ModuleSpec {
8631 ModuleSpec {
8632 module_id: module_id.to_string(),
8633 program: PathBuf::from("/unused").join(module_id),
8634 args: Vec::new(),
8635 env: Vec::new(),
8636 reserved: false,
8637 reserved_prefixes: Vec::new(),
8638 protocol: ModuleProtocol::Subc,
8639 overlap: Default::default(),
8640 }
8641 }
8642
8643 #[tokio::test]
8649 async fn three_crashes_inside_the_window_stop_the_module_and_name_the_window() {
8650 let (logs, _guard) = crate::router::test_log::log_capture(tracing::Level::ERROR);
8651 let supervisor = Supervisor::new(
8652 Arc::new(Registry::default()),
8653 RestartPolicy::new(2, Duration::ZERO),
8654 );
8655 let runtime = supervisor.runtime_config();
8656 let spec = windowed_crash_spec("crash-loop-in-window");
8657 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
8658
8659 for attempt in 1..=2 {
8660 assert!(
8661 matches!(
8662 on_child_exit(
8663 &spec,
8664 runtime.restart_policy,
8665 &supervisor.registry,
8666 &snapshot,
8667 &runtime.terminal_ring,
8668 &runtime.spawn_events,
8669 &runtime.child_roster,
8670 crash_exit_report(attempt),
8671 )
8672 .await,
8673 NextAction::Restart { schedule: _ }
8674 ),
8675 "crash {attempt} is inside the budget and must respawn"
8676 );
8677 }
8678
8679 assert!(matches!(
8680 on_child_exit(
8681 &spec,
8682 runtime.restart_policy,
8683 &supervisor.registry,
8684 &snapshot,
8685 &runtime.terminal_ring,
8686 &runtime.spawn_events,
8687 &runtime.child_roster,
8688 crash_exit_report(3),
8689 )
8690 .await,
8691 NextAction::Stop { .. }
8692 ));
8693
8694 {
8695 let state = lock_snapshot(&snapshot).unwrap();
8696 assert_eq!(state.state, ModuleState::Failed);
8697 assert_eq!(state.crash_restarts.len(), 2);
8698 assert_eq!(state.lifetime_restarts, 2);
8699 }
8700
8701 let history = runtime
8702 .terminal_ring
8703 .lock()
8704 .expect("terminal ring is not poisoned")
8705 .snapshot();
8706 let last = history
8707 .entries
8708 .last()
8709 .expect("the refused crash is retained");
8710 assert_eq!(last.disposition, TerminalDisposition::Failed);
8711 assert_eq!(
8712 last.disposition_detail.as_deref(),
8713 Some("crash budget exhausted: max_restarts=2 within window_secs=600")
8714 );
8715
8716 let captured = crate::router::test_log::captured_logs(&logs);
8717 assert!(
8718 captured.contains("crash budget exhausted: max_restarts=2 within window_secs=600"),
8719 "the stop must be logged with its window: {captured}"
8720 );
8721 }
8722
8723 #[tokio::test]
8731 async fn a_crash_older_than_the_window_frees_its_slot_for_a_later_crash() {
8732 let supervisor = Supervisor::new(
8733 Arc::new(Registry::default()),
8734 RestartPolicy::new(2, Duration::ZERO),
8735 );
8736 let runtime = supervisor.runtime_config();
8737 let spec = windowed_crash_spec("crash-across-windows");
8738 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
8739
8740 for attempt in 1..=2 {
8741 assert!(matches!(
8742 on_child_exit(
8743 &spec,
8744 runtime.restart_policy,
8745 &supervisor.registry,
8746 &snapshot,
8747 &runtime.terminal_ring,
8748 &runtime.spawn_events,
8749 &runtime.child_roster,
8750 crash_exit_report(attempt),
8751 )
8752 .await,
8753 NextAction::Restart { schedule: _ }
8754 ));
8755 }
8756
8757 update_snapshot(&snapshot, Some(&spec.module_id), |state| {
8760 age_oldest_crash_restart_out_of_window(state, runtime.restart_policy.window);
8761 })
8762 .unwrap();
8763
8764 assert!(
8765 matches!(
8766 on_child_exit(
8767 &spec,
8768 runtime.restart_policy,
8769 &supervisor.registry,
8770 &snapshot,
8771 &runtime.terminal_ring,
8772 &runtime.spawn_events,
8773 &runtime.child_roster,
8774 crash_exit_report(3),
8775 )
8776 .await,
8777 NextAction::Restart { schedule: _ }
8778 ),
8779 "a crash older than the window must not hold a budget slot"
8780 );
8781
8782 let state = lock_snapshot(&snapshot).unwrap();
8783 assert_eq!(state.state, ModuleState::Restarting);
8784 assert_eq!(
8785 state.crash_restarts.len(),
8786 2,
8787 "the aged instant is dropped and the new one takes its place"
8788 );
8789 assert_eq!(
8790 state.lifetime_restarts, 3,
8791 "the ledger counts every restart, including the ones the window forgot"
8792 );
8793 }
8794
8795 #[tokio::test]
8800 async fn an_operator_restart_clears_the_ring_and_leaves_the_ledger_alone() {
8801 let supervisor = Supervisor::new(
8802 Arc::new(Registry::default()),
8803 RestartPolicy::new(2, Duration::ZERO),
8804 );
8805 let runtime = supervisor.runtime_config();
8806 let spec = windowed_crash_spec("operator-cleared-budget");
8807 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
8808
8809 for attempt in 1..=2 {
8810 assert!(matches!(
8811 on_child_exit(
8812 &spec,
8813 runtime.restart_policy,
8814 &supervisor.registry,
8815 &snapshot,
8816 &runtime.terminal_ring,
8817 &runtime.spawn_events,
8818 &runtime.child_roster,
8819 crash_exit_report(attempt),
8820 )
8821 .await,
8822 NextAction::Restart { schedule: _ }
8823 ));
8824 }
8825
8826 reset_restart_count(&snapshot, &spec.module_id).unwrap();
8827 {
8828 let state = lock_snapshot(&snapshot).unwrap();
8829 assert!(
8830 state.crash_restarts.is_empty(),
8831 "an operator restart returns the full budget"
8832 );
8833 assert_eq!(
8834 state.lifetime_restarts, 2,
8835 "clearing the budget must not unmake the crashes"
8836 );
8837 }
8838
8839 assert!(
8840 matches!(
8841 on_child_exit(
8842 &spec,
8843 runtime.restart_policy,
8844 &supervisor.registry,
8845 &snapshot,
8846 &runtime.terminal_ring,
8847 &runtime.spawn_events,
8848 &runtime.child_roster,
8849 crash_exit_report(3),
8850 )
8851 .await,
8852 NextAction::Restart { schedule: _ }
8853 ),
8854 "the cleared budget must be spendable again"
8855 );
8856 let state = lock_snapshot(&snapshot).unwrap();
8857 assert_eq!(state.crash_restarts.len(), 1);
8858 assert_eq!(state.lifetime_restarts, 3);
8859 }
8860
8861 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
8862 async fn severance_marker_for_a_dead_child_does_not_label_its_successor() {
8863 let severed = ProcessIdentity {
8864 pid: 41,
8865 start_time: 101,
8866 };
8867 let successor = ProcessIdentity {
8868 pid: 41,
8869 start_time: 202,
8870 };
8871 let module = module_with_recovery_snapshot(ModuleState::Running, true, 0);
8872 update_snapshot(&module.inner.snapshot, Some("recovery-snapshot"), |state| {
8873 state.pid = Some(successor.pid);
8874 state.process_start_time = Some(successor.start_time);
8875 })
8876 .unwrap();
8877 assert!(!module.record_deliberate_severance(severed).unwrap());
8878
8879 let exit_report = apply_deliberate_severance_marker(
8880 &module.inner.snapshot,
8881 Some(successor),
8882 ExitReport {
8883 kind: ExitKind::Crash,
8884 code: Some(1),
8885 signal: None,
8886 at_ms: 1,
8887 },
8888 );
8889
8890 assert_eq!(exit_report.kind, ExitKind::Crash);
8891 }
8892
8893 #[tokio::test]
8894 async fn drain_reap_marks_deliberate_severance_and_records_lifetime_without_budget() {
8895 let registry = Registry::default();
8896 let supervisor = Supervisor::new(
8897 Arc::new(Registry::default()),
8898 RestartPolicy::new(3, Duration::ZERO),
8899 );
8900 let runtime = supervisor.runtime_config();
8901 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
8902 let spec = ModuleSpec {
8903 module_id: "drain-deliberate-severance".to_string(),
8904 program: fake_aft_stub_path(),
8905 args: Vec::new(),
8906 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
8907 reserved: false,
8908 reserved_prefixes: Vec::new(),
8909 protocol: ModuleProtocol::Subc,
8910 overlap: Default::default(),
8911 };
8912 let mut child = spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap();
8913 let process = ProcessIdentity {
8914 pid: 41,
8915 start_time: 101,
8916 };
8917 child.process_identity = Some(process);
8918 update_snapshot(&snapshot, Some(&spec.module_id), |state| {
8919 state.pid = Some(process.pid);
8920 state.process_start_time = Some(process.start_time);
8921 })
8922 .unwrap();
8923 record_deliberate_severance(&snapshot, process).unwrap();
8924
8925 drain_child_to_state(
8926 &spec.module_id,
8927 spec.protocol,
8928 StopNotice::SentOverConnection,
8931 ®istry,
8932 &snapshot,
8933 &runtime.terminal_ring,
8934 &runtime.spawn_events,
8935 child,
8936 Duration::from_secs(1),
8937 ModuleState::Stopped,
8938 Some(false),
8939 )
8940 .await
8941 .unwrap();
8942
8943 let state = lock_snapshot(&snapshot).unwrap();
8944 assert_eq!(
8945 state.last_exit.as_ref().map(|exit| exit.kind),
8946 Some(ExitKind::DeliberateSeverance)
8947 );
8948 assert_eq!(state.lifetime_restarts, 1);
8949 assert_eq!(state.crash_restarts.len(), 0);
8950 drop(state);
8951 let history = runtime.terminal_ring.lock().unwrap().snapshot();
8952 assert_eq!(
8953 history.entries[0].exit_kind,
8954 subc_control::TerminalExitKind::DeliberateSeverance
8955 );
8956 }
8957
8958 #[tokio::test]
8959 async fn ordinary_drain_reap_does_not_record_a_lifetime_restart() {
8960 let registry = Registry::default();
8961 let supervisor = Supervisor::new(
8962 Arc::new(Registry::default()),
8963 RestartPolicy::new(3, Duration::ZERO),
8964 );
8965 let runtime = supervisor.runtime_config();
8966 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
8967 let spec = ModuleSpec {
8968 module_id: "ordinary-drain".to_string(),
8969 program: fake_aft_stub_path(),
8970 args: Vec::new(),
8971 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
8972 reserved: false,
8973 reserved_prefixes: Vec::new(),
8974 protocol: ModuleProtocol::Subc,
8975 overlap: Default::default(),
8976 };
8977 let child = spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap();
8978
8979 drain_child_to_state(
8980 &spec.module_id,
8981 spec.protocol,
8982 StopNotice::SentOverConnection,
8985 ®istry,
8986 &snapshot,
8987 &runtime.terminal_ring,
8988 &runtime.spawn_events,
8989 child,
8990 Duration::from_secs(1),
8991 ModuleState::Stopped,
8992 Some(false),
8993 )
8994 .await
8995 .unwrap();
8996
8997 let state = lock_snapshot(&snapshot).unwrap();
8998 assert_eq!(
8999 state.last_exit.as_ref().map(|exit| exit.kind),
9000 Some(ExitKind::Crash)
9001 );
9002 assert_eq!(state.lifetime_restarts, 0);
9003 assert_eq!(state.crash_restarts.len(), 0);
9004 }
9005
9006 #[test]
9007 fn fatal_connection_teardown_cannot_arm_a_marker_for_a_surviving_process() {
9008 assert!(!include_str!("server.rs")
9014 .contains("router.record_deliberate_connection_severance(ctx.connection_id)"));
9015 }
9016
9017 #[test]
9024 fn drained_after_quiescence_wait_passes_ok_through_and_forces_false_on_err() {
9025 assert!(drained_after_quiescence_wait(&Ok(true)));
9026 assert!(!drained_after_quiescence_wait(&Ok(false)));
9027 assert!(!drained_after_quiescence_wait(&Err(
9028 SuperviseError::StatePoisoned { module_id: None }
9029 )));
9030 }
9031
9032 #[test]
9041 fn wait_error_exit_report_records_a_failed_terminal_with_no_code_or_signal() {
9042 let ring = Arc::new(Mutex::new(TerminalRing::new(
9043 TerminalRingConfig::default(),
9044 0,
9045 )));
9046 record_wait_error_terminal("wait-error", &ring, &super::SpawnEventFeed::default());
9047
9048 let snapshot = ring.lock().unwrap().snapshot();
9049 assert_eq!(snapshot.entries.len(), 1);
9050 let entry = &snapshot.entries[0];
9051 assert_eq!(entry.exit_code, None);
9052 assert_eq!(entry.exit_signal, None);
9053 assert_eq!(entry.disposition, TerminalDisposition::Failed);
9054 }
9055
9056 #[test]
9057 fn wait_error_exit_path_preserves_spawn_event_density() {
9058 let feed = super::SpawnEventFeed::default();
9059 feed.configure_incarnation("wait-error-density".to_string());
9060 feed.emit_spawned("wait-error", 41, 1);
9061 let ring = Arc::new(Mutex::new(TerminalRing::new(
9062 TerminalRingConfig::default(),
9063 0,
9064 )));
9065
9066 record_wait_error_terminal("wait-error", &ring, &feed);
9067 feed.emit_spawned("after-wait-error", 42, 2);
9068
9069 let state = feed.0.lock().unwrap();
9070 let sequences = state
9071 .events
9072 .iter()
9073 .map(|event| event.cursor.seq)
9074 .collect::<Vec<_>>();
9075 assert_eq!(sequences, vec![1, 2, 3]);
9076 assert_eq!(state.events[1].kind, SpawnEventKind::Exited);
9077 assert_eq!(state.events[1].exit_code, None);
9078 assert_eq!(state.events[1].exit_signal, None);
9079 }
9080
9081 #[test]
9085 fn wait_error_exit_report_is_classified_as_a_crash() {
9086 assert_eq!(wait_error_exit_report().kind, ExitKind::Crash);
9087 }
9088}
9089
9090#[cfg(test)]
9091mod health_evidence_tests {
9092 use super::{HealthProbeError, HealthProbeEvidence};
9093 use std::collections::HashSet;
9094
9095 #[test]
9103 fn only_a_dead_lane_is_proof_of_death() {
9104 assert!(HealthProbeError::lane_dead("gone").is_proof_of_death());
9105 assert!(!HealthProbeError::no_answer("timed out").is_proof_of_death());
9109 assert!(!HealthProbeError::bad_answer("garbage").is_proof_of_death());
9110 assert!(!HealthProbeError::misconfigured("no table").is_proof_of_death());
9111 }
9112
9113 #[test]
9119 fn every_evidence_class_has_a_distinct_label() {
9120 let labels = [
9121 HealthProbeError::lane_dead("").label(),
9122 HealthProbeError::no_answer("").label(),
9123 HealthProbeError::bad_answer("").label(),
9124 HealthProbeError::misconfigured("").label(),
9125 ];
9126 let unique: HashSet<_> = labels.iter().collect();
9127 assert_eq!(unique.len(), labels.len(), "labels collided: {labels:?}");
9128 }
9129
9130 #[test]
9136 fn classification_preserves_the_original_message() {
9137 let err = HealthProbeError::no_answer("module did not answer within 5s");
9138 assert_eq!(err.to_string(), "module did not answer within 5s");
9139 assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
9140 }
9141}
9142
9143#[cfg(test)]
9144mod health_tombstone_tests {
9145 use std::{path::PathBuf, sync::Arc, time::Duration};
9146
9147 use subc_protocol::{
9148 manifest::Concurrency,
9149 session::{HealthStatus, ModuleControlResponse},
9150 };
9151 use tokio::sync::mpsc;
9152
9153 use super::{
9154 probe_module_health, HealthAction, HealthConfig, HealthProbeEvidence, ModuleProtocol,
9155 ModuleSpec, RestartPolicy, Supervisor, SupervisorRuntimeConfig,
9156 };
9157 use crate::{
9158 control::ControlHandler,
9159 forwarding::{ForwardingTable, ModuleControlRpcCompletion, ModuleControlRpcOutcome},
9160 registry::{ConnectionId, Registry},
9161 router::FrameSink,
9162 };
9163
9164 struct ProbeHarness {
9165 spec: ModuleSpec,
9166 runtime: SupervisorRuntimeConfig,
9167 forwarding: Arc<ForwardingTable>,
9168 module_connection: ConnectionId,
9169 module_rx: mpsc::Receiver<crate::router::OutboundFrame>,
9170 handler: ControlHandler,
9171 module: super::SupervisedModule,
9172 }
9173
9174 fn probe_harness() -> ProbeHarness {
9175 let registry = Arc::new(Registry::default());
9176 let forwarding = Arc::new(ForwardingTable::default());
9177 let supervisor_handle = super::SupervisorHandle::new();
9178 let health = HealthConfig {
9179 cadence: Duration::from_secs(30),
9180 deadline: Duration::from_secs(5),
9181 failure_threshold: 3,
9182 on_degraded: HealthAction::Report,
9183 on_failing: HealthAction::Report,
9184 critical: false,
9185 };
9186 let supervisor = Supervisor::new(Arc::clone(®istry), RestartPolicy::default())
9187 .with_forwarding(Arc::clone(&forwarding))
9188 .with_handle(supervisor_handle.clone())
9189 .with_health_config(health);
9190 let spec = ModuleSpec {
9191 module_id: "late-health-module".to_string(),
9192 program: PathBuf::from("disabled-module"),
9193 args: Vec::new(),
9194 env: Vec::new(),
9195 reserved: false,
9196 reserved_prefixes: Vec::new(),
9197 protocol: ModuleProtocol::Subc,
9198 overlap: Default::default(),
9199 };
9200 let module = supervisor
9201 .supervise_configured(spec.clone(), false)
9202 .unwrap();
9203 let runtime = supervisor.runtime_config();
9204 let handler = ControlHandler::with_forwarding(registry, Arc::clone(&forwarding))
9205 .with_supervisor(supervisor_handle);
9206 let module_connection = ConnectionId::new(700);
9207 let (module_tx, module_rx) = mpsc::channel(8);
9208 forwarding
9209 .register_module_connection(
9210 module_connection,
9211 spec.module_id.clone(),
9212 subc_protocol::PROTOCOL_VERSION,
9213 Concurrency::ModuleManaged,
9214 FrameSink::new(module_tx),
9215 )
9216 .unwrap();
9217
9218 ProbeHarness {
9219 spec,
9220 runtime,
9221 forwarding,
9222 module_connection,
9223 module_rx,
9224 handler,
9225 module,
9226 }
9227 }
9228
9229 async fn finish_after(
9230 harness: &mut ProbeHarness,
9231 stall: Duration,
9232 ) -> ModuleControlRpcCompletion {
9233 assert!(stall > harness.runtime.health.deadline);
9234 let deadline = harness.runtime.health.deadline;
9235 let probe = probe_module_health(&harness.spec.module_id, &harness.runtime, None);
9236 let answer = async {
9237 let frame = harness.module_rx.recv().await.expect("health.check frame");
9238 tokio::time::advance(deadline).await;
9239 tokio::task::yield_now().await;
9240 tokio::time::advance(stall - deadline).await;
9241 harness
9242 .forwarding
9243 .complete_module_control_rpc(
9244 harness.module_connection,
9245 frame.header.corr,
9246 Some("health.check"),
9247 ModuleControlRpcOutcome::Response(ModuleControlResponse::HealthCheck {
9248 status: HealthStatus::Ok,
9249 detail: None,
9250 metrics: None,
9251 }),
9252 )
9253 .unwrap()
9254 };
9255 let (probe_result, completion) = tokio::join!(probe, answer);
9256 let err = probe_result.expect_err("probe must miss its deadline");
9257 assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
9258 completion
9259 }
9260
9261 async fn time_out_without_answer(harness: &mut ProbeHarness) {
9262 let deadline = harness.runtime.health.deadline;
9263 let probe = probe_module_health(&harness.spec.module_id, &harness.runtime, None);
9264 let exhaust_deadline = async {
9265 let _frame = harness.module_rx.recv().await.expect("health.check frame");
9266 tokio::time::advance(deadline).await;
9267 tokio::task::yield_now().await;
9268 };
9269 let (probe_result, ()) = tokio::join!(probe, exhaust_deadline);
9270 let err = probe_result.expect_err("probe must miss its deadline");
9271 assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
9272 }
9273
9274 #[tokio::test(start_paused = true)]
9275 async fn late_health_answers_record_start_anchored_latency_for_two_stalls() {
9276 let mut harness = probe_harness();
9277
9278 let first = finish_after(&mut harness, Duration::from_secs(8)).await;
9279 let first_latency = match &first {
9280 ModuleControlRpcCompletion::LateHealthAnswer { latency, .. } => *latency,
9281 other => panic!("late answer was not retained: {other:?}"),
9282 };
9283 assert!(harness.handler.observe_module_control_completion(first));
9284
9285 let second = finish_after(&mut harness, Duration::from_secs(11)).await;
9286 let second_latency = match &second {
9287 ModuleControlRpcCompletion::LateHealthAnswer { latency, .. } => *latency,
9288 other => panic!("late answer was not retained: {other:?}"),
9289 };
9290 assert!(harness.handler.observe_module_control_completion(second));
9291
9292 assert_eq!(first_latency, Duration::from_secs(8));
9293 assert_eq!(
9294 second_latency - first_latency,
9295 Duration::from_secs(3),
9296 "latency must grow linearly with the additional stall"
9297 );
9298 let health = harness.module.status().unwrap().health;
9299 assert_eq!(health.late_answer_count, 2);
9300 assert_eq!(health.last_late_answer_latency_ms, Some(11_000));
9301 }
9302
9303 #[tokio::test(start_paused = true)]
9311 async fn late_answer_clears_the_consecutive_failure_streak() {
9312 let mut harness = probe_harness();
9313
9314 time_out_without_answer(&mut harness).await;
9316 harness
9317 .module
9318 .record_health_probe_failure_for_test("[no-answer] test miss")
9319 .unwrap();
9320 assert_eq!(
9321 harness.module.status().unwrap().health.consecutive_failures,
9322 1,
9323 "precondition: the miss must be on the streak before the late answer"
9324 );
9325
9326 let late = finish_after(&mut harness, Duration::from_secs(9)).await;
9328 assert!(matches!(
9329 late,
9330 ModuleControlRpcCompletion::LateHealthAnswer { .. }
9331 ));
9332 assert!(harness.handler.observe_module_control_completion(late));
9333
9334 let health = harness.module.status().unwrap().health;
9335 assert_eq!(
9336 health.consecutive_failures, 0,
9337 "a late answer is an answer: the streak must reset"
9338 );
9339 assert_eq!(health.late_answer_count, 1);
9340 }
9341
9342 #[tokio::test(start_paused = true)]
9343 async fn repeated_serial_probe_cycles_keep_one_tombstone_per_endpoint() {
9344 let mut harness = probe_harness();
9345
9346 for _ in 0..20 {
9347 time_out_without_answer(&mut harness).await;
9348 assert_eq!(
9349 harness.forwarding.health_probe_tombstone_count().unwrap(),
9350 1
9351 );
9352 }
9353 }
9354}
9355
9356#[cfg(test)]
9357mod child_env_tests {
9358 use super::{
9359 apply_child_env, apply_spawn_role, apply_wire_spawn_args, ModuleProtocol, ModuleSpec,
9360 SpawnRole, SupervisorHandle, SPAWN_ROLE_SWAP_CANDIDATE, SUBC_ARG, SUBC_LAUNCH_NONCE_ENV,
9361 SUBC_MODULE_ID_ENV, SUBC_SPAWN_ROLE_ENV,
9362 };
9363 use std::{ffi::OsStr, path::PathBuf};
9364 use tokio::process::Command;
9365
9366 fn spec(env: Vec<(String, String)>) -> ModuleSpec {
9367 ModuleSpec {
9368 module_id: "env-plan".to_string(),
9369 program: PathBuf::from("/nonexistent"),
9370 args: Vec::new(),
9371 env,
9372 reserved: false,
9373 reserved_prefixes: Vec::new(),
9374 protocol: ModuleProtocol::Subc,
9375 overlap: Default::default(),
9376 }
9377 }
9378
9379 #[test]
9393 fn ambient_ck_log_is_removed_and_a_configured_one_survives() {
9394 let mut command = Command::new("/nonexistent");
9395 apply_child_env(&mut command, &spec(Vec::new()));
9396 let removed = command
9397 .as_std()
9398 .get_envs()
9399 .any(|(key, value)| key == OsStr::new("CK_LOG") && value.is_none());
9400 assert!(
9401 removed,
9402 "ambient CK_LOG must be explicitly removed for an unconfigured module"
9403 );
9404
9405 let mut configured = Command::new("/nonexistent");
9406 apply_child_env(
9407 &mut configured,
9408 &spec(vec![("CK_LOG".to_string(), "debug".to_string())]),
9409 );
9410 let effective = configured
9411 .as_std()
9412 .get_envs()
9413 .filter(|(key, _)| *key == OsStr::new("CK_LOG"))
9414 .last()
9415 .map(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()));
9416 assert_eq!(
9417 effective,
9418 Some(Some("debug".to_string())),
9419 "a module's configured CK_LOG must survive the ambient removal"
9420 );
9421 }
9422
9423 #[test]
9432 fn protocol_none_spawn_carries_no_subc_argument_and_no_nonce() {
9433 let connection_file = std::path::Path::new("/run/subc-connection.json");
9434 let handle = SupervisorHandle::new();
9435
9436 let mut none_spec = spec(Vec::new());
9437 none_spec.protocol = ModuleProtocol::None;
9438 let mut none = Command::new("/nonexistent");
9439 apply_wire_spawn_args(&mut none, &none_spec, Some(connection_file), Some(&handle))
9440 .expect("protocol-none spawn args apply");
9441 let none_args: Vec<String> = none
9442 .as_std()
9443 .get_args()
9444 .map(|a| a.to_string_lossy().into_owned())
9445 .collect();
9446 assert!(
9447 !none_args.iter().any(|a| a == SUBC_ARG),
9448 "protocol:none argv must not carry --subc; got {none_args:?}"
9449 );
9450 let none_has_nonce = none
9451 .as_std()
9452 .get_envs()
9453 .any(|(key, value)| key == OsStr::new(SUBC_LAUNCH_NONCE_ENV) && value.is_some());
9454 assert!(
9455 !none_has_nonce,
9456 "protocol:none spawn must not receive a launch nonce"
9457 );
9458 let none_has_module_id = none
9459 .as_std()
9460 .get_envs()
9461 .any(|(key, value)| key == OsStr::new(SUBC_MODULE_ID_ENV) && value.is_some());
9462 assert!(
9463 none_has_module_id,
9464 "SUBC_MODULE_ID is inert and stays on every path"
9465 );
9466 assert!(
9467 handle.spawn_nonce(&none_spec.module_id).is_none(),
9468 "no nonce record for a process that will never present one"
9469 );
9470
9471 let wire_spec = spec(Vec::new());
9473 let mut wire = Command::new("/nonexistent");
9474 apply_wire_spawn_args(&mut wire, &wire_spec, Some(connection_file), Some(&handle))
9475 .expect("subc-wire spawn args apply");
9476 let wire_args: Vec<String> = wire
9477 .as_std()
9478 .get_args()
9479 .map(|a| a.to_string_lossy().into_owned())
9480 .collect();
9481 assert_eq!(
9482 wire_args,
9483 vec![
9484 SUBC_ARG.to_string(),
9485 connection_file.to_string_lossy().into_owned()
9486 ],
9487 "a subc-wire spawn still carries --subc <path>"
9488 );
9489 assert!(wire
9490 .as_std()
9491 .get_envs()
9492 .any(|(key, value)| key == OsStr::new(SUBC_LAUNCH_NONCE_ENV) && value.is_some()));
9493 assert!(handle.spawn_nonce(&wire_spec.module_id).is_some());
9494 }
9495
9496 #[test]
9506 fn plain_spawn_removes_the_spawn_role_even_when_the_spec_sets_it() {
9507 let role = |command: &Command| {
9508 command
9509 .as_std()
9510 .get_envs()
9511 .filter(|(key, _)| *key == OsStr::new(SUBC_SPAWN_ROLE_ENV))
9512 .last()
9513 .map(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()))
9514 };
9515 let forged = spec(vec![(
9516 SUBC_SPAWN_ROLE_ENV.to_string(),
9517 SPAWN_ROLE_SWAP_CANDIDATE.to_string(),
9518 )]);
9519
9520 let mut plain = Command::new("/nonexistent");
9521 apply_child_env(&mut plain, &forged);
9522 apply_spawn_role(&mut plain, SpawnRole::Plain);
9523 assert_eq!(
9524 role(&plain),
9525 Some(None),
9526 "a plain spawn must remove SUBC_SPAWN_ROLE, whatever the spec says"
9527 );
9528
9529 let mut candidate = Command::new("/nonexistent");
9530 apply_child_env(&mut candidate, &spec(Vec::new()));
9531 apply_spawn_role(&mut candidate, SpawnRole::SwapCandidate);
9532 assert_eq!(
9533 role(&candidate),
9534 Some(Some(SPAWN_ROLE_SWAP_CANDIDATE.to_string()))
9535 );
9536 }
9537
9538 #[test]
9544 fn daemon_private_capture_keys_are_not_passed_to_the_child() {
9545 let mut command = Command::new("/nonexistent");
9546 apply_child_env(
9547 &mut command,
9548 &spec(vec![
9549 (super::CAPTURE_KEEP_ENV.to_string(), "5".to_string()),
9550 ("KEPT".to_string(), "yes".to_string()),
9551 ]),
9552 );
9553 let keys: Vec<String> = command
9554 .as_std()
9555 .get_envs()
9556 .filter(|(_, value)| value.is_some())
9557 .map(|(key, _)| key.to_string_lossy().into_owned())
9558 .collect();
9559 assert!(keys.contains(&"KEPT".to_string()), "got {keys:?}");
9560 assert!(
9561 !keys.contains(&super::CAPTURE_KEEP_ENV.to_string()),
9562 "daemon-private capture key leaked to the child: {keys:?}"
9563 );
9564 }
9565}
9566
9567#[cfg(test)]
9568mod jitter_tests {
9569 use super::jittered_health_delay;
9570 use std::{collections::HashSet, time::Duration};
9571
9572 const FLEET: [&str; 14] = [
9581 "aft",
9582 "alfonso-core",
9583 "magic-context",
9584 "broca",
9585 "thalamus",
9586 "quota",
9587 "engram",
9588 "plexus",
9589 "cerebellum",
9590 "astrocyte",
9591 "synapse",
9592 "subc-mcp",
9593 "cortexkit-credentials",
9594 "subc-federation",
9595 ];
9596
9597 #[test]
9605 fn probe_delays_disperse_across_the_fleet() {
9606 let cadence = Duration::from_secs(30);
9607 let delays: HashSet<Duration> = FLEET
9608 .iter()
9609 .map(|id| jittered_health_delay(id, 0, cadence))
9610 .collect();
9611 assert_eq!(
9612 delays.len(),
9613 FLEET.len(),
9614 "every supervised module must land on its own probe offset"
9615 );
9616 }
9617
9618 #[test]
9624 fn jitter_only_delays_and_stays_within_one_tenth_of_cadence() {
9625 let cadence = Duration::from_secs(30);
9626 let span = cadence / 10;
9627 for id in FLEET {
9628 for probe_index in 0..8 {
9629 let delay = jittered_health_delay(id, probe_index, cadence);
9630 assert!(
9631 delay >= cadence,
9632 "{id}#{probe_index}: jitter must not shorten the cadence"
9633 );
9634 assert!(
9635 delay < cadence + span,
9636 "{id}#{probe_index}: jitter must stay inside one tenth of the cadence"
9637 );
9638 }
9639 }
9640 }
9641
9642 #[test]
9648 fn a_module_offset_is_stable_across_restarts() {
9649 let cadence = Duration::from_secs(30);
9650 for id in FLEET {
9651 assert_eq!(
9652 jittered_health_delay(id, 0, cadence),
9653 jittered_health_delay(id, 0, cadence),
9654 "{id}: the same module and probe index must produce the same offset"
9655 );
9656 }
9657 }
9658
9659 #[test]
9661 fn zero_cadence_yields_zero_delay() {
9662 assert_eq!(
9663 jittered_health_delay("aft", 0, Duration::ZERO),
9664 Duration::ZERO
9665 );
9666 }
9667}
9668
9669#[cfg(all(test, target_os = "linux"))]
9670mod cgroup_placement_tests {
9671 use super::{
9672 apply_cgroup_placement, remove_module_cgroup, ModuleProtocol, ModuleSpec, SuperviseError,
9673 SupervisedChild,
9674 };
9675 use crate::stderr_tail::{StderrRing, StderrTailConfig};
9676 use std::{
9677 fs, io,
9678 path::{Path, PathBuf},
9679 sync::{Arc, Mutex},
9680 };
9681 use subc_test_support::TestTempDir;
9682 use tokio::process::Command;
9683
9684 #[test]
9685 fn failed_parent_cgroup_open_is_a_cgroup_supervision_error() {
9686 let path = Path::new("/definitely-missing-subc-cgroup");
9687 let mut command = Command::new("true");
9688 let error = apply_cgroup_placement(
9689 &mut command,
9690 &ModuleSpec {
9691 module_id: "broken-cgroup".to_string(),
9692 program: PathBuf::from("true"),
9693 args: Vec::new(),
9694 env: Vec::new(),
9695 reserved: false,
9696 reserved_prefixes: Vec::new(),
9697 protocol: ModuleProtocol::Subc,
9698 overlap: Default::default(),
9699 },
9700 path,
9701 )
9702 .expect_err("a parent cgroup open failure must reject the supervised spawn");
9703 let reason = error.to_string();
9704
9705 assert!(
9706 matches!(error, SuperviseError::Cgroup { .. }),
9707 "parent cgroup open must be reported as a cgroup supervision error: {reason}"
9708 );
9709 assert!(
9710 reason.contains("/definitely-missing-subc-cgroup/cgroup.procs"),
9711 "parent cgroup open failure must name cgroup.procs: {reason}"
9712 );
9713 }
9714
9715 #[tokio::test]
9716 async fn reaping_a_child_removes_its_empty_module_cgroup() {
9717 let root = TestTempDir::new("supervisor-reap-cgroup");
9718 fs::write(root.join("cgroup.procs"), b"").expect("write scratch cgroup marker");
9719 let placement = subc_cgroup::prepare_at(&root)
9720 .expect("prepare scratch cgroup root")
9721 .expect("scratch root has a cgroup.procs marker");
9722 let module_id = "reaped-module";
9723 let module = placement
9724 .module_path(module_id)
9725 .expect("create scratch module cgroup");
9726 let child = Command::new("true")
9727 .spawn()
9728 .expect("spawn short-lived child");
9729 let pid = child.id().expect("spawned child has pid");
9730 let mut child = SupervisedChild {
9731 child,
9732 module_id: module_id.to_string(),
9733 cgroup_placement: Some(placement),
9734 stdout_pump: None,
9735 stderr_pump: None,
9736 stderr_ring: Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default()))),
9737 spawned_at_ms: 0,
9738 spawned_from: PathBuf::from("true"),
9739 spawned_file_identity: None,
9740 process_start_time: None,
9741 process_identity: None,
9742 pid,
9743 roster_guard: None,
9744 };
9745
9746 child.wait().await.expect("reap short-lived child");
9747
9748 assert!(
9749 !module.exists(),
9750 "reaping the supervised child must remove its empty cgroup"
9751 );
9752 }
9753
9754 #[test]
9755 fn non_empty_cgroup_removal_is_reported_without_blocking_teardown() {
9756 let root = TestTempDir::new("supervisor-non-empty-cgroup");
9757 fs::write(root.join("cgroup.procs"), b"").expect("write scratch cgroup marker");
9758 let placement = subc_cgroup::prepare_at(&root)
9759 .expect("prepare scratch cgroup root")
9760 .expect("scratch root has a cgroup.procs marker");
9761 let module = placement
9762 .module_path("surviving-module")
9763 .expect("create scratch module cgroup");
9764 fs::write(module.join("surviving-process"), b"still present")
9765 .expect("make scratch cgroup non-empty");
9766 let (logs, _guard) = crate::router::test_log::log_capture(tracing::Level::WARN);
9767
9768 remove_module_cgroup(&placement, "surviving-module");
9769
9770 let logs = crate::router::test_log::captured_logs(&logs);
9771 assert!(
9772 module.exists(),
9773 "failed removal must leave the cgroup intact"
9774 );
9775 assert!(
9776 logs.contains("could not remove module cgroup after process exit; continuing teardown")
9777 && logs.contains("surviving-module"),
9778 "best-effort removal must report the failure without returning it: {logs}"
9779 );
9780 }
9781
9782 #[test]
9783 fn cgroup_pre_exec_spawn_failure_names_the_cgroup_path() {
9784 let cgroup_path = PathBuf::from("/sys/fs/cgroup/subc-modules/broken-module");
9785 let reason = SuperviseError::Spawn {
9786 program: PathBuf::from("/bin/true"),
9787 source: io::Error::from_raw_os_error(13),
9788 cgroup_path: Some(cgroup_path.clone()),
9789 }
9790 .to_string();
9791
9792 assert!(
9793 reason.contains(&cgroup_path.display().to_string()),
9794 "a pre_exec spawn failure must name the cgroup path: {reason}"
9795 );
9796 }
9797}
9798
9799#[cfg(test)]
9800mod spawn_subscriber_lag_tests {
9801 use super::*;
9802
9803 #[tokio::test]
9808 async fn lagged_spawn_subscriber_receives_a_terminal_lagged_error_after_its_queued_frames() {
9809 let feed = SpawnEventFeed::default();
9810 feed.configure_incarnation("lag-incarnation".to_string());
9811 let (tx, mut rx) = mpsc::channel(1);
9814 feed.subscribe(ConnectionId::new(1), 7, 1, None, FrameSink::new(tx))
9815 .expect("subscribe");
9816 let emitted = SPAWN_SUBSCRIBER_BUFFER + 16;
9817 for index in 0..emitted {
9818 feed.emit_spawned(&format!("lag-module-{index}"), 1000, 0);
9819 tokio::task::yield_now().await;
9822 }
9823 assert_eq!(
9824 feed.subscriber_count(),
9825 0,
9826 "the lagged subscriber must be removed"
9827 );
9828
9829 let mut data = Vec::new();
9830 let mut last = None;
9831 loop {
9832 let next = tokio::time::timeout(Duration::from_secs(5), rx.recv())
9833 .await
9834 .expect("the forwarder must finish once the subscriber is dropped");
9835 let Some(outbound) = next else { break };
9836 let frame = outbound.frame;
9837 if frame.header.ty == FrameType::StreamData {
9838 assert!(last.is_none(), "no data may follow the terminal frame");
9839 let event: SpawnEvent = serde_json::from_slice(&frame.body).unwrap();
9840 data.push(event.cursor.seq);
9841 } else {
9842 assert!(last.is_none(), "exactly one terminal frame");
9843 last = Some(frame);
9844 }
9845 }
9846 assert!(!data.is_empty(), "queued frames drain before the terminal");
9847 for pair in data.windows(2) {
9848 assert_eq!(
9849 pair[1],
9850 pair[0] + 1,
9851 "queued frames arrive dense and in order"
9852 );
9853 }
9854 let terminal = last.expect("a lagged subscriber must receive a terminal frame");
9855 assert_eq!(terminal.header.ty, FrameType::Error);
9856 assert_eq!(terminal.header.corr, 7);
9857 let body: subc_protocol::ErrorBody = serde_json::from_slice(&terminal.body).unwrap();
9858 assert_eq!(body.code, SPAWN_SUBSCRIBER_LAGGED_CODE);
9859 let detail = body.detail.expect("lagged error carries detail");
9860 assert_eq!(
9861 detail["first_undelivered_cursor"]["seq"],
9862 data.last().unwrap() + 1,
9863 "the named cursor is the first event the subscriber did not receive"
9864 );
9865 assert_eq!(
9866 detail["first_undelivered_cursor"]["daemon_incarnation"],
9867 "lag-incarnation"
9868 );
9869 }
9870}
9871
9872#[cfg(test)]
9873mod terminal_history_read_concurrency_tests {
9874 use super::*;
9875 use crate::terminal_journal::read_pause;
9876 use std::sync::mpsc as std_mpsc;
9877 use subc_test_support::TestTempDir;
9878
9879 fn journaled_ring(
9880 journal: &Arc<crate::terminal_journal::TerminalJournal>,
9881 ) -> Arc<Mutex<TerminalRing>> {
9882 Arc::new(Mutex::new(
9883 TerminalRing::new(TerminalRingConfig::default(), 1)
9884 .with_journal(Some(Arc::clone(journal))),
9885 ))
9886 }
9887
9888 fn crash(at_ms: u64) -> ExitReport {
9889 ExitReport {
9890 kind: ExitKind::Crash,
9891 code: Some(1),
9892 signal: None,
9893 at_ms,
9894 }
9895 }
9896
9897 fn record_within(
9900 module_id: &'static str,
9901 ring: &Arc<Mutex<TerminalRing>>,
9902 at_ms: u64,
9903 bound: Duration,
9904 ) -> bool {
9905 let ring = Arc::clone(ring);
9906 let (done, done_rx) = std_mpsc::channel();
9907 std::thread::spawn(move || {
9908 record_terminal(
9909 module_id,
9910 &ring,
9911 &SpawnEventFeed::default(),
9912 &crash(at_ms),
9913 TerminalDisposition::Restarting,
9914 );
9915 let _ = done.send(());
9916 });
9917 done_rx.recv_timeout(bound).is_ok()
9918 }
9919
9920 #[test]
9925 fn exits_recorded_during_a_paused_history_read_are_not_blocked_or_half_merged() {
9926 let dir = TestTempDir::new("terminal-history-concurrent-read");
9927 let path = dir.join("terminals.jsonl");
9928 let journal = Arc::new(crate::terminal_journal::TerminalJournal::open(
9929 path.clone(),
9930 "daemon".into(),
9931 ));
9932 let reader_ring = journaled_ring(&journal);
9933 let other_ring = journaled_ring(&journal);
9934 assert!(record_within(
9935 "reader-module",
9936 &reader_ring,
9937 10,
9938 Duration::from_secs(5)
9939 ));
9940
9941 let (started, release) = read_pause::install(&path);
9942 let reading = {
9943 let ring = Arc::clone(&reader_ring);
9944 std::thread::spawn(move || durable_terminal_history_of(&ring, "reader-module"))
9945 };
9946 started
9947 .recv_timeout(Duration::from_secs(5))
9948 .expect("the history read reached its pause");
9949
9950 let bound = Duration::from_secs(1);
9951 assert!(
9952 record_within("other-module", &other_ring, 20, bound),
9953 "another module's exit waited on a history read (journal writer held)"
9954 );
9955 assert!(
9956 record_within("reader-module", &reader_ring, 30, bound),
9957 "the read module's own exit waited on its history read (ring held)"
9958 );
9959
9960 drop(release);
9961 let paused = reading.join().unwrap();
9962 assert_eq!(
9963 paused.entries.iter().map(|e| e.at_ms).collect::<Vec<_>>(),
9964 vec![10],
9965 "an exit recorded after the read began lands in neither half of it"
9966 );
9967 assert_eq!(paused.journal_skipped_lines, 0);
9968 assert_eq!(paused.journal_read_errors, 0);
9969
9970 let after = durable_terminal_history_of(&reader_ring, "reader-module");
9971 assert_eq!(
9972 after.entries.iter().map(|e| e.at_ms).collect::<Vec<_>>(),
9973 vec![10, 30],
9974 "the next read merges ring and journal with no duplicate"
9975 );
9976 assert_eq!(after.journal_skipped_lines, 0);
9977 }
9978}
9979
9980#[cfg(test)]
9985mod stderr_settle_tests {
9986 use std::{
9987 future::Future,
9988 io,
9989 pin::Pin,
9990 sync::{Arc, Mutex},
9991 task::{Context, Poll},
9992 time::Duration,
9993 };
9994
9995 use tokio::{
9996 io::{AsyncRead, ReadBuf},
9997 sync::oneshot,
9998 time::Instant,
9999 };
10000
10001 use super::{settle_stderr_pump, StderrPump};
10002 use crate::stderr_tail::{
10003 pump_stderr_to, CaptureState, OutputSink, StderrRing, StderrTailConfig, TailEntry,
10004 };
10005
10006 const BOUND: Duration = Duration::from_millis(250);
10007
10008 struct HeldReader {
10012 before: Option<Vec<u8>>,
10013 gate: Option<oneshot::Receiver<()>>,
10014 after: io::Cursor<Vec<u8>>,
10015 }
10016
10017 impl AsyncRead for HeldReader {
10018 fn poll_read(
10019 mut self: Pin<&mut Self>,
10020 cx: &mut Context<'_>,
10021 buf: &mut ReadBuf<'_>,
10022 ) -> Poll<io::Result<()>> {
10023 if let Some(bytes) = self.before.take() {
10024 buf.put_slice(&bytes);
10025 return Poll::Ready(Ok(()));
10026 }
10027 if let Some(gate) = self.gate.as_mut() {
10028 match Pin::new(gate).poll(cx) {
10029 Poll::Pending => return Poll::Pending,
10030 Poll::Ready(_) => self.gate = None,
10031 }
10032 }
10033 Pin::new(&mut self.after).poll_read(cx, buf)
10034 }
10035 }
10036
10037 struct DiscardSink;
10038
10039 impl OutputSink for DiscardSink {
10040 fn write_line(&mut self, _line: &[u8]) {}
10041 }
10042
10043 fn line(text: &str) -> TailEntry {
10044 TailEntry::Line {
10045 text: text.to_string(),
10046 truncated: false,
10047 }
10048 }
10049
10050 fn lock(ring: &Arc<Mutex<StderrRing>>) -> std::sync::MutexGuard<'_, StderrRing> {
10051 ring.lock().unwrap()
10052 }
10053
10054 fn held_pump(
10058 ring: &Arc<Mutex<StderrRing>>,
10059 before: &str,
10060 after: &str,
10061 ) -> (StderrPump, oneshot::Sender<()>) {
10062 let generation = lock(ring).begin_process();
10063 let (release, gate) = oneshot::channel();
10064 let reader = HeldReader {
10065 before: Some(before.as_bytes().to_vec()),
10066 gate: Some(gate),
10067 after: io::Cursor::new(after.as_bytes().to_vec()),
10068 };
10069 let task = tokio::spawn(pump_stderr_to(
10070 reader,
10071 Arc::clone(ring),
10072 generation,
10073 DiscardSink,
10074 ));
10075 (StderrPump { task, generation }, release)
10076 }
10077
10078 async fn wait_until(ring: &Arc<Mutex<StderrRing>>, done: impl Fn(&StderrRing) -> bool) {
10079 for _ in 0..1000 {
10080 if done(&lock(ring)) {
10081 return;
10082 }
10083 tokio::time::sleep(Duration::from_millis(1)).await;
10084 }
10085 panic!(
10086 "ring never reached the expected state: {:?}",
10087 lock(ring).snapshot(None, None)
10088 );
10089 }
10090
10091 #[tokio::test(start_paused = true)]
10092 async fn a_crash_line_the_reader_had_not_reached_by_the_bound_is_kept_before_the_restart() {
10093 let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
10094 let (pump, release) = held_pump(&ring, "booting\n", "config error: missing storage\n");
10095
10096 settle_stderr_pump("crasher", &ring, pump, BOUND).await;
10097 let before_release = lock(&ring).snapshot(None, None);
10098 assert!(
10099 matches!(before_release.capture, CaptureState::Incomplete { .. }),
10100 "a reader that has not reached EOF cannot claim a whole tail: {before_release:?}"
10101 );
10102
10103 let next = lock(&ring).begin_process();
10106 lock(&ring).push_line_from(next, "next process booting");
10107 release.send(()).unwrap();
10108 wait_until(&ring, |ring| {
10109 ring.snapshot(None, None).capture == CaptureState::Captured
10110 })
10111 .await;
10112
10113 assert_eq!(
10114 lock(&ring).snapshot(None, None).entries,
10115 vec![
10116 line("booting"),
10117 line("config error: missing storage"),
10118 TailEntry::ProcessStart,
10119 line("next process booting"),
10120 ],
10121 "the crash's last line must survive a slow reader and stay in the crashed process's section"
10122 );
10123 }
10124
10125 #[tokio::test(start_paused = true)]
10126 async fn a_pipe_held_open_by_a_descendant_reads_incomplete_without_delaying_the_restart_past_the_bound(
10127 ) {
10128 let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
10129 let (pump, _held) = held_pump(&ring, "parent exiting\n", "");
10132
10133 let started = Instant::now();
10134 settle_stderr_pump("orphaning", &ring, pump, BOUND).await;
10135 assert_eq!(
10136 started.elapsed(),
10137 BOUND,
10138 "the restart must wait exactly the bound for a pipe that stays open, no longer"
10139 );
10140
10141 let next = lock(&ring).begin_process();
10142 lock(&ring).push_line_from(next, "next process booting");
10143 tokio::time::sleep(Duration::from_secs(60)).await;
10144
10145 let snapshot = lock(&ring).snapshot(None, None);
10146 match &snapshot.capture {
10147 CaptureState::Incomplete { reason } => assert!(
10148 reason.contains("had not reached EOF") && reason.contains("250ms"),
10149 "the reason must say what is missing and after how long: {reason}"
10150 ),
10151 other => panic!("expected Incomplete while the pipe is held open, got {other:?}"),
10152 }
10153 assert_eq!(
10154 snapshot.entries,
10155 vec![
10156 line("parent exiting"),
10157 TailEntry::ProcessStart,
10158 line("next process booting"),
10159 ]
10160 );
10161 }
10162
10163 #[tokio::test(start_paused = true)]
10164 async fn a_reader_that_reaches_eof_within_the_bound_leaves_the_tail_captured() {
10165 let ring = Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default())));
10166 let (pump, release) = held_pump(&ring, "one\n", "two\n");
10167 release.send(()).unwrap();
10168
10169 settle_stderr_pump("clean", &ring, pump, BOUND).await;
10170
10171 let snapshot = lock(&ring).snapshot(None, None);
10172 assert_eq!(snapshot.capture, CaptureState::Captured);
10173 assert_eq!(snapshot.entries, vec![line("one"), line("two")]);
10174 }
10175}
10176
10177#[cfg(all(test, windows))]
10191mod job_containment_tests {
10192 use super::*;
10193 use std::{
10194 path::{Path, PathBuf},
10195 sync::{Arc, Mutex},
10196 time::{Duration, Instant},
10197 };
10198 use subc_test_support::TestTempDir;
10199
10200 fn stub_path() -> PathBuf {
10206 let mut path = std::env::current_exe().expect("current_exe available in tests");
10207 path.pop();
10208 path.pop();
10209 path.push("fake-aft-stub.exe");
10210 assert!(
10211 path.exists(),
10212 "fake-aft-stub not built at {}: run `cargo test -p subc-core` (which builds \
10213 [[bin]] targets) rather than `cargo test -p subc-core --lib` (which does not)",
10214 path.display()
10215 );
10216 path
10217 }
10218
10219 fn read_grandchild_pid(path: &Path) -> u32 {
10221 let deadline = Instant::now() + Duration::from_secs(10);
10222 loop {
10223 if let Ok(contents) = std::fs::read_to_string(path) {
10224 if let Ok(pid) = contents.trim().parse() {
10225 return pid;
10226 }
10227 }
10228 assert!(
10229 Instant::now() < deadline,
10230 "the stub never recorded a grandchild pid at {}",
10231 path.display()
10232 );
10233 std::thread::sleep(Duration::from_millis(10));
10234 }
10235 }
10236
10237 struct Fixture {
10240 _dir: TestTempDir,
10241 module_id: String,
10242 grandchild: u32,
10243 child: Option<SupervisedChild>,
10244 registry: Arc<Registry>,
10245 snapshot: Arc<Mutex<SupervisorSnapshot>>,
10246 terminal_ring: Arc<Mutex<TerminalRing>>,
10247 spawn_events: SpawnEventFeed,
10248 }
10249
10250 fn fixture(label: &str, module_id: &str) -> Fixture {
10251 let dir = TestTempDir::new(label);
10252 let pid_file = dir.join("grandchild.pid");
10253 let supervisor = Supervisor::new(
10254 Arc::new(Registry::default()),
10255 RestartPolicy::new(3, Duration::ZERO),
10256 );
10257 let runtime = supervisor.runtime_config();
10258 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
10259 let spec = ModuleSpec {
10260 module_id: module_id.to_string(),
10261 program: stub_path(),
10262 args: Vec::new(),
10266 env: vec![
10267 ("FAKE_AFT_NEVER_CONNECT".to_string(), "1".to_string()),
10268 (
10269 "FAKE_AFT_GRANDCHILD_PID_FILE".to_string(),
10270 pid_file.display().to_string(),
10271 ),
10272 ],
10273 reserved: false,
10274 reserved_prefixes: Vec::new(),
10275 protocol: ModuleProtocol::Subc,
10276 overlap: Default::default(),
10277 };
10278 let child = spawn_and_mark_running(&spec, &runtime, &snapshot)
10279 .expect("spawn the supervised fixture");
10280 let grandchild = read_grandchild_pid(&pid_file);
10281 Fixture {
10282 _dir: dir,
10283 module_id: module_id.to_string(),
10284 grandchild,
10285 child: Some(child),
10286 registry: Arc::new(Registry::default()),
10287 snapshot,
10288 terminal_ring: Arc::clone(&runtime.terminal_ring),
10289 spawn_events: SpawnEventFeed::default(),
10290 }
10291 }
10292
10293 impl Fixture {
10294 async fn drain(&mut self) {
10296 let child = self
10297 .child
10298 .take()
10299 .expect("the fixture child is still present");
10300 drain_child_to_state(
10301 &self.module_id,
10302 ModuleProtocol::Subc,
10303 StopNotice::NotSent,
10306 &self.registry,
10307 &self.snapshot,
10308 &self.terminal_ring,
10309 &self.spawn_events,
10310 child,
10311 Duration::from_millis(500),
10312 ModuleState::Stopped,
10313 Some(false),
10314 )
10315 .await
10316 .expect("drain the supervised fixture");
10317 }
10318 }
10319
10320 #[tokio::test]
10326 async fn teardown_reaps_the_grandchild() {
10327 let mut fixture = fixture("teardown-grandchild", "tree-teardown");
10328 let grandchild = fixture.grandchild;
10329
10330 assert!(
10331 subc_jobobject::process_exists(grandchild),
10332 "grandchild {grandchild} must be alive before teardown, or this proves nothing"
10333 );
10334
10335 fixture.drain().await;
10336
10337 assert!(
10338 subc_jobobject::wait_for_process_exit(grandchild, Duration::from_secs(10)),
10339 "grandchild {grandchild} outlived module teardown: the tree was not contained"
10340 );
10341 }
10342
10343 #[test]
10356 fn an_uncontained_grandchild_survives_a_direct_child_kill() {
10357 let dir = TestTempDir::new("teardown-uncontained");
10358 let pid_file = dir.join("grandchild.pid");
10359 let mut child = std::process::Command::new(stub_path())
10360 .env("FAKE_AFT_NEVER_CONNECT", "1")
10361 .env(
10362 "FAKE_AFT_GRANDCHILD_PID_FILE",
10363 pid_file.display().to_string(),
10364 )
10365 .stdin(std::process::Stdio::null())
10366 .stdout(std::process::Stdio::null())
10367 .stderr(std::process::Stdio::null())
10368 .spawn()
10369 .expect("spawn the uncontained fixture");
10370 let grandchild = read_grandchild_pid(&pid_file);
10371
10372 child.kill().expect("kill the direct child");
10374 let _ = child.wait();
10375
10376 assert!(
10377 subc_jobobject::process_exists(grandchild),
10378 "grandchild {grandchild} died with the direct child, so this control no longer \
10379 distinguishes contained from uncontained teardown and the regression test is \
10380 passing vacuously"
10381 );
10382
10383 kill_tree(grandchild);
10386 }
10387
10388 #[tokio::test]
10397 async fn dropping_containment_reaps_the_grandchild() {
10398 let mut fixture = fixture("drop-containment", "tree-drop");
10399 let grandchild = fixture.grandchild;
10400
10401 assert!(subc_jobobject::process_exists(grandchild));
10402
10403 fixture.child.as_mut().expect("child present").job = None;
10405
10406 assert!(
10407 subc_jobobject::wait_for_process_exit(grandchild, Duration::from_secs(10)),
10408 "grandchild {grandchild} survived the containment handle closing, so a daemon \
10409 crash would leave the tree behind"
10410 );
10411 }
10412
10413 fn kill_tree(pid: u32) {
10415 let _ = std::process::Command::new("taskkill.exe")
10416 .args(["/PID", &pid.to_string(), "/T", "/F"])
10417 .stdin(std::process::Stdio::null())
10418 .stdout(std::process::Stdio::null())
10419 .stderr(std::process::Stdio::null())
10420 .status();
10421 assert!(
10422 subc_jobobject::wait_for_process_exit(pid, Duration::from_secs(10)),
10423 "could not clean up grandchild {pid}"
10424 );
10425 }
10426}