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 daemon_config::{
35 CAPTURE_KEEP_ENV, CAPTURE_MAX_AGE_DAYS_ENV, CAPTURE_MAX_FILE_MB_ENV, CK_LOG_ENV,
36 },
37 forwarding::{
38 CloseReason, ForwardingError, ForwardingTable, GoodbyeTarget, ModuleControlRpcOutcome,
39 ModuleDrainTarget, PendingModuleControlRpc,
40 },
41 provenance::{spawned_file_identity, ExecutableIdentityProbe, SpawnedFileIdentity},
42 registry::{ConnectionId, RegistryError},
43 stderr_tail::{
44 pump_stderr_to, pump_stdout_to, ChildOutputSink, StderrRing, StderrTailConfig,
45 StderrTailSnapshot,
46 },
47 terminal_ring::{TerminalHistorySnapshot, TerminalRecord, TerminalRing, TerminalRingConfig},
48 Frame, FrameSink, Registry,
49};
50
51#[path = "supervise_swap.rs"]
52mod swap;
53
54pub const SUBC_ARG: &str = "--subc";
60
61const DEFAULT_MAX_RESTARTS: u32 = 3;
62const DEFAULT_BACKOFF: Duration = Duration::from_millis(100);
63const DEFAULT_MAX_BACKOFF: Duration = Duration::from_secs(30);
64const DEFAULT_RESTART_WINDOW: Duration = Duration::from_secs(600);
68pub const DEFAULT_DRAIN_TIMEOUT: Duration = Duration::from_secs(30);
79const REGISTRY_RELEASE_TIMEOUT: Duration = Duration::from_secs(1);
80const REGISTRY_RELEASE_POLL: Duration = Duration::from_millis(10);
81const STDERR_PUMP_DRAIN_TIMEOUT: Duration = Duration::from_millis(250);
82pub const SPAWN_EVENT_RING_CAPACITY: usize = 4096;
84const SPAWN_SUBSCRIBER_BUFFER: usize = SPAWN_EVENT_RING_CAPACITY + 1;
85
86struct SupervisedChild {
87 child: Child,
88 #[cfg(target_os = "linux")]
91 module_id: String,
92 #[cfg(target_os = "linux")]
93 cgroup_placement: Option<subc_cgroup::Placement>,
94 stdout_pump: Option<JoinHandle<()>>,
95 stderr_pump: Option<JoinHandle<()>>,
96 stderr_ring: Arc<Mutex<StderrRing>>,
97 spawned_at_ms: u64,
98 spawned_from: PathBuf,
99 spawned_file_identity: Option<SpawnedFileIdentity>,
100 process_start_time: Option<u64>,
101 process_identity: Option<ProcessIdentity>,
102 pid: u32,
103}
104
105impl SupervisedChild {
106 fn id(&self) -> Option<u32> {
107 Some(self.pid)
108 }
109
110 fn process_identity(&self) -> Option<ProcessIdentity> {
111 self.process_identity
112 }
113
114 async fn wait(&mut self) -> io::Result<ExitStatus> {
115 let result = self.child.wait().await;
116 #[cfg(target_os = "linux")]
117 if result.is_ok() {
118 if let Some(placement) = self.cgroup_placement.take() {
119 remove_module_cgroup(&placement, &self.module_id);
120 }
121 }
122 result
123 }
124
125 fn start_kill(&mut self) -> io::Result<()> {
126 self.child.start_kill()
127 }
128
129 async fn drain_stderr(&mut self, module_id: &str) {
130 if let Some(mut pump) = self.stdout_pump.take() {
131 match timeout(STDERR_PUMP_DRAIN_TIMEOUT, &mut pump).await {
132 Ok(Ok(())) => {}
133 Ok(Err(error)) => {
134 warn!(module_id, error = %error, "stdout pump ended unexpectedly");
135 }
136 Err(_) => {
137 pump.abort();
138 warn!(
139 module_id,
140 waited = ?STDERR_PUMP_DRAIN_TIMEOUT,
141 "stdout pump did not drain before restart; stopped it before the next process"
142 );
143 }
144 }
145 }
146
147 let Some(mut pump) = self.stderr_pump.take() else {
148 return;
149 };
150 match timeout(STDERR_PUMP_DRAIN_TIMEOUT, &mut pump).await {
151 Ok(Ok(())) => {}
152 Ok(Err(err)) => {
153 self.stderr_ring
154 .lock()
155 .unwrap_or_else(|poisoned| poisoned.into_inner())
156 .mark_incomplete(format!("stderr pump ended unexpectedly: {err}"));
157 warn!(module_id, error = %err, "stderr pump ended before clean EOF");
158 }
159 Err(_) => {
160 pump.abort();
161 self.stderr_ring
162 .lock()
163 .unwrap_or_else(|poisoned| poisoned.into_inner())
164 .mark_incomplete(format!(
165 "stderr pump did not reach EOF within {:?} before restart",
166 STDERR_PUMP_DRAIN_TIMEOUT
167 ));
168 warn!(
169 module_id,
170 waited = ?STDERR_PUMP_DRAIN_TIMEOUT,
171 "stderr pump did not drain before restart; stopped it before marking the new process"
172 );
173 }
174 }
175 }
176}
177
178fn registration_release_events() -> &'static watch::Sender<u64> {
179 static EVENTS: OnceLock<watch::Sender<u64>> = OnceLock::new();
180 EVENTS.get_or_init(|| {
181 let (sender, _receiver) = watch::channel(0);
182 sender
183 })
184}
185
186pub(crate) fn notify_registration_release() {
187 let events = registration_release_events();
188 let next_generation = (*events.borrow()).wrapping_add(1);
189 events.send_replace(next_generation);
190}
191
192#[derive(Debug, Clone, PartialEq, Eq)]
194pub struct ModuleSpec {
195 pub module_id: String,
196 pub program: PathBuf,
197 pub args: Vec<String>,
198 pub env: Vec<(String, String)>,
199 pub reserved: bool,
204 pub reserved_prefixes: Vec<String>,
209 pub protocol: ModuleProtocol,
225 pub overlap: ModuleOverlap,
230}
231
232#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
239pub enum ModuleOverlap {
240 #[default]
242 Exclusive,
243 Safe,
255}
256
257impl ModuleOverlap {
258 pub fn as_str(self) -> &'static str {
259 match self {
260 Self::Exclusive => "exclusive",
261 Self::Safe => "safe",
262 }
263 }
264}
265
266pub const SUBC_SPAWN_ROLE_ENV: &str = "SUBC_SPAWN_ROLE";
276pub const SPAWN_ROLE_SWAP_CANDIDATE: &str = "swap_candidate";
278pub const DEFAULT_SWAP_READY_TIMEOUT: Duration = Duration::from_secs(100);
283
284#[derive(Debug, Clone, Copy, PartialEq, Eq)]
302pub struct RestartPolicy {
303 pub max_restarts: u32,
304 pub backoff: Duration,
307 pub max_backoff: Duration,
309 pub window: Duration,
313}
314
315impl RestartPolicy {
316 pub fn new(max_restarts: u32, backoff: Duration) -> Self {
320 Self {
321 max_restarts,
322 backoff,
323 max_backoff: DEFAULT_MAX_BACKOFF,
324 window: DEFAULT_RESTART_WINDOW,
325 }
326 }
327
328 pub fn with_max_backoff(mut self, max_backoff: Duration) -> Self {
329 self.max_backoff = max_backoff;
330 self
331 }
332
333 pub fn with_window(mut self, window: Duration) -> Self {
334 self.window = window;
335 self
336 }
337
338 fn delay_for_restart(&self, restart_in_window: u32) -> Duration {
343 if self.backoff.is_zero() || self.max_backoff.is_zero() {
344 return Duration::ZERO;
345 }
346
347 let mut delay = self.backoff;
348 for _ in 0..restart_in_window {
349 if delay >= self.max_backoff {
350 return self.max_backoff;
351 }
352 delay = delay
353 .checked_mul(10)
354 .unwrap_or(self.max_backoff)
355 .min(self.max_backoff);
356 }
357 delay.min(self.max_backoff)
358 }
359
360 fn budget_exhausted_detail(&self) -> String {
365 format!(
366 "crash budget exhausted: max_restarts={} within window_secs={}",
367 self.max_restarts,
368 self.window.as_secs()
369 )
370 }
371}
372
373impl Default for RestartPolicy {
374 fn default() -> Self {
375 Self {
376 max_restarts: DEFAULT_MAX_RESTARTS,
377 backoff: DEFAULT_BACKOFF,
378 max_backoff: DEFAULT_MAX_BACKOFF,
379 window: DEFAULT_RESTART_WINDOW,
380 }
381 }
382}
383
384#[derive(Debug, Clone, Copy, PartialEq, Eq)]
385struct CrashRestartSchedule {
386 restart_in_window: u32,
387 delay: Duration,
388}
389
390fn daemon_will_restart(
397 state: &mut SupervisorSnapshot,
398 policy: &RestartPolicy,
399 now: Instant,
400) -> bool {
401 state.enabled && state.crash_restarts_in_window(policy.window, now) < policy.max_restarts
402}
403
404const DEFAULT_HEALTH_CADENCE: Duration = Duration::from_secs(30);
405const DEFAULT_HEALTH_DEADLINE: Duration = Duration::from_secs(5);
406const DEFAULT_HEALTH_FAILURE_THRESHOLD: u32 = 3;
407const MAX_HEALTH_METRICS_BYTES: usize = 16 * 1024;
408
409#[derive(Debug, Clone, Copy, PartialEq, Eq)]
410pub enum HealthAction {
411 Report,
412 Restart,
413 Alert,
414}
415
416impl fmt::Display for HealthAction {
417 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
418 f.write_str(match self {
419 Self::Report => "report",
420 Self::Restart => "restart",
421 Self::Alert => "alert",
422 })
423 }
424}
425
426#[derive(Debug, Clone, Copy, PartialEq, Eq)]
427pub struct HealthConfig {
428 pub cadence: Duration,
429 pub deadline: Duration,
430 pub failure_threshold: u32,
431 pub on_degraded: HealthAction,
432 pub on_failing: HealthAction,
433 pub critical: bool,
434}
435
436impl Default for HealthConfig {
437 fn default() -> Self {
438 Self {
439 cadence: DEFAULT_HEALTH_CADENCE,
440 deadline: DEFAULT_HEALTH_DEADLINE,
441 failure_threshold: DEFAULT_HEALTH_FAILURE_THRESHOLD,
442 on_degraded: HealthAction::Report,
443 on_failing: HealthAction::Report,
444 critical: false,
445 }
446 }
447}
448
449#[derive(Debug, Clone, PartialEq)]
467pub struct ModuleHealthStatus {
468 pub status: SupervisorHealthStatus,
469 pub last_probe_ms: Option<u64>,
470 pub detail: Option<String>,
471 pub metrics: Option<Value>,
472 pub consecutive_failures: u32,
473 pub late_answer_count: u64,
476 pub last_late_answer_latency_ms: Option<u64>,
478 pub last_action: Option<String>,
479 pub last_action_ms: Option<u64>,
483}
484
485impl Default for ModuleHealthStatus {
486 fn default() -> Self {
487 Self {
488 status: SupervisorHealthStatus::Unknown,
489 last_probe_ms: None,
490 detail: None,
491 metrics: None,
492 consecutive_failures: 0,
493 late_answer_count: 0,
494 last_late_answer_latency_ms: None,
495 last_action: None,
496 last_action_ms: None,
497 }
498 }
499}
500
501#[derive(Debug, Clone, Copy, PartialEq, Eq)]
503pub enum ModuleState {
504 Starting,
505 Running,
506 Unresponsive,
507 Restarting,
508 Draining,
509 Stopped,
510 Failed,
511 Disabled,
512}
513
514impl fmt::Display for ModuleState {
515 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
516 f.write_str(match self {
517 Self::Starting => "starting",
518 Self::Running => "running",
519 Self::Unresponsive => "unresponsive",
520 Self::Restarting => "restarting",
521 Self::Draining => "draining",
522 Self::Stopped => "stopped",
523 Self::Failed => "failed",
524 Self::Disabled => "disabled",
525 })
526 }
527}
528
529#[derive(Debug, Clone, Copy, PartialEq, Eq)]
531pub enum ExitKind {
532 Clean,
533 Crash,
534 DeliberateSeverance,
535}
536
537impl From<ExitKind> for TerminalExitKind {
538 fn from(kind: ExitKind) -> Self {
539 match kind {
540 ExitKind::Clean => Self::Clean,
541 ExitKind::Crash => Self::Crash,
542 ExitKind::DeliberateSeverance => Self::DeliberateSeverance,
543 }
544 }
545}
546
547#[derive(Debug, Clone, Copy, PartialEq, Eq)]
550pub(crate) struct ProcessIdentity {
551 pub(crate) pid: u32,
552 pub(crate) start_time: u64,
553}
554
555#[derive(Debug, Clone, PartialEq, Eq)]
557pub struct ExitReport {
558 pub kind: ExitKind,
559 pub code: Option<i32>,
560 pub signal: Option<i32>,
561 pub at_ms: u64,
562}
563
564#[derive(Debug, Clone, PartialEq)]
567pub struct ModuleStatus {
568 pub module_id: String,
569 pub state: ModuleState,
570 pub enabled: bool,
571 pub process_alive: bool,
572 pub registration_active: bool,
573 pub protocol: ModuleProtocol,
576 pub live: bool,
587 pub restart_count: u32,
591 pub lifetime_restarts: u32,
595 pub spawn_generation: u64,
596 pub max_restarts: u32,
601 pub restart_window: Duration,
605 pub drain_timeout: Duration,
609 pub restart_backoff: Duration,
610 pub restart_max_backoff: Duration,
611 pub pid: Option<u32>,
612 pub spawned_at_ms: Option<u64>,
613 pub spawned_from: Option<PathBuf>,
614 pub process_start_time: Option<u64>,
615 pub last_exit: Option<ExitReport>,
616 pub health: ModuleHealthStatus,
617}
618
619#[derive(Debug, Clone, PartialEq)]
620struct SupervisorSnapshot {
621 state: ModuleState,
622 enabled: bool,
623 process_alive: bool,
624 crash_restarts: VecDeque<Instant>,
630 lifetime_restarts: u32,
631 spawn_generation: u64,
640 pid: Option<u32>,
641 spawned_at_ms: Option<u64>,
642 spawned_from: Option<PathBuf>,
643 spawned_file_identity: Option<SpawnedFileIdentity>,
644 process_start_time: Option<u64>,
645 deliberate_severance: Option<ProcessIdentity>,
646 last_exit: Option<ExitReport>,
647 health: ModuleHealthStatus,
648 in_alternate_slot: bool,
653}
654
655impl SupervisorSnapshot {
656 fn starting() -> Self {
657 Self::new(ModuleState::Starting, true)
658 }
659
660 fn disabled() -> Self {
661 Self::new(ModuleState::Disabled, false)
662 }
663
664 fn failed() -> Self {
665 Self::new(ModuleState::Failed, true)
666 }
667
668 fn crash_restarts_in_window(&mut self, window: Duration, now: Instant) -> u32 {
672 while let Some(oldest) = self.crash_restarts.front() {
673 if now.duration_since(*oldest) > window {
674 self.crash_restarts.pop_front();
675 } else {
676 break;
677 }
678 }
679 u32::try_from(self.crash_restarts.len()).unwrap_or(u32::MAX)
680 }
681
682 fn record_crash_restart(&mut self, policy: &RestartPolicy, now: Instant) {
688 self.crash_restarts.push_back(now);
689 while self.crash_restarts.len() > policy.max_restarts as usize {
690 self.crash_restarts.pop_front();
691 }
692 self.lifetime_restarts += 1;
693 }
694
695 fn next_crash_restart(
699 &mut self,
700 policy: &RestartPolicy,
701 now: Instant,
702 ) -> Option<CrashRestartSchedule> {
703 let restart_in_window = self.crash_restarts_in_window(policy.window, now);
704 if restart_in_window >= policy.max_restarts {
705 return None;
706 }
707 self.record_crash_restart(policy, now);
708 Some(CrashRestartSchedule {
709 restart_in_window,
710 delay: policy.delay_for_restart(restart_in_window),
711 })
712 }
713
714 fn clear_crash_restarts(&mut self) {
719 self.crash_restarts.clear();
720 }
721
722 fn new(state: ModuleState, enabled: bool) -> Self {
723 Self {
724 state,
725 enabled,
726 process_alive: false,
727 crash_restarts: VecDeque::new(),
728 lifetime_restarts: 0,
729 spawn_generation: 0,
730 pid: None,
731 spawned_at_ms: None,
732 spawned_from: None,
733 spawned_file_identity: None,
734 process_start_time: None,
735 deliberate_severance: None,
736 last_exit: None,
737 health: ModuleHealthStatus::default(),
738 in_alternate_slot: false,
739 }
740 }
741}
742
743type SharedSnapshot = Arc<Mutex<SupervisorSnapshot>>;
744
745type SpawnSubscriberKey = (ConnectionId, u64);
746
747#[derive(Debug)]
748struct SpawnSubscriber {
749 version: u8,
750 frames: mpsc::Sender<Frame>,
751}
752
753#[derive(Debug)]
754struct SpawnEventState {
755 daemon_incarnation: String,
756 seq: u64,
757 capacity: usize,
758 live: HashMap<String, LiveSpawn>,
759 generations: HashMap<String, u64>,
760 events: VecDeque<SpawnEvent>,
761 subscribers: HashMap<SpawnSubscriberKey, SpawnSubscriber>,
762}
763
764impl Default for SpawnEventState {
765 fn default() -> Self {
766 Self {
767 daemon_incarnation: "unconfigured".to_string(),
768 seq: 0,
769 capacity: SPAWN_EVENT_RING_CAPACITY,
770 live: HashMap::new(),
771 generations: HashMap::new(),
772 events: VecDeque::new(),
773 subscribers: HashMap::new(),
774 }
775 }
776}
777
778#[derive(Debug, Clone, Default)]
779struct SpawnEventFeed(Arc<Mutex<SpawnEventState>>);
780
781#[derive(Debug, Clone, PartialEq, Eq)]
782pub(crate) enum SpawnSubscribeRefusal {
783 ForeignIncarnation { current: String },
784 TooOld { oldest: SpawnCursor },
785 Frame(String),
786}
787
788impl SpawnEventFeed {
789 fn configure_incarnation(&self, daemon_incarnation: String) {
790 let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
791 state.daemon_incarnation = daemon_incarnation;
792 state.seq = 0;
793 state.live.clear();
794 state.generations.clear();
795 state.events.clear();
796 state.subscribers.clear();
797 }
798
799 fn cursor(state: &SpawnEventState) -> SpawnCursor {
800 SpawnCursor {
801 daemon_incarnation: state.daemon_incarnation.clone(),
802 seq: state.seq,
803 }
804 }
805
806 fn snapshot(&self) -> SpawnSnapshot {
807 let state = self.0.lock().unwrap_or_else(|p| p.into_inner());
808 let mut live = state.live.values().cloned().collect::<Vec<_>>();
809 live.sort_by(|left, right| left.module_id.cmp(&right.module_id));
810 SpawnSnapshot {
811 cursor: Self::cursor(&state),
812 ring_bound: state.capacity as u64,
813 live,
814 }
815 }
816
817 fn emit_spawned(&self, module_id: &str, pid: u32, spawned_at_ms: u64) -> u64 {
818 let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
819 let generation = state
820 .generations
821 .get(module_id)
822 .copied()
823 .unwrap_or(0)
824 .checked_add(1)
825 .expect("spawn generation exhausted");
826 state.generations.insert(module_id.to_string(), generation);
827 let live = LiveSpawn {
828 module_id: module_id.to_string(),
829 spawn_generation: generation,
830 pid,
831 spawned_at_ms,
832 };
833 state.live.insert(module_id.to_string(), live);
834 Self::emit_locked(
835 &mut state,
836 SpawnEventKind::Spawned,
837 module_id.to_string(),
838 generation,
839 pid,
840 None,
841 None,
842 );
843 generation
844 }
845
846 fn emit_exited(&self, module_id: &str, exit_code: Option<i32>, exit_signal: Option<i32>) {
847 let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
848 let Some(live) = state.live.remove(module_id) else {
849 warn!(
850 module_id,
851 "terminal record had no live spawn event identity"
852 );
853 return;
854 };
855 Self::emit_locked(
856 &mut state,
857 SpawnEventKind::Exited,
858 module_id.to_string(),
859 live.spawn_generation,
860 live.pid,
861 exit_code,
862 exit_signal,
863 );
864 }
865
866 fn emit_superseded_exited(
873 &self,
874 module_id: &str,
875 spawn_generation: u64,
876 pid: u32,
877 exit_code: Option<i32>,
878 exit_signal: Option<i32>,
879 ) {
880 let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
881 if state
882 .live
883 .get(module_id)
884 .is_some_and(|live| live.spawn_generation == spawn_generation)
885 {
886 state.live.remove(module_id);
887 }
888 Self::emit_locked(
889 &mut state,
890 SpawnEventKind::Exited,
891 module_id.to_string(),
892 spawn_generation,
893 pid,
894 exit_code,
895 exit_signal,
896 );
897 }
898
899 #[allow(clippy::too_many_arguments)]
900 fn emit_locked(
901 state: &mut SpawnEventState,
902 kind: SpawnEventKind,
903 module_id: String,
904 spawn_generation: u64,
905 pid: u32,
906 exit_code: Option<i32>,
907 exit_signal: Option<i32>,
908 ) {
909 state.seq = state
910 .seq
911 .checked_add(1)
912 .expect("spawn event sequence exhausted");
913 let event = SpawnEvent {
914 cursor: Self::cursor(state),
915 kind,
916 module_id,
917 spawn_generation,
918 pid,
919 exit_code,
920 exit_signal,
921 };
922 state.events.push_back(event.clone());
923 while state.events.len() > state.capacity {
924 state.events.pop_front();
925 }
926 let body = match serde_json::to_vec(&event) {
927 Ok(body) => body,
928 Err(error) => {
929 error!(%error, "failed to serialize supervisor spawn event");
930 return;
931 }
932 };
933 state.subscribers.retain(|(connection_id, corr), subscriber| {
934 let frame = Frame::build_with_version(
935 subscriber.version,
936 FrameType::StreamData,
937 control_flags(),
938 0,
939 0,
940 *corr,
941 body.clone(),
942 );
943 match frame {
944 Ok(frame) => {
945 if subscriber.frames.try_send(frame).is_ok() {
946 true
947 } else {
948 warn!(connection_id = connection_id.get(), corr, "dropping lagged supervisor spawn subscriber");
949 false
950 }
951 }
952 Err(error) => {
953 warn!(connection_id = connection_id.get(), corr, %error, "dropping supervisor spawn subscriber after frame build failure");
954 false
955 }
956 }
957 });
958 }
959
960 fn subscribe(
961 &self,
962 connection_id: ConnectionId,
963 corr: u64,
964 version: u8,
965 since: Option<SpawnCursor>,
966 sink: FrameSink,
967 ) -> Result<(), SpawnSubscribeRefusal> {
968 let (frames, mut receiver) = mpsc::channel(SPAWN_SUBSCRIBER_BUFFER);
969 {
970 let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
971 let replay = if let Some(since) = since {
972 if since.daemon_incarnation != state.daemon_incarnation {
973 return Err(SpawnSubscribeRefusal::ForeignIncarnation {
974 current: state.daemon_incarnation.clone(),
975 });
976 }
977 if let Some(oldest) = state.events.front().map(|event| event.cursor.clone()) {
978 if since.seq < oldest.seq.saturating_sub(1) {
979 return Err(SpawnSubscribeRefusal::TooOld { oldest });
980 }
981 }
982 state
983 .events
984 .iter()
985 .filter(|event| event.cursor.seq > since.seq)
986 .cloned()
987 .collect::<Vec<_>>()
988 } else {
989 Vec::new()
990 };
991 for event in replay {
992 let body = serde_json::to_vec(&event)
993 .map_err(|error| SpawnSubscribeRefusal::Frame(error.to_string()))?;
994 let frame = Frame::build_with_version(
995 version,
996 FrameType::StreamData,
997 control_flags(),
998 0,
999 0,
1000 corr,
1001 body,
1002 )
1003 .map_err(|error| SpawnSubscribeRefusal::Frame(error.to_string()))?;
1004 frames
1005 .try_send(frame)
1006 .map_err(|error| SpawnSubscribeRefusal::Frame(error.to_string()))?;
1007 }
1008 state.subscribers.insert(
1009 (connection_id, corr),
1010 SpawnSubscriber {
1011 version,
1012 frames: frames.clone(),
1013 },
1014 );
1015 }
1016 tokio::spawn(async move {
1017 while let Some(frame) = receiver.recv().await {
1018 if sink.send(frame).await.is_err() {
1019 break;
1020 }
1021 }
1022 });
1023 Ok(())
1024 }
1025
1026 fn cancel(&self, connection_id: ConnectionId, corr: u64) -> bool {
1027 let Some(subscriber) = self
1028 .0
1029 .lock()
1030 .unwrap_or_else(|p| p.into_inner())
1031 .subscribers
1032 .remove(&(connection_id, corr))
1033 else {
1034 return false;
1035 };
1036 if let Ok(frame) = Frame::build_with_version(
1037 subscriber.version,
1038 FrameType::StreamEnd,
1039 control_flags(),
1040 0,
1041 0,
1042 corr,
1043 Vec::new(),
1044 ) {
1045 tokio::spawn(async move {
1046 let _ = subscriber.frames.send(frame).await;
1047 });
1048 }
1049 true
1050 }
1051
1052 fn remove_connection(&self, connection_id: ConnectionId) {
1053 self.0
1054 .lock()
1055 .unwrap_or_else(|p| p.into_inner())
1056 .subscribers
1057 .retain(|(subscriber_connection, _), _| *subscriber_connection != connection_id);
1058 }
1059
1060 #[cfg(any(test, feature = "test-support"))]
1061 fn set_capacity(&self, capacity: usize) {
1062 self.0.lock().unwrap_or_else(|p| p.into_inner()).capacity = capacity;
1063 }
1064
1065 #[cfg(any(test, feature = "test-support"))]
1066 fn subscriber_count(&self) -> usize {
1067 self.0
1068 .lock()
1069 .unwrap_or_else(|p| p.into_inner())
1070 .subscribers
1071 .len()
1072 }
1073}
1074
1075pub trait ModuleProcessLiveness: Send + Sync {
1077 fn process_live(&self, module_id: &str) -> Option<bool>;
1078}
1079
1080#[derive(Debug, Clone, Default)]
1082pub struct SupervisorProcessLiveness {
1083 snapshots: Arc<Mutex<HashMap<String, SharedSnapshot>>>,
1084}
1085
1086impl SupervisorProcessLiveness {
1087 pub fn new() -> Self {
1088 Self::default()
1089 }
1090
1091 fn track(&self, module_id: String, snapshot: SharedSnapshot) {
1092 let mut snapshots = self
1093 .snapshots
1094 .lock()
1095 .unwrap_or_else(|poisoned| poisoned.into_inner());
1096 snapshots.insert(module_id, snapshot);
1097 }
1098
1099 fn untrack_if_current(&self, module_id: &str, snapshot: &SharedSnapshot) {
1100 let mut snapshots = self
1101 .snapshots
1102 .lock()
1103 .unwrap_or_else(|poisoned| poisoned.into_inner());
1104 let is_current = snapshots
1105 .get(module_id)
1106 .map(|tracked| Arc::ptr_eq(tracked, snapshot))
1107 .unwrap_or(false);
1108 if is_current {
1109 snapshots.remove(module_id);
1110 }
1111 }
1112}
1113
1114impl ModuleProcessLiveness for SupervisorProcessLiveness {
1115 fn process_live(&self, module_id: &str) -> Option<bool> {
1116 let snapshot = {
1117 let snapshots = self
1118 .snapshots
1119 .lock()
1120 .unwrap_or_else(|poisoned| poisoned.into_inner());
1121 snapshots.get(module_id).cloned()
1122 }?;
1123 let snapshot = snapshot
1124 .lock()
1125 .unwrap_or_else(|poisoned| poisoned.into_inner());
1126 Some(snapshot.state == ModuleState::Running && snapshot.process_alive)
1127 }
1128}
1129
1130#[derive(Debug, Clone)]
1131struct SupervisorRuntimeConfig {
1132 restart_policy: RestartPolicy,
1133 drain_timeout: Duration,
1136 effective_drain_timeout: Arc<Mutex<Duration>>,
1139 default_drain_timeout: Duration,
1142 health: HealthConfig,
1143 connection_file_path: Option<PathBuf>,
1144 capture_logs_dir: Option<PathBuf>,
1145 forwarding: Option<Arc<ForwardingTable>>,
1146 supervisor_handle: Option<SupervisorHandle>,
1149 stderr_ring: Arc<Mutex<StderrRing>>,
1156 terminal_ring: Arc<Mutex<TerminalRing>>,
1157 spawn_events: SpawnEventFeed,
1158 #[cfg(target_os = "linux")]
1159 cgroup_placement: Option<subc_cgroup::Placement>,
1160 #[cfg(test)]
1161 test_seed_stale_facts_before_enable_spawn: bool,
1162}
1163
1164#[derive(Debug, Clone, PartialEq, Eq)]
1165struct SupervisedConfiguration {
1166 spec: ModuleSpec,
1167 health: HealthConfig,
1168}
1169
1170#[derive(Debug, Clone, Default)]
1176pub struct SupervisorHandle {
1177 modules: Arc<Mutex<HashMap<String, SupervisedModule>>>,
1178 spawn_events: SpawnEventFeed,
1179 reserved_nonces: Arc<Mutex<HashMap<String, Option<String>>>>,
1190 removal_tombstones: Arc<Mutex<HashMap<String, u64>>>,
1196 spawn_nonces: Arc<Mutex<HashMap<String, String>>>,
1200 reserved_prefix_owners: Arc<Mutex<HashMap<String, String>>>,
1208 swaps: Arc<Mutex<HashMap<String, OpenSwap>>>,
1214 promotion_observer: PromotionObserverSlot,
1216 operation_lock: Arc<AsyncMutex<()>>,
1220}
1221
1222pub(crate) trait SwapPromotionObserver: Send + Sync {
1231 fn swap_promoted(&self, registration: &crate::registry::ModuleRegistration);
1232}
1233
1234#[derive(Clone, Default)]
1238struct PromotionObserverSlot(Arc<Mutex<Option<std::sync::Weak<dyn SwapPromotionObserver>>>>);
1239
1240impl fmt::Debug for PromotionObserverSlot {
1241 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1242 f.write_str("PromotionObserverSlot")
1243 }
1244}
1245
1246#[derive(Debug, Clone)]
1248struct OpenSwap {
1249 candidate_nonce: String,
1252 incumbent_nonce: Option<String>,
1257 candidate_admitted: bool,
1261}
1262
1263#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1266pub(crate) enum SwapHelloAdmission {
1267 NotSwapping,
1270 Candidate,
1272 Refused,
1275}
1276
1277#[derive(Debug, Clone, PartialEq, Eq)]
1278pub(crate) enum ReservedHelloRejection {
1279 Exact {
1280 module_id: String,
1281 },
1282 Prefix {
1283 prefix: String,
1284 owner_module_id: String,
1285 },
1286}
1287
1288impl SupervisorHandle {
1289 pub fn new() -> Self {
1290 Self::default()
1291 }
1292
1293 pub(crate) fn spawn_snapshot(&self) -> SpawnSnapshot {
1294 self.spawn_events.snapshot()
1295 }
1296
1297 pub(crate) fn subscribe_spawns(
1298 &self,
1299 connection_id: ConnectionId,
1300 corr: u64,
1301 version: u8,
1302 since: Option<SpawnCursor>,
1303 sink: FrameSink,
1304 ) -> Result<(), SpawnSubscribeRefusal> {
1305 self.spawn_events
1306 .subscribe(connection_id, corr, version, since, sink)
1307 }
1308
1309 pub(crate) fn cancel_spawn_subscription(&self, connection_id: ConnectionId, corr: u64) -> bool {
1310 self.spawn_events.cancel(connection_id, corr)
1311 }
1312
1313 pub(crate) fn remove_spawn_subscribers(&self, connection_id: ConnectionId) {
1314 self.spawn_events.remove_connection(connection_id);
1315 }
1316
1317 #[cfg(any(test, feature = "test-support"))]
1318 pub fn set_spawn_event_capacity_for_test(&self, capacity: usize) {
1319 assert!(capacity > 0, "spawn event capacity must be non-zero");
1320 self.spawn_events.set_capacity(capacity);
1321 }
1322
1323 #[cfg(any(test, feature = "test-support"))]
1324 pub fn spawn_subscriber_count_for_test(&self) -> usize {
1325 self.spawn_events.subscriber_count()
1326 }
1327
1328 pub fn set_spawn_nonce(&self, module_id: &str, nonce: String) {
1331 self.spawn_nonces
1332 .lock()
1333 .unwrap_or_else(|poisoned| poisoned.into_inner())
1334 .insert(module_id.to_string(), nonce);
1335 }
1336
1337 pub fn set_reserved_nonce(&self, module_id: &str, nonce: String) {
1340 self.reserved_nonces
1341 .lock()
1342 .unwrap_or_else(|poisoned| poisoned.into_inner())
1343 .insert(module_id.to_string(), Some(nonce));
1344 }
1345
1346 pub fn set_reserved_prefixes(&self, owner_module_id: &str, prefixes: &[String]) {
1348 let mut owners = self
1349 .reserved_prefix_owners
1350 .lock()
1351 .unwrap_or_else(|poisoned| poisoned.into_inner());
1352 owners.retain(|_, owner| owner != owner_module_id);
1353 for prefix in prefixes {
1354 owners.insert(prefix.clone(), owner_module_id.to_string());
1355 }
1356 }
1357
1358 #[cfg(test)]
1360 pub(crate) fn spawn_nonce(&self, module_id: &str) -> Option<String> {
1361 self.spawn_nonces
1362 .lock()
1363 .unwrap_or_else(|poisoned| poisoned.into_inner())
1364 .get(module_id)
1365 .cloned()
1366 }
1367
1368 fn apply_identity_configuration(&self, spec: &ModuleSpec) {
1369 self.set_reserved_prefixes(&spec.module_id, &spec.reserved_prefixes);
1370 let spawn_nonce = self
1371 .spawn_nonces
1372 .lock()
1373 .unwrap_or_else(|poisoned| poisoned.into_inner())
1374 .get(&spec.module_id)
1375 .cloned();
1376 let mut reserved_nonces = self
1377 .reserved_nonces
1378 .lock()
1379 .unwrap_or_else(|poisoned| poisoned.into_inner());
1380 if spec.reserved {
1381 reserved_nonces.insert(spec.module_id.clone(), spawn_nonce);
1386 }
1387 drop(reserved_nonces);
1388 self.removal_tombstones
1392 .lock()
1393 .unwrap_or_else(|poisoned| poisoned.into_inner())
1394 .remove(&spec.module_id);
1395 }
1396
1397 pub fn reserved_hello_authorized(&self, module_id: &str, presented: Option<&str>) -> bool {
1402 self.reserved_hello_rejection(module_id, presented)
1403 .is_none()
1404 }
1405
1406 pub(crate) fn reserved_hello_rejection(
1407 &self,
1408 module_id: &str,
1409 presented: Option<&str>,
1410 ) -> Option<ReservedHelloRejection> {
1411 let nonces = self
1412 .reserved_nonces
1413 .lock()
1414 .unwrap_or_else(|poisoned| poisoned.into_inner());
1415 if let Some(expected) = nonces.get(module_id) {
1416 let authorized = match expected {
1420 Some(expected) => {
1421 presented.is_some_and(|p| constant_time_eq(expected.as_bytes(), p.as_bytes()))
1422 }
1423 None => false,
1424 };
1425 if authorized {
1426 return None;
1427 }
1428 return Some(ReservedHelloRejection::Exact {
1429 module_id: module_id.to_string(),
1430 });
1431 }
1432 drop(nonces);
1433
1434 let matched_prefix = self
1435 .reserved_prefix_owners
1436 .lock()
1437 .unwrap_or_else(|poisoned| poisoned.into_inner())
1438 .iter()
1439 .filter(|(prefix, _)| module_id.starts_with(prefix.as_str()))
1440 .max_by_key(|(prefix, _)| prefix.len())
1441 .map(|(prefix, owner)| (prefix.clone(), owner.clone()));
1442 let (prefix, owner_module_id) = matched_prefix?;
1443
1444 let authorized = presented.is_some_and(|presented| {
1445 self.spawn_nonces
1446 .lock()
1447 .unwrap_or_else(|poisoned| poisoned.into_inner())
1448 .get(&owner_module_id)
1449 .is_some_and(|expected| constant_time_eq(expected.as_bytes(), presented.as_bytes()))
1450 || self.swap_nonce_matches(&owner_module_id, presented)
1453 });
1454 if authorized {
1455 None
1456 } else {
1457 Some(ReservedHelloRejection::Prefix {
1458 prefix,
1459 owner_module_id,
1460 })
1461 }
1462 }
1463
1464 pub fn spawned_consumer_authorized(&self, module_id: &str, presented: &str) -> bool {
1469 if presented.is_empty() {
1470 return false;
1471 }
1472 let nonces = self
1473 .spawn_nonces
1474 .lock()
1475 .unwrap_or_else(|poisoned| poisoned.into_inner());
1476 let current = nonces
1477 .get(module_id)
1478 .is_some_and(|expected| constant_time_eq(expected.as_bytes(), presented.as_bytes()));
1479 drop(nonces);
1480 current || self.swap_nonce_matches(module_id, presented)
1485 }
1486
1487 fn swap_nonce_matches(&self, module_id: &str, presented: &str) -> bool {
1489 let swaps = self
1490 .swaps
1491 .lock()
1492 .unwrap_or_else(|poisoned| poisoned.into_inner());
1493 swaps.get(module_id).is_some_and(|swap| {
1494 constant_time_eq(swap.candidate_nonce.as_bytes(), presented.as_bytes())
1495 || swap.incumbent_nonce.as_deref().is_some_and(|incumbent| {
1496 constant_time_eq(incumbent.as_bytes(), presented.as_bytes())
1497 })
1498 })
1499 }
1500
1501 pub(crate) fn open_swap(&self, module_id: &str, candidate_nonce: String) {
1504 let incumbent_nonce = self
1505 .spawn_nonces
1506 .lock()
1507 .unwrap_or_else(|poisoned| poisoned.into_inner())
1508 .get(module_id)
1509 .cloned();
1510 self.swaps
1511 .lock()
1512 .unwrap_or_else(|poisoned| poisoned.into_inner())
1513 .insert(
1514 module_id.to_string(),
1515 OpenSwap {
1516 candidate_nonce,
1517 incumbent_nonce,
1518 candidate_admitted: false,
1519 },
1520 );
1521 }
1522
1523 pub(crate) fn close_swap(&self, module_id: &str) {
1526 self.swaps
1527 .lock()
1528 .unwrap_or_else(|poisoned| poisoned.into_inner())
1529 .remove(module_id);
1530 }
1531
1532 pub(crate) fn set_swap_promotion_observer(
1535 &self,
1536 observer: std::sync::Weak<dyn SwapPromotionObserver>,
1537 ) {
1538 *self
1539 .promotion_observer
1540 .0
1541 .lock()
1542 .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(observer);
1543 }
1544
1545 fn notify_swap_promoted(&self, registration: &crate::registry::ModuleRegistration) {
1548 let observer = self
1549 .promotion_observer
1550 .0
1551 .lock()
1552 .unwrap_or_else(|poisoned| poisoned.into_inner())
1553 .as_ref()
1554 .and_then(std::sync::Weak::upgrade);
1555 if let Some(observer) = observer {
1556 observer.swap_promoted(registration);
1557 }
1558 }
1559
1560 pub(crate) fn swap_open(&self, module_id: &str) -> bool {
1562 self.swaps
1563 .lock()
1564 .unwrap_or_else(|poisoned| poisoned.into_inner())
1565 .contains_key(module_id)
1566 }
1567
1568 fn promote_swap_nonce(&self, module_id: &str, reserved: bool) {
1573 let candidate_nonce = self
1574 .swaps
1575 .lock()
1576 .unwrap_or_else(|poisoned| poisoned.into_inner())
1577 .get(module_id)
1578 .map(|swap| swap.candidate_nonce.clone());
1579 let Some(nonce) = candidate_nonce else {
1580 return;
1581 };
1582 self.set_spawn_nonce(module_id, nonce.clone());
1583 if reserved {
1584 self.set_reserved_nonce(module_id, nonce);
1585 }
1586 }
1587
1588 pub(crate) fn swap_hello_admission(
1603 &self,
1604 module_id: &str,
1605 presented: Option<&str>,
1606 ) -> SwapHelloAdmission {
1607 let swaps = self
1608 .swaps
1609 .lock()
1610 .unwrap_or_else(|poisoned| poisoned.into_inner());
1611 let Some(swap) = swaps.get(module_id) else {
1612 return SwapHelloAdmission::NotSwapping;
1613 };
1614 let Some(presented) = presented else {
1615 return SwapHelloAdmission::Refused;
1616 };
1617 if constant_time_eq(swap.candidate_nonce.as_bytes(), presented.as_bytes()) {
1618 return if swap.candidate_admitted {
1619 SwapHelloAdmission::Refused
1620 } else {
1621 SwapHelloAdmission::Candidate
1622 };
1623 }
1624 if swap
1625 .incumbent_nonce
1626 .as_deref()
1627 .is_some_and(|incumbent| constant_time_eq(incumbent.as_bytes(), presented.as_bytes()))
1628 {
1629 return SwapHelloAdmission::NotSwapping;
1630 }
1631 SwapHelloAdmission::Refused
1632 }
1633
1634 pub(crate) fn mark_swap_candidate_admitted(&self, module_id: &str) {
1637 if let Some(swap) = self
1638 .swaps
1639 .lock()
1640 .unwrap_or_else(|poisoned| poisoned.into_inner())
1641 .get_mut(module_id)
1642 {
1643 swap.candidate_admitted = true;
1644 }
1645 }
1646
1647 pub fn spawn_launch_nonce_for(&self, module_id: &str) -> Option<String> {
1649 self.spawn_nonces
1650 .lock()
1651 .unwrap_or_else(|poisoned| poisoned.into_inner())
1652 .get(module_id)
1653 .cloned()
1654 }
1655
1656 pub fn reserved_launch_nonce_for(&self, module_id: &str) -> Option<String> {
1658 self.reserved_nonces
1659 .lock()
1660 .unwrap_or_else(|poisoned| poisoned.into_inner())
1661 .get(module_id)
1662 .cloned()
1663 .flatten()
1664 }
1665
1666 pub fn insert(&self, module: SupervisedModule) -> Option<SupervisedModule> {
1667 let mut modules = self
1668 .modules
1669 .lock()
1670 .unwrap_or_else(|poisoned| poisoned.into_inner());
1671 modules.insert(module.module_id().to_string(), module)
1672 }
1673
1674 pub fn get(&self, module_id: &str) -> Option<SupervisedModule> {
1675 let modules = self
1676 .modules
1677 .lock()
1678 .unwrap_or_else(|poisoned| poisoned.into_inner());
1679 modules.get(module_id).cloned()
1680 }
1681
1682 pub(crate) fn record_late_health_answer(
1683 &self,
1684 module_id: &str,
1685 latency_ms: u64,
1686 ) -> Result<bool, SuperviseError> {
1687 let Some(module) = self.get(module_id) else {
1688 return Ok(false);
1689 };
1690 update_snapshot(&module.inner.snapshot, Some(module_id), |state| {
1691 state.health.late_answer_count = state.health.late_answer_count.saturating_add(1);
1692 state.health.last_late_answer_latency_ms = Some(latency_ms);
1693 state.health.consecutive_failures = 0;
1701 })?;
1702 Ok(true)
1703 }
1704
1705 pub fn record_deliberate_severance(&self, module_id: &str) -> Result<bool, SuperviseError> {
1711 let Some(module) = self.get(module_id) else {
1712 return Ok(false);
1713 };
1714 let status = module.status()?;
1715 let Some((pid, start_time)) = status.pid.zip(status.process_start_time) else {
1716 return Ok(false);
1717 };
1718 module.record_deliberate_severance(ProcessIdentity { pid, start_time })
1719 }
1720
1721 pub fn list(&self) -> Vec<SupervisedModule> {
1722 let modules = self
1723 .modules
1724 .lock()
1725 .unwrap_or_else(|poisoned| poisoned.into_inner());
1726 let mut modules = modules.values().cloned().collect::<Vec<_>>();
1727 modules.sort_by(|left, right| left.module_id().cmp(right.module_id()));
1728 modules
1729 }
1730
1731 pub(crate) fn retire(&self, module_id: &str) -> Option<SupervisedModule> {
1732 self.spawn_nonces
1733 .lock()
1734 .unwrap_or_else(|poisoned| poisoned.into_inner())
1735 .remove(module_id);
1736 self.close_swap(module_id);
1737 let mut reserved_nonces = self
1738 .reserved_nonces
1739 .lock()
1740 .unwrap_or_else(|poisoned| poisoned.into_inner());
1741 if reserved_nonces.contains_key(module_id) {
1742 reserved_nonces.insert(module_id.to_string(), None);
1745 }
1746 drop(reserved_nonces);
1747 self.reserved_prefix_owners
1748 .lock()
1749 .unwrap_or_else(|poisoned| poisoned.into_inner())
1750 .retain(|_, owner| owner != module_id);
1751 self.modules
1752 .lock()
1753 .unwrap_or_else(|poisoned| poisoned.into_inner())
1754 .remove(module_id)
1755 }
1756
1757 pub(crate) fn record_rescan_removal(&self, module_id: &str) {
1760 self.removal_tombstones
1761 .lock()
1762 .unwrap_or_else(|poisoned| poisoned.into_inner())
1763 .insert(module_id.to_string(), unix_ms_now());
1764 }
1765
1766 pub(crate) fn removal_tombstone_age_ms(&self, module_id: &str) -> Option<u64> {
1768 self.removal_tombstones
1769 .lock()
1770 .unwrap_or_else(|poisoned| poisoned.into_inner())
1771 .get(module_id)
1772 .copied()
1773 .map(|removed_at_ms| unix_ms_now().saturating_sub(removed_at_ms))
1774 }
1775
1776 pub(crate) fn release_retained_reserved_gate(&self, module_id: &str) -> bool {
1781 if self.get(module_id).is_some() {
1782 return false;
1783 }
1784 let mut reserved_nonces = self
1785 .reserved_nonces
1786 .lock()
1787 .unwrap_or_else(|poisoned| poisoned.into_inner());
1788 if !matches!(reserved_nonces.get(module_id), Some(None)) {
1789 return false;
1790 }
1791 reserved_nonces.remove(module_id);
1792 true
1793 }
1794
1795 pub(crate) fn operation_lock(&self) -> Arc<AsyncMutex<()>> {
1796 Arc::clone(&self.operation_lock)
1797 }
1798}
1799
1800#[derive(Debug, Clone)]
1802pub struct Supervisor {
1803 registry: Arc<Registry>,
1804 restart_policy: RestartPolicy,
1805 drain_timeout: Duration,
1806 connection_file_path: Option<PathBuf>,
1807 capture_logs_dir: Option<PathBuf>,
1808 forwarding: Option<Arc<ForwardingTable>>,
1809 process_liveness: Arc<SupervisorProcessLiveness>,
1810 supervisor_handle: Option<SupervisorHandle>,
1811 health: HealthConfig,
1812 daemon_start_clock: crate::clock::StartClock,
1813 terminal_journal: Option<Arc<crate::terminal_journal::TerminalJournal>>,
1814 spawn_events: SpawnEventFeed,
1815 provenance_probe: ExecutableIdentityProbe,
1816 #[cfg(target_os = "linux")]
1817 cgroup_placement: Option<subc_cgroup::Placement>,
1818}
1819
1820impl Supervisor {
1821 #[cfg(unix)]
1822 pub(crate) fn stamp_shutdown(&self) {
1823 if let Some(journal) = &self.terminal_journal {
1824 journal.stamp_shutdown();
1825 }
1826 }
1827
1828 #[cfg(unix)]
1832 pub(crate) async fn drain_for_daemon_shutdown(&self) -> Result<(), SuperviseError> {
1833 const NOTICE_BUDGET: Duration = Duration::from_millis(500);
1834 const DRAIN_BUDGET: Duration = Duration::from_secs(2);
1835 let Some(forwarding) = &self.forwarding else {
1836 return Ok(());
1837 };
1838 let module_ids = forwarding
1839 .begin_daemon_drain()
1840 .map_err(SuperviseError::Forwarding)?;
1841 let deadline_ms =
1842 unix_ms_now().saturating_add((NOTICE_BUDGET + DRAIN_BUDGET).as_millis() as u64);
1843 let mut notices = tokio::task::JoinSet::new();
1844 let mut drains = Vec::new();
1845 for module_id in module_ids {
1846 let Some(target) = forwarding
1847 .begin_module_drain(&module_id, RouteCloseReason::Restart)
1848 .map_err(SuperviseError::Forwarding)?
1849 else {
1850 continue;
1851 };
1852 let routes = forwarding
1853 .endpoint_routes(target.endpoint)
1854 .map_err(SuperviseError::Forwarding)?;
1855 let command = serde_json::to_vec(&ModuleControlCommand::Draining {
1859 reason: RouteCloseReason::Restart,
1860 deadline_ms,
1861 })
1862 .expect("module draining serializes");
1863 let closing = serde_json::to_vec(&ClientControlPush::RouteClosing {
1864 module_id: module_id.clone(),
1865 reason: RouteCloseReason::Restart,
1866 })
1867 .expect("route closing serializes");
1868 let mut recipients = vec![(target.sink.clone(), target.negotiated_ver, command)];
1869 let mut seen = std::collections::HashSet::new();
1870 for route in routes {
1871 let client = route.goodbye_target;
1872 if seen.insert(client.connection_id) {
1873 recipients.push((client.sink, client.negotiated_ver, closing.clone()));
1874 }
1875 }
1876 for (sink, version, body) in recipients {
1877 notices.spawn(async move {
1878 let frame = Frame::build_with_version(
1879 version,
1880 FrameType::Push,
1881 control_flags(),
1882 0,
1883 0,
1884 0,
1885 body,
1886 )
1887 .expect("bounded lifecycle notice frame builds");
1888 sink.send_flushed(frame).await
1889 });
1890 }
1891 let gauges = declared_busy_gauges(&self.registry, &module_id)?;
1892 drains.push((module_id, target.endpoint, gauges));
1893 }
1894 let notice_deadline = Instant::now() + NOTICE_BUDGET;
1897 while let Ok(Some(result)) = timeout_at(notice_deadline, notices.join_next()).await {
1898 if !matches!(result, Ok(Ok(()))) {
1899 warn!(?result, "daemon shutdown notice delivery failed");
1900 }
1901 }
1902 notices.abort_all();
1903 let deadline = Instant::now() + DRAIN_BUDGET;
1904 let mut waits = tokio::task::JoinSet::new();
1905 for (module_id, endpoint, gauges) in drains {
1906 let forwarding = Arc::clone(forwarding);
1907 let mut runtime = self.runtime_config();
1908 runtime.health.cadence = Duration::from_millis(100);
1909 waits.spawn(async move {
1910 wait_for_forwarding_quiescence(
1911 &forwarding,
1912 &module_id,
1913 &runtime,
1914 endpoint,
1915 deadline,
1916 &gauges,
1917 DrainScope::Active,
1918 )
1919 .await
1920 });
1921 }
1922 while let Ok(Some(result)) = timeout_at(deadline, waits.join_next()).await {
1923 if !matches!(result, Ok(Ok(true))) {
1924 warn!(?result, "daemon shutdown drain did not reach quiescence");
1925 }
1926 }
1927 Ok(())
1928 }
1929
1930 pub fn new(registry: Arc<Registry>, restart_policy: RestartPolicy) -> Self {
1931 Self {
1932 registry,
1933 restart_policy,
1934 drain_timeout: DEFAULT_DRAIN_TIMEOUT,
1935 connection_file_path: None,
1936 capture_logs_dir: None,
1937 forwarding: None,
1938 process_liveness: Arc::new(SupervisorProcessLiveness::default()),
1939 supervisor_handle: None,
1940 health: HealthConfig::default(),
1941 daemon_start_clock: crate::clock::StartClock::capture(),
1942 terminal_journal: None,
1943 spawn_events: SpawnEventFeed::default(),
1944 provenance_probe: ExecutableIdentityProbe::default(),
1945 #[cfg(target_os = "linux")]
1946 cgroup_placement: None,
1947 }
1948 }
1949
1950 pub fn with_drain_timeout(mut self, drain_timeout: Duration) -> Self {
1951 self.drain_timeout = drain_timeout;
1952 self
1953 }
1954
1955 pub fn with_process_liveness(
1956 mut self,
1957 process_liveness: Arc<SupervisorProcessLiveness>,
1958 ) -> Self {
1959 self.process_liveness = process_liveness;
1960 self
1961 }
1962
1963 pub fn with_connection_file_path(mut self, connection_file_path: impl Into<PathBuf>) -> Self {
1964 self.connection_file_path = Some(connection_file_path.into());
1965 self
1966 }
1967
1968 pub fn with_capture_logs_dir(mut self, logs_dir: impl Into<PathBuf>) -> Self {
1970 self.capture_logs_dir = Some(logs_dir.into());
1971 self
1972 }
1973
1974 pub fn with_terminal_journal(mut self, path: PathBuf, daemon_incarnation: String) -> Self {
1976 self.spawn_events
1980 .configure_incarnation(daemon_incarnation.clone());
1981 self.terminal_journal = Some(Arc::new(crate::terminal_journal::TerminalJournal::open(
1982 path,
1983 daemon_incarnation,
1984 )));
1985 self
1986 }
1987
1988 pub fn with_forwarding(mut self, forwarding: Arc<ForwardingTable>) -> Self {
1989 self.forwarding = Some(forwarding);
1990 self
1991 }
1992
1993 pub fn with_handle(mut self, supervisor_handle: SupervisorHandle) -> Self {
1994 self.spawn_events = supervisor_handle.spawn_events.clone();
1995 self.supervisor_handle = Some(supervisor_handle);
1996 self
1997 }
1998
1999 pub fn with_health_config(mut self, health: HealthConfig) -> Self {
2000 self.health = health;
2001 self
2002 }
2003
2004 #[cfg(target_os = "linux")]
2005 pub fn with_cgroup_placement(
2006 mut self,
2007 cgroup_placement: Option<subc_cgroup::Placement>,
2008 ) -> Self {
2009 self.cgroup_placement = cgroup_placement;
2010 self
2011 }
2012
2013 pub fn spawn(&self, spec: ModuleSpec) -> Result<SupervisedModule, SuperviseError> {
2019 validate_spec(&spec)?;
2020
2021 let runtime = self.runtime_config();
2022 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
2023 let child = spawn_child(
2024 &spec,
2025 runtime.connection_file_path.as_deref(),
2026 self.supervisor_handle.as_ref(),
2027 &runtime.stderr_ring,
2028 runtime.capture_logs_dir.as_deref(),
2029 #[cfg(target_os = "linux")]
2030 runtime.cgroup_placement.as_ref(),
2031 )?;
2032 set_running(&snapshot, &child, &spec.module_id, &runtime.spawn_events)?;
2033 self.process_liveness
2034 .track(spec.module_id.clone(), Arc::clone(&snapshot));
2035
2036 Ok(self.supervised_module(spec, runtime, snapshot, Some(child)))
2037 }
2038
2039 pub fn supervise_configured(
2045 &self,
2046 spec: ModuleSpec,
2047 enabled: bool,
2048 ) -> Result<SupervisedModule, SuperviseError> {
2049 validate_spec(&spec)?;
2050
2051 let runtime = self.runtime_config();
2052 if !enabled {
2053 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::disabled()));
2054 return Ok(self.supervised_module(spec, runtime, snapshot, None));
2055 }
2056
2057 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
2058 match spawn_child(
2059 &spec,
2060 runtime.connection_file_path.as_deref(),
2061 self.supervisor_handle.as_ref(),
2062 &runtime.stderr_ring,
2063 runtime.capture_logs_dir.as_deref(),
2064 #[cfg(target_os = "linux")]
2065 runtime.cgroup_placement.as_ref(),
2066 ) {
2067 Ok(child) => {
2068 set_running(&snapshot, &child, &spec.module_id, &runtime.spawn_events)?;
2069 self.process_liveness
2070 .track(spec.module_id.clone(), Arc::clone(&snapshot));
2071 Ok(self.supervised_module(spec, runtime, snapshot, Some(child)))
2072 }
2073 Err(err) => {
2074 error!(
2075 module_id = %spec.module_id,
2076 program = %spec.program.display(),
2077 error = %err,
2078 "configured module failed to spawn; marking failed and continuing"
2079 );
2080 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::failed()));
2081 Ok(self.supervised_module(spec, runtime, snapshot, None))
2082 }
2083 }
2084 }
2085
2086 pub fn supervise_configured_with_health(
2092 &self,
2093 spec: ModuleSpec,
2094 enabled: bool,
2095 health: HealthConfig,
2096 drain_timeout_ms: Option<u64>,
2097 restart_policy: RestartPolicy,
2098 ) -> Result<SupervisedModule, SuperviseError> {
2099 validate_spec(&spec)?;
2100
2101 let mut runtime = self.runtime_config();
2102 runtime.health = health;
2103 runtime.restart_policy = restart_policy;
2104 if let Some(ms) = drain_timeout_ms {
2105 runtime.drain_timeout = Duration::from_millis(ms);
2106 *runtime
2107 .effective_drain_timeout
2108 .lock()
2109 .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.drain_timeout;
2110 }
2111 if !enabled {
2112 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::disabled()));
2113 return Ok(self.supervised_module(spec, runtime, snapshot, None));
2114 }
2115
2116 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
2117 match spawn_child(
2118 &spec,
2119 runtime.connection_file_path.as_deref(),
2120 self.supervisor_handle.as_ref(),
2121 &runtime.stderr_ring,
2122 runtime.capture_logs_dir.as_deref(),
2123 #[cfg(target_os = "linux")]
2124 runtime.cgroup_placement.as_ref(),
2125 ) {
2126 Ok(child) => {
2127 set_running(&snapshot, &child, &spec.module_id, &runtime.spawn_events)?;
2128 self.process_liveness
2129 .track(spec.module_id.clone(), Arc::clone(&snapshot));
2130 Ok(self.supervised_module(spec, runtime, snapshot, Some(child)))
2131 }
2132 Err(err) => {
2133 if health.critical {
2134 error!(
2135 module_id = %spec.module_id,
2136 program = %spec.program.display(),
2137 error = %err,
2138 "critical configured module failed to spawn; marking failed and alerting"
2139 );
2140 } else {
2141 error!(
2142 module_id = %spec.module_id,
2143 program = %spec.program.display(),
2144 error = %err,
2145 "configured module failed to spawn; marking failed and continuing"
2146 );
2147 }
2148 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::failed()));
2149 Ok(self.supervised_module(spec, runtime, snapshot, None))
2150 }
2151 }
2152 }
2153
2154 fn runtime_config(&self) -> SupervisorRuntimeConfig {
2155 SupervisorRuntimeConfig {
2156 restart_policy: self.restart_policy,
2157 drain_timeout: self.drain_timeout,
2158 effective_drain_timeout: Arc::new(Mutex::new(self.drain_timeout)),
2159 default_drain_timeout: self.drain_timeout,
2160 health: self.health,
2161 connection_file_path: self.connection_file_path.clone(),
2162 capture_logs_dir: self.capture_logs_dir.clone(),
2163 forwarding: self.forwarding.clone(),
2164 supervisor_handle: self.supervisor_handle.clone(),
2165 stderr_ring: Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default()))),
2166 terminal_ring: Arc::new(Mutex::new(
2167 TerminalRing::new(
2168 TerminalRingConfig::default(),
2169 self.daemon_start_clock.started_at_ms(),
2170 )
2171 .with_start_clock(self.daemon_start_clock)
2172 .with_journal(self.terminal_journal.clone()),
2173 )),
2174 spawn_events: self.spawn_events.clone(),
2175 #[cfg(target_os = "linux")]
2176 cgroup_placement: self.cgroup_placement.clone(),
2177 #[cfg(test)]
2178 test_seed_stale_facts_before_enable_spawn: false,
2179 }
2180 }
2181
2182 fn supervised_module(
2183 &self,
2184 spec: ModuleSpec,
2185 runtime: SupervisorRuntimeConfig,
2186 snapshot: SharedSnapshot,
2187 child: Option<SupervisedChild>,
2188 ) -> SupervisedModule {
2189 let configuration = Arc::new(Mutex::new(SupervisedConfiguration {
2190 spec: spec.clone(),
2191 health: runtime.health,
2192 }));
2193 let stderr_ring = Arc::clone(&runtime.stderr_ring);
2194 let terminal_ring = Arc::clone(&runtime.terminal_ring);
2195 let restart_policy = runtime.restart_policy;
2199 let effective_drain_timeout = Arc::clone(&runtime.effective_drain_timeout);
2200 let (tx, rx) = mpsc::channel(4);
2201 let monitor = tokio::spawn(supervise_loop(
2202 spec.clone(),
2203 runtime,
2204 Arc::clone(&self.registry),
2205 Arc::clone(&self.process_liveness),
2206 Arc::clone(&snapshot),
2207 child,
2208 rx,
2209 ));
2210
2211 let module_id = spec.module_id.clone();
2212 let module = SupervisedModule {
2213 inner: Arc::new(SupervisedModuleInner {
2214 module_id: module_id.clone(),
2215 registry: Arc::clone(&self.registry),
2216 snapshot,
2217 configuration,
2218 stderr_ring,
2219 terminal_ring,
2220 commands: tx,
2221 monitor: Mutex::new(Some(monitor)),
2222 restart_policy,
2223 effective_drain_timeout,
2224 provenance_probe: self.provenance_probe.clone(),
2225 }),
2226 };
2227 if let Some(supervisor_handle) = &self.supervisor_handle {
2228 supervisor_handle.apply_identity_configuration(&spec);
2229 supervisor_handle.insert(module.clone());
2230 }
2231 module
2232 }
2233}
2234
2235impl Default for Supervisor {
2236 fn default() -> Self {
2237 Self::new(Arc::new(Registry::default()), RestartPolicy::default())
2238 }
2239}
2240
2241#[derive(Clone)]
2243pub struct SupervisedModule {
2244 inner: Arc<SupervisedModuleInner>,
2245}
2246
2247struct SupervisedModuleInner {
2248 module_id: String,
2249 registry: Arc<Registry>,
2250 snapshot: SharedSnapshot,
2251 configuration: Arc<Mutex<SupervisedConfiguration>>,
2252 stderr_ring: Arc<Mutex<StderrRing>>,
2253 terminal_ring: Arc<Mutex<TerminalRing>>,
2254 commands: mpsc::Sender<SupervisorCommand>,
2255 monitor: Mutex<Option<JoinHandle<()>>>,
2256 restart_policy: RestartPolicy,
2260 effective_drain_timeout: Arc<Mutex<Duration>>,
2261 provenance_probe: ExecutableIdentityProbe,
2262}
2263
2264impl fmt::Debug for SupervisedModule {
2265 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2266 f.debug_struct("SupervisedModule")
2267 .field("module_id", &self.inner.module_id)
2268 .field("status", &self.status())
2269 .finish_non_exhaustive()
2270 }
2271}
2272
2273impl SupervisedModule {
2274 pub fn module_id(&self) -> &str {
2275 &self.inner.module_id
2276 }
2277
2278 #[cfg(test)]
2282 pub(crate) fn record_health_probe_failure_for_test(
2283 &self,
2284 detail: &str,
2285 ) -> Result<(), SuperviseError> {
2286 update_snapshot(&self.inner.snapshot, Some(&self.inner.module_id), |state| {
2287 state.health.consecutive_failures = state.health.consecutive_failures.saturating_add(1);
2288 state.health.detail = Some(detail.to_string());
2289 })
2290 }
2291
2292 pub fn state(&self) -> Result<ModuleState, SuperviseError> {
2293 Ok(lock_snapshot(&self.inner.snapshot)?.state)
2294 }
2295
2296 pub fn stderr_tail(
2303 &self,
2304 max_lines: Option<usize>,
2305 max_bytes: Option<usize>,
2306 ) -> StderrTailSnapshot {
2307 self.inner
2308 .stderr_ring
2309 .lock()
2310 .unwrap_or_else(|poisoned| poisoned.into_inner())
2311 .snapshot(max_lines, max_bytes)
2312 }
2313
2314 pub fn terminal_history(&self) -> TerminalHistorySnapshot {
2319 self.inner
2320 .terminal_ring
2321 .lock()
2322 .unwrap_or_else(|poisoned| poisoned.into_inner())
2323 .snapshot()
2324 }
2325
2326 pub fn durable_terminal_history(&self) -> subc_control::TerminalHistory {
2328 self.inner
2329 .terminal_ring
2330 .lock()
2331 .unwrap_or_else(|p| p.into_inner())
2332 .durable_history(&self.inner.module_id)
2333 }
2334
2335 pub fn status(&self) -> Result<ModuleStatus, SuperviseError> {
2336 self.status_with_snapshot_lock(&self.inner.snapshot, None)
2337 }
2338
2339 pub(crate) fn record_deliberate_severance(
2340 &self,
2341 identity: ProcessIdentity,
2342 ) -> Result<bool, SuperviseError> {
2343 let mut snapshot = lock_snapshot(&self.inner.snapshot)?;
2344 if snapshot.pid != Some(identity.pid)
2345 || snapshot.process_start_time != Some(identity.start_time)
2346 {
2347 return Ok(false);
2348 }
2349 snapshot.deliberate_severance = Some(identity);
2350 Ok(true)
2351 }
2352
2353 pub(crate) fn status_for_control(
2358 &self,
2359 caller: &'static str,
2360 ) -> Result<ModuleStatus, SuperviseError> {
2361 self.status_with_snapshot_lock(&self.inner.snapshot, Some(caller))
2362 }
2363
2364 fn status_with_snapshot_lock(
2365 &self,
2366 snapshot: &SharedSnapshot,
2367 caller: Option<&'static str>,
2368 ) -> Result<ModuleStatus, SuperviseError> {
2369 let mut guard = match caller {
2370 Some(caller) => lock_snapshot_for_control(snapshot, &self.inner.module_id, caller)?,
2371 None => lock_snapshot(snapshot)?,
2372 };
2373 let restart_count =
2376 guard.crash_restarts_in_window(self.inner.restart_policy.window, Instant::now());
2377 let snapshot = guard.clone();
2378 drop(guard);
2379 let drain_timeout = *self.inner.effective_drain_timeout.lock().map_err(|_| {
2380 SuperviseError::StatePoisoned {
2381 module_id: Some(self.inner.module_id.clone()),
2382 }
2383 })?;
2384 let registration_active = self
2385 .inner
2386 .registry
2387 .get_module(&self.inner.module_id)
2388 .map_err(SuperviseError::Registry)?
2389 .is_some();
2390 let protocol = self.declared_protocol()?;
2391 let running_process =
2392 snapshot.enabled && snapshot.state == ModuleState::Running && snapshot.process_alive;
2393 let live = match protocol {
2399 ModuleProtocol::Subc => running_process && registration_active,
2400 ModuleProtocol::None => running_process,
2401 };
2402
2403 Ok(ModuleStatus {
2404 module_id: self.inner.module_id.clone(),
2405 state: snapshot.state,
2406 enabled: snapshot.enabled,
2407 process_alive: snapshot.process_alive,
2408 registration_active,
2409 protocol,
2410 live,
2411 restart_count,
2412 lifetime_restarts: snapshot.lifetime_restarts,
2413 spawn_generation: snapshot.spawn_generation,
2414 max_restarts: self.inner.restart_policy.max_restarts,
2415 restart_window: self.inner.restart_policy.window,
2416 drain_timeout,
2417 restart_backoff: self.inner.restart_policy.backoff,
2418 restart_max_backoff: self.inner.restart_policy.max_backoff,
2419 pid: snapshot.pid,
2420 spawned_at_ms: snapshot.spawned_at_ms,
2421 spawned_from: snapshot.spawned_from,
2422 process_start_time: snapshot.process_start_time,
2423 last_exit: snapshot.last_exit,
2424 health: snapshot.health,
2425 })
2426 }
2427
2428 #[cfg(test)]
2429 pub(crate) fn hold_snapshot_for_test(
2430 &self,
2431 acquired: std::sync::mpsc::Sender<()>,
2432 hold: Duration,
2433 ) -> std::thread::JoinHandle<()> {
2434 let snapshot = Arc::clone(&self.inner.snapshot);
2435 std::thread::spawn(move || {
2436 let _guard = snapshot.lock().expect("test snapshot lock is not poisoned");
2437 acquired
2438 .send(())
2439 .expect("test receiver waits for snapshot lock");
2440 std::thread::sleep(hold);
2441 })
2442 }
2443
2444 pub(crate) async fn running_image_agreement(&self) -> subc_control::RunningImageAgreement {
2445 let snapshot = match lock_snapshot(&self.inner.snapshot) {
2446 Ok(snapshot) => snapshot.clone(),
2447 Err(_) => {
2448 return subc_control::RunningImageAgreement::Unavailable {
2449 reason: subc_control::RunningImageUnavailableReason::NotRunning,
2450 };
2451 }
2452 };
2453 self.inner
2454 .provenance_probe
2455 .observe(
2456 snapshot.pid,
2457 snapshot.spawned_from.as_deref(),
2458 snapshot.spawned_file_identity,
2459 snapshot.process_start_time,
2460 )
2461 .await
2462 }
2463
2464 pub(crate) fn will_recover_after_connection_loss(&self) -> Result<bool, SuperviseError> {
2465 let mut snapshot = lock_snapshot(&self.inner.snapshot)?;
2466 Ok(match snapshot.state {
2467 ModuleState::Restarting => true,
2468 ModuleState::Failed | ModuleState::Disabled => false,
2469 _ => daemon_will_restart(&mut snapshot, &self.inner.restart_policy, Instant::now()),
2470 })
2471 }
2472
2473 #[cfg(test)]
2474 pub(crate) fn is_warming(&self) -> Result<bool, SuperviseError> {
2475 self.is_warming_with_snapshot_lock(None)
2476 }
2477
2478 pub(crate) fn is_warming_for_control(
2479 &self,
2480 caller: &'static str,
2481 ) -> Result<bool, SuperviseError> {
2482 self.is_warming_with_snapshot_lock(Some(caller))
2483 }
2484
2485 fn is_warming_with_snapshot_lock(
2486 &self,
2487 caller: Option<&'static str>,
2488 ) -> Result<bool, SuperviseError> {
2489 let snapshot = match caller {
2490 Some(caller) => {
2491 lock_snapshot_for_control(&self.inner.snapshot, &self.inner.module_id, caller)?
2492 }
2493 None => lock_snapshot(&self.inner.snapshot)?,
2494 }
2495 .clone();
2496 Ok(matches!(
2497 snapshot.state,
2498 ModuleState::Starting | ModuleState::Running | ModuleState::Restarting
2499 ))
2500 }
2501
2502 pub async fn drain(&self) -> Result<(), SuperviseError> {
2504 self.stop().await
2505 }
2506
2507 pub(crate) async fn retire(&self) -> Result<(), SuperviseError> {
2508 match self.state()? {
2509 ModuleState::Stopped | ModuleState::Failed => return Ok(()),
2510 ModuleState::Starting
2511 | ModuleState::Running
2512 | ModuleState::Unresponsive
2513 | ModuleState::Restarting
2514 | ModuleState::Draining
2515 | ModuleState::Disabled => {}
2516 }
2517
2518 let (reply_tx, reply_rx) = oneshot::channel();
2519 self.inner
2520 .commands
2521 .send(SupervisorCommand::Retire { reply: reply_tx })
2522 .await
2523 .map_err(|_| SuperviseError::CommandClosed {
2524 module_id: self.inner.module_id.clone(),
2525 })?;
2526 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
2527 module_id: self.inner.module_id.clone(),
2528 })?
2529 }
2530
2531 pub async fn stop(&self) -> Result<(), SuperviseError> {
2532 match self.state()? {
2533 ModuleState::Stopped | ModuleState::Failed => return Ok(()),
2534 ModuleState::Starting
2535 | ModuleState::Running
2536 | ModuleState::Unresponsive
2537 | ModuleState::Restarting
2538 | ModuleState::Draining
2539 | ModuleState::Disabled => {}
2540 }
2541
2542 let (reply_tx, reply_rx) = oneshot::channel();
2543 self.inner
2544 .commands
2545 .send(SupervisorCommand::Drain { reply: reply_tx })
2546 .await
2547 .map_err(|_| SuperviseError::CommandClosed {
2548 module_id: self.inner.module_id.clone(),
2549 })?;
2550 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
2551 module_id: self.inner.module_id.clone(),
2552 })?
2553 }
2554
2555 pub async fn restart(&self, drain_timeout_ms: Option<u64>) -> Result<(), SuperviseError> {
2556 let (reply_tx, reply_rx) = oneshot::channel();
2557 self.inner
2558 .commands
2559 .send(SupervisorCommand::Restart {
2560 drain_timeout_ms,
2561 reply: reply_tx,
2562 })
2563 .await
2564 .map_err(|_| SuperviseError::CommandClosed {
2565 module_id: self.inner.module_id.clone(),
2566 })?;
2567 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
2568 module_id: self.inner.module_id.clone(),
2569 })?
2570 }
2571
2572 pub async fn swap(&self, ready_timeout: Option<Duration>) -> Result<(), SuperviseError> {
2577 let (reply_tx, reply_rx) = oneshot::channel();
2578 self.inner
2579 .commands
2580 .send(SupervisorCommand::Swap {
2581 ready_timeout,
2582 reply: reply_tx,
2583 })
2584 .await
2585 .map_err(|_| SuperviseError::CommandClosed {
2586 module_id: self.inner.module_id.clone(),
2587 })?;
2588 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
2589 module_id: self.inner.module_id.clone(),
2590 })?
2591 }
2592
2593 pub async fn reload(&self) -> Result<(), SuperviseError> {
2594 let (reply_tx, reply_rx) = oneshot::channel();
2595 self.inner
2596 .commands
2597 .send(SupervisorCommand::Reload { reply: reply_tx })
2598 .await
2599 .map_err(|_| SuperviseError::CommandClosed {
2600 module_id: self.inner.module_id.clone(),
2601 })?;
2602 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
2603 module_id: self.inner.module_id.clone(),
2604 })?
2605 }
2606
2607 pub async fn set_enabled(&self, enabled: bool) -> Result<bool, SuperviseError> {
2608 let (reply_tx, reply_rx) = oneshot::channel();
2609 self.inner
2610 .commands
2611 .send(SupervisorCommand::SetEnabled {
2612 enabled,
2613 reply: reply_tx,
2614 })
2615 .await
2616 .map_err(|_| SuperviseError::CommandClosed {
2617 module_id: self.inner.module_id.clone(),
2618 })?;
2619 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
2620 module_id: self.inner.module_id.clone(),
2621 })?
2622 }
2623
2624 pub(crate) fn declared_protocol(&self) -> Result<ModuleProtocol, SuperviseError> {
2629 Ok(self
2630 .inner
2631 .configuration
2632 .lock()
2633 .map_err(|_| SuperviseError::StatePoisoned {
2634 module_id: Some(self.inner.module_id.clone()),
2635 })?
2636 .spec
2637 .protocol)
2638 }
2639
2640 pub(crate) fn configuration(&self) -> Result<(ModuleSpec, HealthConfig), SuperviseError> {
2641 let configuration =
2642 self.inner
2643 .configuration
2644 .lock()
2645 .map_err(|_| SuperviseError::StatePoisoned {
2646 module_id: Some(self.inner.module_id.clone()),
2647 })?;
2648 Ok((configuration.spec.clone(), configuration.health))
2649 }
2650
2651 #[cfg(any(test, feature = "test-support"))]
2655 pub async fn update_spec_for_test(&self, spec: ModuleSpec) -> Result<(), SuperviseError> {
2656 let (_, health) = self.configuration()?;
2657 let drain_timeout_ms = u64::try_from(
2658 self.inner
2659 .effective_drain_timeout
2660 .lock()
2661 .unwrap_or_else(|poisoned| poisoned.into_inner())
2662 .as_millis(),
2663 )
2664 .ok();
2665 self.update_configuration(spec, health, drain_timeout_ms)
2666 .await
2667 }
2668
2669 pub(crate) async fn update_configuration(
2670 &self,
2671 spec: ModuleSpec,
2672 health: HealthConfig,
2673 drain_timeout_ms: Option<u64>,
2674 ) -> Result<(), SuperviseError> {
2675 if spec.module_id != self.inner.module_id {
2676 return Err(SuperviseError::InvalidSpec {
2677 reason: "a supervised module's module_id cannot be changed".to_string(),
2678 });
2679 }
2680 validate_spec(&spec)?;
2681 let (reply_tx, reply_rx) = oneshot::channel();
2682 self.inner
2683 .commands
2684 .send(SupervisorCommand::UpdateConfiguration {
2685 spec: spec.clone(),
2686 health,
2687 drain_timeout_ms,
2688 reply: reply_tx,
2689 })
2690 .await
2691 .map_err(|_| SuperviseError::CommandClosed {
2692 module_id: self.inner.module_id.clone(),
2693 })?;
2694 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
2695 module_id: self.inner.module_id.clone(),
2696 })?;
2697 let mut configuration =
2698 self.inner
2699 .configuration
2700 .lock()
2701 .map_err(|_| SuperviseError::StatePoisoned {
2702 module_id: Some(self.inner.module_id.clone()),
2703 })?;
2704 configuration.spec = spec;
2705 configuration.health = health;
2706 Ok(())
2707 }
2708}
2709
2710impl Drop for SupervisedModuleInner {
2711 fn drop(&mut self) {
2712 let Ok(mut monitor) = self.monitor.lock() else {
2713 return;
2714 };
2715 if let Some(monitor) = monitor.as_ref().filter(|monitor| !monitor.is_finished()) {
2716 let _ = update_snapshot(&self.snapshot, Some(&self.module_id), |state| {
2717 state.state = ModuleState::Stopped;
2718 clear_current_process_facts(state);
2719 });
2720 monitor.abort();
2721 }
2722 let _ = monitor.take();
2723 }
2724}
2725
2726#[derive(Debug)]
2727enum SupervisorCommand {
2728 Drain {
2729 reply: oneshot::Sender<Result<(), SuperviseError>>,
2730 },
2731 Retire {
2732 reply: oneshot::Sender<Result<(), SuperviseError>>,
2733 },
2734 Restart {
2735 drain_timeout_ms: Option<u64>,
2740 reply: oneshot::Sender<Result<(), SuperviseError>>,
2741 },
2742 Reload {
2743 reply: oneshot::Sender<Result<(), SuperviseError>>,
2744 },
2745 SetEnabled {
2746 enabled: bool,
2747 reply: oneshot::Sender<Result<bool, SuperviseError>>,
2748 },
2749 UpdateConfiguration {
2750 spec: ModuleSpec,
2751 health: HealthConfig,
2752 drain_timeout_ms: Option<u64>,
2755 reply: oneshot::Sender<()>,
2756 },
2757 Swap {
2758 ready_timeout: Option<Duration>,
2761 reply: oneshot::Sender<Result<(), SuperviseError>>,
2763 },
2764}
2765
2766#[derive(Debug)]
2767pub enum SuperviseError {
2768 InvalidSpec {
2769 reason: String,
2770 },
2771 Spawn {
2772 program: PathBuf,
2773 source: io::Error,
2774 cgroup_path: Option<PathBuf>,
2775 },
2776 Cgroup {
2777 module_id: String,
2778 source: io::Error,
2779 },
2780 LaunchNonce {
2783 reason: String,
2784 },
2785 Wait {
2786 module_id: String,
2787 source: io::Error,
2788 },
2789 Kill {
2790 module_id: String,
2791 source: io::Error,
2792 },
2793 Forwarding(ForwardingError),
2794 Registry(RegistryError),
2795 ReloadUnavailable {
2796 module_id: String,
2797 reason: String,
2798 },
2799 Disabled {
2804 module_id: String,
2805 },
2806 ReloadFailed {
2807 module_id: String,
2808 reason: String,
2809 },
2810 RegistrationStillActive {
2811 module_id: String,
2812 waited: Duration,
2813 },
2814 StatePoisoned {
2815 module_id: Option<String>,
2816 },
2817 CommandClosed {
2818 module_id: String,
2819 },
2820 SwapInProgress {
2824 module_id: String,
2825 },
2826 SwapRefused {
2828 module_id: String,
2829 reason: SwapRefusal,
2830 },
2831 SwapFailed {
2835 module_id: String,
2836 arm: SwapFailureArm,
2837 detail: String,
2838 candidate_exit: Option<ExitReport>,
2841 },
2842}
2843
2844#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2846pub enum SwapRefusal {
2847 OverlapExclusive,
2849 NotRegistered,
2852 ProtocolNone,
2855 NotConfigured,
2858 AlreadySwapping,
2860}
2861
2862impl SwapRefusal {
2863 pub fn as_str(self) -> &'static str {
2864 match self {
2865 Self::OverlapExclusive => "overlap_exclusive",
2866 Self::NotRegistered => "not_registered",
2867 Self::ProtocolNone => "protocol_none",
2868 Self::NotConfigured => "not_configured",
2869 Self::AlreadySwapping => "already_swapping",
2870 }
2871 }
2872}
2873
2874#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2877pub enum SwapFailureArm {
2878 SpawnFailed,
2880 NeverRegistered,
2882 NeverReady,
2884 CandidateExited,
2886 CandidateUnhealthy,
2888 Interrupted,
2892 CutoverLost,
2897}
2898
2899impl SwapFailureArm {
2900 pub fn as_str(self) -> &'static str {
2901 match self {
2902 Self::SpawnFailed => "spawn_failed",
2903 Self::NeverRegistered => "never_registered",
2904 Self::NeverReady => "never_ready",
2905 Self::CandidateExited => "candidate_exited",
2906 Self::CandidateUnhealthy => "candidate_unhealthy",
2907 Self::Interrupted => "interrupted",
2908 Self::CutoverLost => "cutover_lost",
2909 }
2910 }
2911}
2912
2913impl fmt::Display for SuperviseError {
2914 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2915 match self {
2916 Self::InvalidSpec { reason } => write!(f, "invalid module spec: {reason}"),
2917 Self::Spawn {
2918 program,
2919 source,
2920 cgroup_path: Some(cgroup_path),
2921 } => write!(
2922 f,
2923 "failed to place module in cgroup '{}' while spawning '{}': {source}",
2924 cgroup_path.display(),
2925 program.display()
2926 ),
2927 Self::Spawn {
2928 program,
2929 source,
2930 cgroup_path: None,
2931 } => write!(
2932 f,
2933 "failed to spawn module '{}': {source}",
2934 program.display()
2935 ),
2936 Self::Cgroup { module_id, source } => {
2937 write!(
2938 f,
2939 "failed to prepare cgroup for module '{module_id}': {source}"
2940 )
2941 }
2942 Self::LaunchNonce { reason } => {
2943 write!(
2944 f,
2945 "failed to generate reserved-module launch nonce: {reason}"
2946 )
2947 }
2948 Self::Wait { module_id, source } => {
2949 write!(f, "failed to wait for module '{module_id}': {source}")
2950 }
2951 Self::Kill { module_id, source } => {
2952 write!(f, "failed to kill module '{module_id}': {source}")
2953 }
2954 Self::Forwarding(err) => write!(f, "forwarding error: {err}"),
2955 Self::Registry(err) => write!(f, "registry error: {err}"),
2956 Self::ReloadUnavailable { module_id, reason } => {
2957 write!(f, "reload unavailable for module '{module_id}': {reason}")
2958 }
2959 Self::Disabled { module_id } => {
2960 write!(
2961 f,
2962 "module '{module_id}' is disabled; enable it before restart or reload"
2963 )
2964 }
2965 Self::ReloadFailed { module_id, reason } => {
2966 write!(f, "reload failed for module '{module_id}': {reason}")
2967 }
2968 Self::RegistrationStillActive { module_id, waited } => write!(
2969 f,
2970 "module '{module_id}' registration remained active after waiting {waited:?}"
2971 ),
2972 Self::StatePoisoned { module_id } => match module_id {
2973 Some(module_id) => {
2974 write!(f, "supervisor state for module '{module_id}' was poisoned")
2975 }
2976 None => write!(f, "supervisor state was poisoned"),
2977 },
2978 Self::CommandClosed { module_id } => {
2979 write!(
2980 f,
2981 "supervisor command channel for module '{module_id}' is closed"
2982 )
2983 }
2984 Self::SwapInProgress { module_id } => write!(
2985 f,
2986 "module '{module_id}' is being swapped; retry once the swap has cut over or failed, or stop the module to abort the swap"
2987 ),
2988 Self::SwapRefused { module_id, reason } => match reason {
2989 SwapRefusal::OverlapExclusive => write!(
2990 f,
2991 "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"
2992 ),
2993 SwapRefusal::NotRegistered => write!(
2994 f,
2995 "module '{module_id}' is not registered, so there is no serving process to keep while a replacement warms; use a plain restart"
2996 ),
2997 SwapRefusal::ProtocolNone => write!(
2998 f,
2999 "module '{module_id}' is protocol: \"none\" and never registers, so a swap could never see its replacement become ready; use a plain restart"
3000 ),
3001 SwapRefusal::NotConfigured => write!(
3002 f,
3003 "module '{module_id}' cannot be swapped: the supervisor was built without the forwarding table or shared handle a swap needs"
3004 ),
3005 SwapRefusal::AlreadySwapping => {
3006 write!(f, "module '{module_id}' is already being swapped")
3007 }
3008 },
3009 Self::SwapFailed {
3010 module_id,
3011 arm,
3012 detail,
3013 ..
3014 } => write!(
3015 f,
3016 "swap of module '{module_id}' failed ({}): {detail}; the running process was left serving",
3017 arm.as_str()
3018 ),
3019 }
3020 }
3021}
3022
3023impl Error for SuperviseError {
3024 fn source(&self) -> Option<&(dyn Error + 'static)> {
3025 match self {
3026 Self::Spawn { source, .. }
3027 | Self::Cgroup { source, .. }
3028 | Self::Wait { source, .. }
3029 | Self::Kill { source, .. } => Some(source),
3030 Self::Forwarding(err) => Some(err),
3031 Self::Registry(err) => Some(err),
3032 Self::LaunchNonce { .. }
3033 | Self::InvalidSpec { .. }
3034 | Self::ReloadUnavailable { .. }
3035 | Self::Disabled { .. }
3036 | Self::ReloadFailed { .. }
3037 | Self::RegistrationStillActive { .. }
3038 | Self::StatePoisoned { .. }
3039 | Self::CommandClosed { .. }
3040 | Self::SwapInProgress { .. }
3041 | Self::SwapRefused { .. }
3042 | Self::SwapFailed { .. } => None,
3043 }
3044 }
3045}
3046
3047pub(crate) fn validate_spec(spec: &ModuleSpec) -> Result<(), SuperviseError> {
3048 if spec.module_id.trim().is_empty() {
3049 return Err(SuperviseError::InvalidSpec {
3050 reason: "module_id must not be empty".to_string(),
3051 });
3052 }
3053
3054 Ok(())
3055}
3056
3057#[derive(Debug, Default)]
3058struct HealthProbeRuntime {
3059 registered_connection: Option<crate::ConnectionId>,
3060 advertised: bool,
3061 next_probe_at: Option<Instant>,
3062 probe_index: u64,
3063}
3064
3065impl HealthProbeRuntime {
3066 fn refresh_registration(
3067 &mut self,
3068 spec: &ModuleSpec,
3069 runtime: &SupervisorRuntimeConfig,
3070 registry: &Registry,
3071 snapshot: &SharedSnapshot,
3072 ) {
3073 if spec.protocol == ModuleProtocol::None {
3085 self.registered_connection = None;
3086 self.advertised = false;
3087 self.next_probe_at = None;
3088 return;
3089 }
3090
3091 let registration = match registry.get_module(&spec.module_id) {
3092 Ok(registration) => registration,
3093 Err(err) => {
3094 warn!(module_id = %spec.module_id, error = %err, "health prober could not read registry");
3095 self.advertised = false;
3096 self.next_probe_at = None;
3097 return;
3098 }
3099 };
3100
3101 let Some(registration) = registration else {
3102 self.registered_connection = None;
3103 self.advertised = false;
3104 self.next_probe_at = None;
3105 return;
3106 };
3107
3108 let advertised = registration
3109 .control_ops
3110 .iter()
3111 .any(|op| op == MODULE_CONTROL_OP_HEALTH_CHECK);
3112 if !advertised {
3113 self.registered_connection = Some(registration.connection_id);
3114 self.advertised = false;
3115 self.next_probe_at = None;
3116 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
3117 state.health.status = SupervisorHealthStatus::Unknown;
3118 state.health.consecutive_failures = 0;
3119 state.health.last_probe_ms = None;
3120 state.health.detail = None;
3121 state.health.metrics = None;
3122 });
3123 return;
3124 }
3125
3126 let reregistered = self.registered_connection != Some(registration.connection_id);
3127 self.registered_connection = Some(registration.connection_id);
3128 self.advertised = true;
3129 if reregistered || self.next_probe_at.is_none() {
3130 self.probe_index = 0;
3131 self.next_probe_at = Some(
3132 Instant::now() + jittered_health_delay(&spec.module_id, 0, runtime.health.cadence),
3133 );
3134 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
3135 state.health.status = SupervisorHealthStatus::Unknown;
3136 state.health.consecutive_failures = 0;
3137 state.health.detail = None;
3138 state.health.metrics = None;
3139 });
3140 }
3141 }
3142
3143 fn wake_after(&self) -> Duration {
3144 if !self.advertised {
3145 return REGISTRY_RELEASE_POLL;
3146 }
3147 self.next_probe_at
3148 .map(|next| next.saturating_duration_since(Instant::now()))
3149 .unwrap_or(REGISTRY_RELEASE_POLL)
3150 }
3151
3152 fn due(&self) -> bool {
3153 self.advertised
3154 && self
3155 .next_probe_at
3156 .is_some_and(|next| Instant::now() >= next)
3157 }
3158
3159 fn schedule_next(&mut self, spec: &ModuleSpec, cadence: Duration) {
3160 self.probe_index = self.probe_index.wrapping_add(1);
3161 self.next_probe_at = Some(
3162 Instant::now() + jittered_health_delay(&spec.module_id, self.probe_index, cadence),
3163 );
3164 }
3165}
3166
3167#[derive(Debug)]
3202enum HealthProbeEvidence {
3203 LaneDead,
3205 NoAnswer,
3207 BadAnswer,
3209 Misconfigured,
3211}
3212
3213#[derive(Debug)]
3214struct HealthProbeError {
3215 evidence: HealthProbeEvidence,
3216 message: String,
3217}
3218
3219impl HealthProbeError {
3220 fn lane_dead(message: impl Into<String>) -> Self {
3221 Self::with(HealthProbeEvidence::LaneDead, message)
3222 }
3223
3224 fn no_answer(message: impl Into<String>) -> Self {
3225 Self::with(HealthProbeEvidence::NoAnswer, message)
3226 }
3227
3228 fn bad_answer(message: impl Into<String>) -> Self {
3229 Self::with(HealthProbeEvidence::BadAnswer, message)
3230 }
3231
3232 fn misconfigured(message: impl Into<String>) -> Self {
3233 Self::with(HealthProbeEvidence::Misconfigured, message)
3234 }
3235
3236 fn with(evidence: HealthProbeEvidence, message: impl Into<String>) -> Self {
3237 Self {
3238 evidence,
3239 message: message.into(),
3240 }
3241 }
3242
3243 #[allow(dead_code)]
3257 fn is_proof_of_death(&self) -> bool {
3258 matches!(self.evidence, HealthProbeEvidence::LaneDead)
3259 }
3260
3261 fn label(&self) -> &'static str {
3269 match self.evidence {
3270 HealthProbeEvidence::LaneDead => "lane-dead",
3271 HealthProbeEvidence::NoAnswer => "no-answer",
3272 HealthProbeEvidence::BadAnswer => "bad-answer",
3273 HealthProbeEvidence::Misconfigured => "daemon-misconfigured",
3274 }
3275 }
3276}
3277
3278impl fmt::Display for HealthProbeError {
3279 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3280 f.write_str(&self.message)
3281 }
3282}
3283
3284async fn run_health_probe_cycle(
3285 spec: &ModuleSpec,
3286 runtime: &SupervisorRuntimeConfig,
3287 registry: &Registry,
3288 process_liveness: &SupervisorProcessLiveness,
3289 snapshot: &SharedSnapshot,
3290 child: &mut Option<SupervisedChild>,
3291) {
3292 let now_ms = unix_ms_now();
3293 match probe_module_health(&spec.module_id, runtime, None).await {
3294 Ok(report) => {
3295 handle_health_report(
3296 spec,
3297 runtime,
3298 registry,
3299 process_liveness,
3300 snapshot,
3301 child,
3302 report,
3303 now_ms,
3304 )
3305 .await;
3306 }
3307 Err(err) => {
3308 handle_health_probe_failure(
3309 spec,
3310 runtime,
3311 registry,
3312 process_liveness,
3313 snapshot,
3314 child,
3315 err,
3316 now_ms,
3317 )
3318 .await;
3319 }
3320 }
3321}
3322
3323async fn probe_module_health(
3324 module_id: &str,
3325 runtime: &SupervisorRuntimeConfig,
3326 drain_deadline: Option<Instant>,
3327) -> Result<HealthReport, HealthProbeError> {
3328 let Some(forwarding) = runtime.forwarding.as_ref() else {
3329 return Err(HealthProbeError::misconfigured(
3330 "supervisor was not configured with a forwarding table",
3331 ));
3332 };
3333 let probe_started_at = Instant::now();
3334 let mut deadline = probe_started_at + runtime.health.deadline;
3335 if let Some(drain_deadline) = drain_deadline {
3336 deadline = deadline.min(drain_deadline);
3337 }
3338 let pending = if drain_deadline.is_some() {
3339 forwarding.begin_drain_health_probe_rpc_for(
3340 module_id,
3341 MODULE_CONTROL_OP_HEALTH_CHECK,
3342 probe_started_at,
3343 deadline,
3344 )
3345 } else {
3346 forwarding.begin_health_probe_rpc_for(
3347 module_id,
3348 MODULE_CONTROL_OP_HEALTH_CHECK,
3349 probe_started_at,
3350 deadline,
3351 )
3352 }
3353 .map_err(|err| {
3354 HealthProbeError::lane_dead(format!("failed to begin health.check RPC: {err}"))
3357 })?;
3358 await_health_probe(forwarding, pending, deadline, runtime.health.deadline).await
3359}
3360
3361async fn probe_endpoint_health(
3368 endpoint: crate::ModuleEndpointId,
3369 runtime: &SupervisorRuntimeConfig,
3370 deadline_cap: Option<Instant>,
3371) -> Result<HealthReport, HealthProbeError> {
3372 let Some(forwarding) = runtime.forwarding.as_ref() else {
3373 return Err(HealthProbeError::misconfigured(
3374 "supervisor was not configured with a forwarding table",
3375 ));
3376 };
3377 let probe_started_at = Instant::now();
3378 let mut deadline = probe_started_at + runtime.health.deadline;
3379 if let Some(cap) = deadline_cap {
3380 deadline = deadline.min(cap);
3381 }
3382 let pending = forwarding
3383 .begin_endpoint_health_probe_rpc_for(
3384 endpoint,
3385 MODULE_CONTROL_OP_HEALTH_CHECK,
3386 probe_started_at,
3387 deadline,
3388 )
3389 .map_err(|err| {
3390 HealthProbeError::lane_dead(format!("failed to begin health.check RPC: {err}"))
3391 })?;
3392 await_health_probe(forwarding, pending, deadline, runtime.health.deadline).await
3393}
3394
3395async fn await_health_probe(
3397 forwarding: &ForwardingTable,
3398 pending: PendingModuleControlRpc,
3399 deadline: Instant,
3400 probe_budget: Duration,
3401) -> Result<HealthReport, HealthProbeError> {
3402 let PendingModuleControlRpc {
3403 endpoint,
3404 module_sink,
3405 negotiated_ver,
3406 corr,
3407 receiver,
3408 } = pending;
3409 let body = serde_json::to_vec(&ModuleControlRequest::HealthCheck {}).map_err(|err| {
3410 HealthProbeError::misconfigured(format!("failed to encode health.check: {err}"))
3411 })?;
3412 let frame = Frame::build_with_version(
3413 negotiated_ver,
3414 FrameType::Request,
3415 control_flags(),
3416 0,
3417 0,
3418 corr,
3419 body,
3420 )
3421 .map_err(|err| {
3422 HealthProbeError::misconfigured(format!("failed to build health.check frame: {err}"))
3423 })?;
3424
3425 match timeout_at(deadline, module_sink.send(frame)).await {
3431 Ok(Ok(())) => {}
3432 Ok(Err(err)) => {
3433 let _ = forwarding.cancel_module_control_rpc(endpoint, corr);
3434 return Err(HealthProbeError::lane_dead(format!(
3437 "failed to send health.check: {err}"
3438 )));
3439 }
3440 Err(_elapsed) => {
3441 let _ = forwarding.cancel_module_control_rpc(endpoint, corr);
3442 return Err(HealthProbeError::no_answer(
3446 "health.check send timed out before enqueue (module egress full)",
3447 ));
3448 }
3449 }
3450
3451 match timeout_at(deadline, receiver).await {
3452 Ok(Ok(ModuleControlRpcOutcome::Response(response))) => {
3456 response.health_report().ok_or_else(|| {
3457 HealthProbeError::bad_answer("health.check RPC returned a non-health response")
3458 })
3459 }
3460 Ok(Ok(ModuleControlRpcOutcome::Rejected(body))) => Err(HealthProbeError::bad_answer(
3461 format!("health.check rejected: {}", body.message),
3462 )),
3463 Ok(Ok(ModuleControlRpcOutcome::ModuleGone(message))) => {
3464 Err(HealthProbeError::lane_dead(message))
3465 }
3466 Ok(Ok(ModuleControlRpcOutcome::MalformedResponse(message))) => {
3467 Err(HealthProbeError::bad_answer(message))
3468 }
3469 Ok(Ok(ModuleControlRpcOutcome::UnexpectedOp { expected, actual })) => {
3470 Err(HealthProbeError::bad_answer(format!(
3471 "expected module-control op '{expected}', got '{actual}'"
3472 )))
3473 }
3474 Ok(Ok(ModuleControlRpcOutcome::DeadlineElapsed)) => Err(HealthProbeError::bad_answer(
3478 "module answered health.check after its daemon deadline",
3479 )),
3480 Ok(Err(_)) => Err(HealthProbeError::misconfigured(
3481 "health.check waiter was canceled before the module responded",
3482 )),
3483 Err(_) => {
3484 let _ = forwarding.tombstone_health_probe_rpc(endpoint, corr);
3485 Err(HealthProbeError::no_answer(format!(
3486 "module did not answer health.check within {probe_budget:?}"
3487 )))
3488 }
3489 }
3490}
3491
3492#[allow(clippy::too_many_arguments)]
3493async fn handle_health_report(
3494 spec: &ModuleSpec,
3495 runtime: &SupervisorRuntimeConfig,
3496 registry: &Registry,
3497 process_liveness: &SupervisorProcessLiveness,
3498 snapshot: &SharedSnapshot,
3499 child: &mut Option<SupervisedChild>,
3500 report: HealthReport,
3501 now_ms: u64,
3502) {
3503 let status = supervisor_health_status(report.status);
3504 let detail = report.detail.clone();
3505 let metrics = truncate_health_metrics(report.metrics);
3506 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
3507 state.health.status = status;
3508 state.health.last_probe_ms = Some(now_ms);
3509 state.health.detail = detail.clone();
3510 state.health.metrics = metrics.clone();
3511 state.health.consecutive_failures = 0;
3512 });
3513
3514 let action = match report.status {
3515 HealthStatus::Ok => return,
3516 HealthStatus::Degraded => runtime.health.on_degraded,
3517 HealthStatus::Failing => runtime.health.on_failing,
3518 };
3519 apply_l3_health_action(
3520 spec,
3521 runtime,
3522 registry,
3523 process_liveness,
3524 snapshot,
3525 child,
3526 status,
3527 detail.as_deref(),
3528 action,
3529 now_ms,
3530 )
3531 .await;
3532}
3533
3534#[allow(clippy::too_many_arguments)]
3535async fn handle_health_probe_failure(
3536 spec: &ModuleSpec,
3537 runtime: &SupervisorRuntimeConfig,
3538 registry: &Registry,
3539 process_liveness: &SupervisorProcessLiveness,
3540 snapshot: &SharedSnapshot,
3541 child: &mut Option<SupervisedChild>,
3542 err: HealthProbeError,
3543 now_ms: u64,
3544) {
3545 let threshold = runtime.health.failure_threshold.max(1);
3546 let mut failures = 0;
3547 let detail = format!("[{}] {err}", err.label());
3552 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
3553 state.health.last_probe_ms = Some(now_ms);
3554 state.health.consecutive_failures = state.health.consecutive_failures.saturating_add(1);
3555 state.health.detail = Some(detail.clone());
3556 state.health.metrics = None;
3557 failures = state.health.consecutive_failures;
3558 });
3559
3560 if failures < threshold {
3561 warn!(
3562 module_id = %spec.module_id,
3563 consecutive_failures = failures,
3564 threshold,
3565 evidence = err.label(),
3566 detail = %detail,
3567 "health.check probe failed"
3568 );
3569 return;
3570 }
3571
3572 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
3573 state.state = ModuleState::Unresponsive;
3574 state.health.status = SupervisorHealthStatus::Unresponsive;
3575 });
3576 if runtime.health.critical {
3580 error!(
3581 module_id = %spec.module_id,
3582 status = "unresponsive",
3583 evidence = err.label(),
3584 detail = %detail,
3585 "critical module health alert"
3586 );
3587 } else {
3588 warn!(
3589 module_id = %spec.module_id,
3590 status = "unresponsive",
3591 evidence = err.label(),
3592 detail = %detail,
3593 "module health threshold breached"
3594 );
3595 }
3596 if let Err(err) = health_restart_child(
3597 spec,
3598 runtime,
3599 registry,
3600 process_liveness,
3601 snapshot,
3602 child,
3603 SupervisorHealthStatus::Unresponsive,
3604 Some(&detail),
3605 now_ms,
3606 )
3607 .await
3608 {
3609 error!(module_id = %spec.module_id, error = %err, "health-triggered restart failed");
3610 }
3611}
3612
3613#[allow(clippy::too_many_arguments)]
3614async fn apply_l3_health_action(
3615 spec: &ModuleSpec,
3616 runtime: &SupervisorRuntimeConfig,
3617 registry: &Registry,
3618 process_liveness: &SupervisorProcessLiveness,
3619 snapshot: &SharedSnapshot,
3620 child: &mut Option<SupervisedChild>,
3621 status: SupervisorHealthStatus,
3622 detail: Option<&str>,
3623 action: HealthAction,
3624 now_ms: u64,
3625) {
3626 record_health_action(snapshot, &spec.module_id, action.to_string(), now_ms);
3627 match action {
3628 HealthAction::Report => {
3629 info!(
3630 module_id = %spec.module_id,
3631 status = ?status,
3632 detail,
3633 "module reported non-ok health"
3634 );
3635 }
3636 HealthAction::Alert => {
3637 error!(
3638 module_id = %spec.module_id,
3639 status = ?status,
3640 detail,
3641 "module health alert"
3642 );
3643 }
3644 HealthAction::Restart => {
3645 if let Err(err) = health_restart_child(
3646 spec,
3647 runtime,
3648 registry,
3649 process_liveness,
3650 snapshot,
3651 child,
3652 status,
3653 detail,
3654 now_ms,
3655 )
3656 .await
3657 {
3658 error!(module_id = %spec.module_id, error = %err, "health-triggered restart failed");
3659 }
3660 }
3661 }
3662}
3663
3664#[allow(clippy::too_many_arguments)]
3665async fn health_restart_child(
3666 spec: &ModuleSpec,
3667 runtime: &SupervisorRuntimeConfig,
3668 registry: &Registry,
3669 process_liveness: &SupervisorProcessLiveness,
3670 snapshot: &SharedSnapshot,
3671 child: &mut Option<SupervisedChild>,
3672 status: SupervisorHealthStatus,
3673 detail: Option<&str>,
3674 now_ms: u64,
3675) -> Result<(), SuperviseError> {
3676 let (enabled, schedule) = {
3677 let mut state = lock_snapshot(snapshot)?;
3678 let enabled = state.enabled;
3679 let schedule = if enabled {
3680 state.next_crash_restart(&runtime.restart_policy, Instant::now())
3681 } else {
3682 None
3683 };
3684 (enabled, schedule)
3685 };
3686
3687 if !enabled {
3688 return Err(SuperviseError::Disabled {
3689 module_id: spec.module_id.clone(),
3690 });
3691 }
3692
3693 if schedule.is_none() {
3694 record_health_action(snapshot, &spec.module_id, "disabled".to_string(), now_ms);
3695 error!(
3696 module_id = %spec.module_id,
3697 status = ?status,
3698 detail,
3699 max_restarts = runtime.restart_policy.max_restarts,
3700 window_secs = runtime.restart_policy.window.as_secs(),
3701 "health restart budget exhausted; disabling module"
3702 );
3703 begin_forwarding_drain_if_configured(
3704 spec,
3705 runtime,
3706 registry,
3707 snapshot,
3708 Some(false),
3709 RouteCloseReason::Disable,
3710 )
3711 .await?;
3712 drain_optional_child(
3713 &spec.module_id,
3714 spec.protocol,
3715 registry,
3716 snapshot,
3717 &runtime.terminal_ring,
3718 &runtime.spawn_events,
3719 child,
3720 runtime.drain_timeout,
3721 ModuleState::Disabled,
3722 Some(false),
3723 )
3724 .await?;
3725 process_liveness.untrack_if_current(&spec.module_id, snapshot);
3726 return Ok(());
3727 }
3728
3729 let schedule = schedule.expect("a health restart must have a crash-restart schedule");
3730 let mut restart_count = 0;
3731 update_snapshot(snapshot, Some(&spec.module_id), |state| {
3732 restart_count = state.crash_restarts.len();
3733 state.state = ModuleState::Unresponsive;
3734 state.health.status = status;
3735 state.health.last_action = Some(HealthAction::Restart.to_string());
3736 state.health.last_action_ms = Some(now_ms);
3737 })?;
3738 warn!(
3739 module_id = %spec.module_id,
3740 status = ?status,
3741 detail,
3742 restart_count,
3743 restart_in_window = schedule.restart_in_window,
3744 delay_ms = schedule.delay.as_millis() as u64,
3745 "health-triggered module restart"
3746 );
3747
3748 begin_forwarding_drain_if_configured(
3749 spec,
3750 runtime,
3751 registry,
3752 snapshot,
3753 Some(true),
3754 RouteCloseReason::Restart,
3755 )
3756 .await?;
3757 drain_optional_child(
3758 &spec.module_id,
3759 spec.protocol,
3760 registry,
3761 snapshot,
3762 &runtime.terminal_ring,
3763 &runtime.spawn_events,
3764 child,
3765 runtime.drain_timeout,
3766 ModuleState::Restarting,
3767 Some(true),
3768 )
3769 .await?;
3770 sleep(schedule.delay).await;
3771 if !respawn_still_pending(snapshot) {
3775 process_liveness.untrack_if_current(&spec.module_id, snapshot);
3776 return Ok(());
3777 }
3778 process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
3779 match spawn_and_mark_running(spec, runtime, snapshot) {
3780 Ok(next_child) => {
3781 *child = Some(next_child);
3782 Ok(())
3783 }
3784 Err(err) => {
3785 fail_snapshot(snapshot, Some(&spec.module_id), None);
3786 process_liveness.untrack_if_current(&spec.module_id, snapshot);
3787 *child = None;
3788 Err(err)
3789 }
3790 }
3791}
3792
3793fn record_health_action(snapshot: &SharedSnapshot, module_id: &str, action: String, now_ms: u64) {
3794 let _ = update_snapshot(snapshot, Some(module_id), |state| {
3795 state.health.last_action = Some(action);
3796 state.health.last_action_ms = Some(now_ms);
3797 });
3798}
3799
3800fn supervisor_health_status(status: HealthStatus) -> SupervisorHealthStatus {
3801 match status {
3802 HealthStatus::Ok => SupervisorHealthStatus::Ok,
3803 HealthStatus::Degraded => SupervisorHealthStatus::Degraded,
3804 HealthStatus::Failing => SupervisorHealthStatus::Failing,
3805 }
3806}
3807
3808fn truncate_health_metrics(metrics: Option<Value>) -> Option<Value> {
3820 let metrics = metrics?;
3821 match serde_json::to_vec(&metrics) {
3822 Ok(encoded) if encoded.len() > MAX_HEALTH_METRICS_BYTES => Some(serde_json::json!({
3823 "truncated": true,
3824 "original_bytes": encoded.len(),
3825 })),
3826 Ok(_) | Err(_) => Some(metrics),
3827 }
3828}
3829
3830fn jittered_health_delay(module_id: &str, probe_index: u64, cadence: Duration) -> Duration {
3836 if cadence.is_zero() {
3837 return Duration::ZERO;
3838 }
3839 let cadence_ms = cadence.as_millis() as u64;
3840 if cadence_ms == 0 {
3856 return cadence;
3857 }
3858 let jitter_span = (cadence_ms / 10).max(1);
3873 let hash = module_id.as_bytes().iter().fold(
3874 probe_index.wrapping_mul(0x9E37_79B9_7F4A_7C15),
3875 |acc, byte| {
3876 acc.wrapping_mul(1099511628211)
3877 .wrapping_add(u64::from(*byte))
3878 },
3879 );
3880 cadence + Duration::from_millis(hash % jitter_span)
3881}
3882
3883#[cfg(test)]
3884mod tests {
3885 use super::*;
3886
3887 #[test]
3888 fn readding_a_module_clears_its_rescan_removal_tombstone() {
3889 let handle = SupervisorHandle::new();
3890 let module_id = "readded-tombstone";
3891 handle.record_rescan_removal(module_id);
3892 assert!(handle.removal_tombstone_age_ms(module_id).is_some());
3893
3894 handle.apply_identity_configuration(&ModuleSpec {
3895 module_id: module_id.to_string(),
3896 program: PathBuf::from("/test/module"),
3897 args: Vec::new(),
3898 env: Vec::new(),
3899 reserved: false,
3900 reserved_prefixes: Vec::new(),
3901 protocol: ModuleProtocol::Subc,
3902 overlap: Default::default(),
3903 });
3904
3905 assert!(
3906 handle.removal_tombstone_age_ms(module_id).is_none(),
3907 "a re-added module must not retain a stale removal tombstone"
3908 );
3909 }
3910
3911 fn stale_process_snapshot(state: ModuleState, enabled: bool) -> SharedSnapshot {
3912 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(state, enabled)));
3913 update_snapshot(&snapshot, Some("stale-process-facts"), |snapshot| {
3914 snapshot.process_alive = true;
3915 snapshot.pid = Some(41);
3916 snapshot.spawned_at_ms = Some(42);
3917 snapshot.spawned_from = Some(PathBuf::from("/spawned/module"));
3918 snapshot.spawned_file_identity = Some(SpawnedFileIdentity {
3919 device: 43,
3920 inode: 44,
3921 });
3922 })
3923 .unwrap();
3924 snapshot
3925 }
3926
3927 fn assert_snapshot_process_facts_cleared(snapshot: &SharedSnapshot) {
3928 let snapshot = lock_snapshot(snapshot).unwrap();
3929 assert!(!snapshot.process_alive);
3930 assert_eq!(snapshot.pid, None);
3931 assert_eq!(snapshot.spawned_at_ms, None);
3932 assert_eq!(snapshot.spawned_from, None);
3933 assert_eq!(snapshot.spawned_file_identity, None);
3934 }
3935
3936 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3937 async fn failed_enable_spawn_clears_preexisting_current_process_facts() {
3938 let supervisor = Supervisor::default();
3939 let mut runtime = supervisor.runtime_config();
3940 runtime.test_seed_stale_facts_before_enable_spawn = true;
3941 let snapshot = stale_process_snapshot(ModuleState::Disabled, false);
3942 let mut child = None;
3943 let spec = ModuleSpec {
3944 module_id: "failed-enable-clears-facts".to_string(),
3945 program: PathBuf::from("/definitely/missing/failed-enable-module"),
3946 args: Vec::new(),
3947 env: Vec::new(),
3948 reserved: false,
3949 reserved_prefixes: Vec::new(),
3950 protocol: ModuleProtocol::Subc,
3951 overlap: Default::default(),
3952 };
3953
3954 let result = set_child_enabled(
3955 &spec,
3956 &runtime,
3957 &supervisor.registry,
3958 &supervisor.process_liveness,
3959 &snapshot,
3960 &mut child,
3961 true,
3962 )
3963 .await;
3964
3965 assert!(matches!(result, Err(SuperviseError::Spawn { .. })));
3966 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
3967 assert_snapshot_process_facts_cleared(&snapshot);
3968 }
3969
3970 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3971 async fn failed_reload_spawn_clears_current_process_facts() {
3972 let supervisor = Supervisor::default();
3973 let mut runtime = supervisor.runtime_config();
3974 runtime.restart_policy = RestartPolicy::new(0, Duration::ZERO);
3975 let snapshot = stale_process_snapshot(ModuleState::Running, true);
3976 let mut child = None;
3977 let spec = ModuleSpec {
3978 module_id: "failed-reload-clears-facts".to_string(),
3979 program: PathBuf::from("/unused/failed-reload-module"),
3980 args: Vec::new(),
3981 env: Vec::new(),
3982 reserved: false,
3983 reserved_prefixes: Vec::new(),
3984 protocol: ModuleProtocol::Subc,
3985 overlap: Default::default(),
3986 };
3987
3988 let result = handle_reload_spawn_failure(
3989 &spec,
3990 &runtime,
3991 &supervisor.process_liveness,
3992 &snapshot,
3993 &mut child,
3994 "forced reload spawn failure".to_string(),
3995 )
3996 .await;
3997
3998 assert!(matches!(result, Err(SuperviseError::ReloadFailed { .. })));
3999 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
4000 assert_snapshot_process_facts_cleared(&snapshot);
4001 }
4002
4003 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4004 async fn dropping_a_module_with_an_active_monitor_clears_current_process_facts() {
4005 let supervisor = Supervisor::default();
4006 let snapshot = stale_process_snapshot(ModuleState::Running, true);
4007 let module = supervisor.supervised_module(
4008 ModuleSpec {
4009 module_id: "drop-clears-facts".to_string(),
4010 program: PathBuf::from("/unused/drop-module"),
4011 args: Vec::new(),
4012 env: Vec::new(),
4013 reserved: false,
4014 reserved_prefixes: Vec::new(),
4015 protocol: ModuleProtocol::Subc,
4016 overlap: Default::default(),
4017 },
4018 supervisor.runtime_config(),
4019 Arc::clone(&snapshot),
4020 None,
4021 );
4022 assert!(!module
4023 .inner
4024 .monitor
4025 .lock()
4026 .unwrap()
4027 .as_ref()
4028 .unwrap()
4029 .is_finished());
4030
4031 drop(module);
4032
4033 assert_eq!(
4034 lock_snapshot(&snapshot).unwrap().state,
4035 ModuleState::Stopped
4036 );
4037 assert_snapshot_process_facts_cleared(&snapshot);
4038 }
4039
4040 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4041 async fn configuration_update_does_not_replace_captured_running_process_facts() {
4042 let supervisor = Supervisor::default();
4043 let snapshot = stale_process_snapshot(ModuleState::Running, true);
4044 let initial = ModuleSpec {
4045 module_id: "rescan-preserves-spawn-facts".to_string(),
4046 program: PathBuf::from("/spawned/module"),
4047 args: Vec::new(),
4048 env: Vec::new(),
4049 reserved: false,
4050 reserved_prefixes: Vec::new(),
4051 protocol: ModuleProtocol::Subc,
4052 overlap: Default::default(),
4053 };
4054 let module = supervisor.supervised_module(
4055 initial.clone(),
4056 supervisor.runtime_config(),
4057 snapshot,
4058 None,
4059 );
4060 let before = module.status().unwrap();
4061 let mut replacement = initial;
4062 replacement.program = PathBuf::from("/rescanned/replacement-module");
4063
4064 module
4065 .update_configuration(replacement, HealthConfig::default(), None)
4066 .await
4067 .unwrap();
4068
4069 let after = module.status().unwrap();
4070 assert_eq!(after.pid, before.pid);
4071 assert_eq!(after.spawned_at_ms, before.spawned_at_ms);
4072 assert_eq!(after.spawned_from, before.spawned_from);
4073 drop(module);
4074 }
4075}
4076
4077fn unix_ms_now() -> u64 {
4078 SystemTime::now()
4079 .duration_since(UNIX_EPOCH)
4080 .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64)
4081 .unwrap_or(0)
4082}
4083
4084async fn supervise_loop(
4085 mut spec: ModuleSpec,
4086 mut runtime: SupervisorRuntimeConfig,
4087 registry: Arc<Registry>,
4088 process_liveness: Arc<SupervisorProcessLiveness>,
4089 snapshot: SharedSnapshot,
4090 mut child: Option<SupervisedChild>,
4091 mut commands: mpsc::Receiver<SupervisorCommand>,
4092) {
4093 let mut health_probe = HealthProbeRuntime::default();
4094 let mut pending_respawn: Option<Instant> = None;
4098 let mut requeued: VecDeque<SupervisorCommand> = VecDeque::new();
4101 loop {
4102 if let Some(command) = requeued.pop_front() {
4103 if !handle_supervisor_command(
4104 command,
4105 &mut spec,
4106 &mut runtime,
4107 ®istry,
4108 &process_liveness,
4109 &snapshot,
4110 &mut child,
4111 &mut commands,
4112 &mut requeued,
4113 )
4114 .await
4115 {
4116 return;
4117 }
4118 if child.is_some() || !respawn_still_pending(&snapshot) {
4119 pending_respawn = None;
4120 }
4121 continue;
4122 }
4123 if child.is_some() {
4124 health_probe.refresh_registration(&spec, &runtime, ®istry, &snapshot);
4125 let probe_sleep = sleep(health_probe.wake_after());
4126 tokio::pin!(probe_sleep);
4127 let active_child = child.as_mut().expect("child checked above");
4128 tokio::select! {
4129 wait_result = active_child.wait() => {
4130 let exit_report = match wait_result {
4139 Ok(status) => classify_reaped_child_exit(&snapshot, active_child, &status),
4140 Err(err) => {
4141 active_child.drain_stderr(&spec.module_id).await;
4142 fail_snapshot(&snapshot, Some(&spec.module_id), None);
4143 record_wait_error_terminal(
4149 &spec.module_id,
4150 &runtime.terminal_ring,
4151 &runtime.spawn_events,
4152 );
4153 untrack_if_registration_released(
4154 &process_liveness,
4155 ®istry,
4156 &spec.module_id,
4157 &snapshot,
4158 );
4159 error!(module_id = %spec.module_id, error = %err, "failed to wait for supervised module");
4160 child = None;
4161 continue;
4162 }
4163 };
4164 active_child.drain_stderr(&spec.module_id).await;
4165
4166 match on_child_exit(
4167 &spec,
4168 runtime.restart_policy,
4169 ®istry,
4170 &snapshot,
4171 &runtime.terminal_ring,
4172 &runtime.spawn_events,
4173 exit_report,
4174 ).await {
4175 NextAction::Stop { registration_released } => {
4176 if registration_released {
4177 process_liveness.untrack_if_current(&spec.module_id, &snapshot);
4178 }
4179 child = None;
4180 }
4181 NextAction::Restart { schedule } => {
4182 let delay = schedule.map_or(
4183 runtime.restart_policy.delay_for_restart(0),
4184 |schedule| schedule.delay,
4185 );
4186 if let Some(schedule) = schedule {
4187 log_crash_respawn(&spec.module_id, schedule);
4188 }
4189 child = None;
4197 pending_respawn = Some(Instant::now() + delay);
4198 }
4199 }
4200 }
4201 command = commands.recv() => {
4202 let Some(command) = command else {
4203 return;
4204 };
4205 if !handle_supervisor_command(
4206 command,
4207 &mut spec,
4208 &mut runtime,
4209 ®istry,
4210 &process_liveness,
4211 &snapshot,
4212 &mut child,
4213 &mut commands,
4214 &mut requeued,
4215 ).await {
4216 return;
4217 }
4218 }
4219 _ = &mut probe_sleep => {
4220 if health_probe.due() {
4221 run_health_probe_cycle(
4222 &spec,
4223 &runtime,
4224 ®istry,
4225 &process_liveness,
4226 &snapshot,
4227 &mut child,
4228 ).await;
4229 if child.is_some() {
4230 health_probe.schedule_next(&spec, runtime.health.cadence);
4231 }
4232 }
4233 }
4234 }
4235 } else if let Some(deadline) = pending_respawn {
4236 tokio::select! {
4237 _ = sleep_until(deadline) => {
4238 pending_respawn = None;
4239 if !respawn_still_pending(&snapshot) {
4243 continue;
4244 }
4245 if let Err(err) = wait_for_registration_release(
4246 ®istry,
4247 &spec.module_id,
4248 REGISTRY_RELEASE_TIMEOUT,
4249 ).await {
4250 fail_snapshot(&snapshot, Some(&spec.module_id), None);
4251 error!(module_id = %spec.module_id, error = %err, "registration did not release before restart");
4252 continue;
4253 }
4254
4255 match spawn_and_mark_running(&spec, &runtime, &snapshot) {
4256 Ok(next_child) => {
4257 child = Some(next_child);
4258 debug!(module_id = %spec.module_id, "supervised module restarted after crash");
4259 }
4260 Err(err) => {
4261 fail_snapshot(&snapshot, Some(&spec.module_id), None);
4262 process_liveness.untrack_if_current(&spec.module_id, &snapshot);
4263 error!(module_id = %spec.module_id, error = %err, "failed to restart supervised module");
4264 }
4265 }
4266 }
4267 command = commands.recv() => {
4268 let Some(command) = command else {
4269 return;
4270 };
4271 if !handle_supervisor_command(
4272 command,
4273 &mut spec,
4274 &mut runtime,
4275 ®istry,
4276 &process_liveness,
4277 &snapshot,
4278 &mut child,
4279 &mut commands,
4280 &mut requeued,
4281 ).await {
4282 return;
4283 }
4284 if child.is_some() || !respawn_still_pending(&snapshot) {
4289 pending_respawn = None;
4290 }
4291 }
4292 }
4293 } else {
4294 let Some(command) = commands.recv().await else {
4295 return;
4296 };
4297 if !handle_supervisor_command(
4298 command,
4299 &mut spec,
4300 &mut runtime,
4301 ®istry,
4302 &process_liveness,
4303 &snapshot,
4304 &mut child,
4305 &mut commands,
4306 &mut requeued,
4307 )
4308 .await
4309 {
4310 return;
4311 }
4312 }
4313 }
4314}
4315
4316fn log_crash_respawn(module_id: &str, schedule: CrashRestartSchedule) {
4317 info!(
4318 module_id,
4319 restart_in_window = schedule.restart_in_window,
4320 delay_ms = schedule.delay.as_millis() as u64,
4321 "respawning after crash"
4322 );
4323}
4324
4325fn respawn_still_pending(snapshot: &SharedSnapshot) -> bool {
4331 matches!(
4332 lock_snapshot(snapshot),
4333 Ok(state) if state.enabled && state.state == ModuleState::Restarting
4334 )
4335}
4336
4337enum NextAction {
4338 Stop {
4339 registration_released: bool,
4340 },
4341 Restart {
4342 schedule: Option<CrashRestartSchedule>,
4343 },
4344}
4345
4346#[allow(clippy::too_many_arguments)]
4347async fn handle_supervisor_command(
4348 command: SupervisorCommand,
4349 spec: &mut ModuleSpec,
4350 runtime: &mut SupervisorRuntimeConfig,
4351 registry: &Registry,
4352 process_liveness: &SupervisorProcessLiveness,
4353 snapshot: &SharedSnapshot,
4354 child: &mut Option<SupervisedChild>,
4355 commands: &mut mpsc::Receiver<SupervisorCommand>,
4356 requeued: &mut VecDeque<SupervisorCommand>,
4357) -> bool {
4358 match command {
4359 SupervisorCommand::Drain { reply } => {
4360 let result = drain_optional_child(
4361 &spec.module_id,
4362 spec.protocol,
4363 registry,
4364 snapshot,
4365 &runtime.terminal_ring,
4366 &runtime.spawn_events,
4367 child,
4368 runtime.drain_timeout,
4369 ModuleState::Stopped,
4370 None,
4371 )
4372 .await;
4373 let registration_released = result.is_ok();
4374 let _ = reply.send(result);
4375 if registration_released {
4376 process_liveness.untrack_if_current(&spec.module_id, snapshot);
4377 }
4378 false
4379 }
4380 SupervisorCommand::Retire { reply } => {
4381 let result = async {
4382 begin_forwarding_drain_if_configured(
4383 spec,
4384 runtime,
4385 registry,
4386 snapshot,
4387 None,
4388 RouteCloseReason::Disable,
4389 )
4390 .await?;
4391 drain_optional_child(
4392 &spec.module_id,
4393 spec.protocol,
4394 registry,
4395 snapshot,
4396 &runtime.terminal_ring,
4397 &runtime.spawn_events,
4398 child,
4399 runtime.drain_timeout,
4400 ModuleState::Stopped,
4401 None,
4402 )
4403 .await
4404 }
4405 .await;
4406 let registration_released = result.is_ok();
4407 let _ = reply.send(result);
4408 if registration_released {
4409 process_liveness.untrack_if_current(&spec.module_id, snapshot);
4410 }
4411 false
4412 }
4413 SupervisorCommand::Restart {
4414 drain_timeout_ms,
4415 reply,
4416 } => {
4417 let validation = match lock_snapshot(snapshot) {
4429 Ok(state) if !state.enabled => Err(SuperviseError::Disabled {
4430 module_id: spec.module_id.clone(),
4431 }),
4432 Ok(_) => Ok(()),
4433 Err(err) => Err(err),
4434 };
4435 let initiated = validation.is_ok();
4436 let _ = reply.send(validation);
4437 if initiated {
4438 let drain_timeout = drain_timeout_ms
4441 .map(Duration::from_millis)
4442 .unwrap_or(runtime.drain_timeout);
4443 if let Err(err) = restart_child(
4444 spec,
4445 runtime,
4446 registry,
4447 process_liveness,
4448 snapshot,
4449 child,
4450 drain_timeout,
4451 )
4452 .await
4453 {
4454 warn!(
4455 module_id = %spec.module_id,
4456 error = %err,
4457 "operator restart failed after initiation ack; module state carries the outcome"
4458 );
4459 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4460 state.state = ModuleState::Failed;
4461 clear_current_process_facts(state);
4462 });
4463 }
4464 }
4465 true
4466 }
4467 SupervisorCommand::Reload { reply } => {
4468 let result =
4469 reload_child(spec, runtime, registry, process_liveness, snapshot, child).await;
4470 let _ = reply.send(result);
4471 true
4472 }
4473 SupervisorCommand::SetEnabled { enabled, reply } => {
4474 let result = set_child_enabled(
4475 spec,
4476 runtime,
4477 registry,
4478 process_liveness,
4479 snapshot,
4480 child,
4481 enabled,
4482 )
4483 .await;
4484 let _ = reply.send(result);
4485 true
4486 }
4487 SupervisorCommand::UpdateConfiguration {
4488 spec: next_spec,
4489 health,
4490 drain_timeout_ms,
4491 reply,
4492 } => {
4493 if let Some(handle) = &runtime.supervisor_handle {
4494 handle.apply_identity_configuration(&next_spec);
4495 }
4496 *spec = next_spec;
4497 runtime.health = health;
4498 runtime.drain_timeout = drain_timeout_ms
4499 .map(Duration::from_millis)
4500 .unwrap_or(runtime.default_drain_timeout);
4501 *runtime
4502 .effective_drain_timeout
4503 .lock()
4504 .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.drain_timeout;
4505 let _ = reply.send(());
4506 true
4507 }
4508 SupervisorCommand::Swap {
4509 ready_timeout,
4510 reply,
4511 } => {
4512 let end = swap::run_swap(
4513 spec,
4514 runtime,
4515 registry,
4516 process_liveness,
4517 snapshot,
4518 child,
4519 commands,
4520 ready_timeout.unwrap_or(DEFAULT_SWAP_READY_TIMEOUT),
4521 reply,
4522 )
4523 .await;
4524 requeued.extend(end.requeue);
4525 true
4526 }
4527 }
4528}
4529
4530async fn restart_child(
4531 spec: &ModuleSpec,
4532 runtime: &SupervisorRuntimeConfig,
4533 registry: &Registry,
4534 process_liveness: &SupervisorProcessLiveness,
4535 snapshot: &SharedSnapshot,
4536 child: &mut Option<SupervisedChild>,
4537 drain_timeout: Duration,
4538) -> Result<(), SuperviseError> {
4539 if !lock_snapshot(snapshot)?.enabled {
4541 return Err(SuperviseError::Disabled {
4542 module_id: spec.module_id.clone(),
4543 });
4544 }
4545 begin_forwarding_drain_with_timeout(
4546 spec,
4547 runtime,
4548 registry,
4549 snapshot,
4550 None,
4551 RouteCloseReason::Restart,
4552 drain_timeout,
4553 )
4554 .await?;
4555
4556 if child.is_some() {
4557 drain_optional_child(
4558 &spec.module_id,
4559 spec.protocol,
4560 registry,
4561 snapshot,
4562 &runtime.terminal_ring,
4563 &runtime.spawn_events,
4564 child,
4565 drain_timeout,
4566 ModuleState::Restarting,
4567 Some(true),
4568 )
4569 .await?;
4570 } else {
4571 update_snapshot(snapshot, Some(&spec.module_id), |state| {
4572 state.enabled = true;
4573 state.state = ModuleState::Restarting;
4574 clear_current_process_facts(state);
4575 })?;
4576 wait_for_registration_release(registry, &spec.module_id, REGISTRY_RELEASE_TIMEOUT).await?;
4577 }
4578
4579 reset_restart_count(snapshot, &spec.module_id)?;
4580 sleep(runtime.restart_policy.backoff).await;
4581 if !respawn_still_pending(snapshot) {
4584 process_liveness.untrack_if_current(&spec.module_id, snapshot);
4585 return Ok(());
4586 }
4587 process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
4588 match spawn_and_mark_running(spec, runtime, snapshot) {
4594 Ok(next_child) => {
4595 *child = Some(next_child);
4596 debug!(module_id = %spec.module_id, "supervised module restarted by operator request");
4597 Ok(())
4598 }
4599 Err(err) => {
4600 fail_snapshot(snapshot, Some(&spec.module_id), None);
4601 process_liveness.untrack_if_current(&spec.module_id, snapshot);
4602 *child = None;
4603 Err(err)
4604 }
4605 }
4606}
4607
4608async fn reload_child(
4609 spec: &ModuleSpec,
4610 runtime: &SupervisorRuntimeConfig,
4611 registry: &Registry,
4612 process_liveness: &SupervisorProcessLiveness,
4613 snapshot: &SharedSnapshot,
4614 child: &mut Option<SupervisedChild>,
4615) -> Result<(), SuperviseError> {
4616 if !lock_snapshot(snapshot)?.enabled {
4618 return Err(SuperviseError::Disabled {
4619 module_id: spec.module_id.clone(),
4620 });
4621 }
4622 begin_forwarding_drain(
4623 spec,
4624 runtime,
4625 registry,
4626 snapshot,
4627 Some(true),
4628 RouteCloseReason::Reload,
4629 )
4630 .await?;
4631
4632 if child.is_some() {
4633 drain_optional_child(
4634 &spec.module_id,
4635 spec.protocol,
4636 registry,
4637 snapshot,
4638 &runtime.terminal_ring,
4639 &runtime.spawn_events,
4640 child,
4641 runtime.drain_timeout,
4642 ModuleState::Restarting,
4643 Some(true),
4644 )
4645 .await?;
4646 } else {
4647 update_snapshot(snapshot, Some(&spec.module_id), |state| {
4648 state.enabled = true;
4649 state.state = ModuleState::Restarting;
4650 clear_current_process_facts(state);
4651 })?;
4652 wait_for_registration_release(registry, &spec.module_id, REGISTRY_RELEASE_TIMEOUT).await?;
4653 }
4654
4655 reset_restart_count(snapshot, &spec.module_id)?;
4656 sleep(runtime.restart_policy.backoff).await;
4657 if !respawn_still_pending(snapshot) {
4660 process_liveness.untrack_if_current(&spec.module_id, snapshot);
4661 return Ok(());
4662 }
4663 process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
4664 let next_child = match spawn_and_mark_running(spec, runtime, snapshot) {
4665 Ok(next_child) => next_child,
4666 Err(err) => {
4667 return handle_reload_spawn_failure(
4668 spec,
4669 runtime,
4670 process_liveness,
4671 snapshot,
4672 child,
4673 format!("new child failed to spawn: {err}"),
4674 )
4675 .await;
4676 }
4677 };
4678 *child = Some(next_child);
4679
4680 let wait_outcome = {
4681 let active_child = child.as_mut().expect("new reload child was just stored");
4682 wait_for_registration_after_reload(
4683 registry,
4684 &spec.module_id,
4685 snapshot,
4686 active_child,
4687 REGISTRY_RELEASE_TIMEOUT,
4688 )
4689 .await?
4690 };
4691
4692 match wait_outcome {
4693 RegistrationWaitOutcome::Registered => {
4694 debug!(module_id = %spec.module_id, "supervised module reloaded and registered");
4695 Ok(())
4696 }
4697 RegistrationWaitOutcome::Exited(exit_report) => {
4698 if let Some(active_child) = child.as_mut() {
4699 active_child.drain_stderr(&spec.module_id).await;
4700 }
4701 *child = None;
4702 handle_reload_child_registration_failure(
4703 spec,
4704 runtime,
4705 registry,
4706 process_liveness,
4707 snapshot,
4708 child,
4709 ReloadRegistrationFailure {
4710 exit_report: registration_failure_exit_report(exit_report),
4711 reason: "new child exited before registering".to_string(),
4712 },
4713 )
4714 .await
4715 }
4716 RegistrationWaitOutcome::TimedOut => {
4717 let mut timed_out_child = child
4718 .take()
4719 .expect("timed-out reload child is still running");
4720 timed_out_child
4721 .start_kill()
4722 .map_err(|source| SuperviseError::Kill {
4723 module_id: spec.module_id.clone(),
4724 source,
4725 })?;
4726 let status = timed_out_child
4727 .wait()
4728 .await
4729 .map_err(|source| SuperviseError::Wait {
4730 module_id: spec.module_id.clone(),
4731 source,
4732 })?;
4733 timed_out_child.drain_stderr(&spec.module_id).await;
4734 handle_reload_child_registration_failure(
4735 spec,
4736 runtime,
4737 registry,
4738 process_liveness,
4739 snapshot,
4740 child,
4741 ReloadRegistrationFailure {
4742 exit_report: registration_failure_exit_report(classify_reaped_child_exit(
4743 snapshot,
4744 &timed_out_child,
4745 &status,
4746 )),
4747 reason: format!(
4748 "new child did not register within {:?}",
4749 REGISTRY_RELEASE_TIMEOUT
4750 ),
4751 },
4752 )
4753 .await
4754 }
4755 }
4756}
4757
4758async fn set_child_enabled(
4759 spec: &ModuleSpec,
4760 runtime: &SupervisorRuntimeConfig,
4761 registry: &Registry,
4762 process_liveness: &SupervisorProcessLiveness,
4763 snapshot: &SharedSnapshot,
4764 child: &mut Option<SupervisedChild>,
4765 enabled: bool,
4766) -> Result<bool, SuperviseError> {
4767 let (current_enabled, current_state) = {
4768 let state = lock_snapshot(snapshot)?;
4769 (state.enabled, state.state)
4770 };
4771 let revive_terminal = enabled
4779 && current_enabled
4780 && child.is_none()
4781 && matches!(current_state, ModuleState::Failed | ModuleState::Stopped);
4782 if current_enabled == enabled && !revive_terminal {
4783 return Ok(false);
4784 }
4785
4786 if enabled {
4787 update_snapshot(snapshot, Some(&spec.module_id), |state| {
4788 state.enabled = true;
4789 state.state = ModuleState::Starting;
4790 clear_current_process_facts(state);
4791 })?;
4792 #[cfg(test)]
4793 if runtime.test_seed_stale_facts_before_enable_spawn {
4794 update_snapshot(snapshot, Some(&spec.module_id), |state| {
4795 state.process_alive = true;
4796 state.pid = Some(41);
4797 state.spawned_at_ms = Some(42);
4798 state.spawned_from = Some(PathBuf::from("/spawned/module"));
4799 state.spawned_file_identity = Some(SpawnedFileIdentity {
4800 device: 43,
4801 inode: 44,
4802 });
4803 })?;
4804 }
4805 wait_for_registration_release(registry, &spec.module_id, REGISTRY_RELEASE_TIMEOUT).await?;
4806 reset_restart_count(snapshot, &spec.module_id)?;
4807 process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
4808 let next_child = match spawn_and_mark_running(spec, runtime, snapshot) {
4809 Ok(next_child) => next_child,
4810 Err(err) => {
4811 if let Err(state_err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4812 state.state = ModuleState::Failed;
4813 clear_current_process_facts(state);
4814 }) {
4815 error!(module_id = %spec.module_id, error = %state_err, "failed to record enable spawn failure");
4816 }
4817 process_liveness.untrack_if_current(&spec.module_id, snapshot);
4818 return Err(err);
4819 }
4820 };
4821 *child = Some(next_child);
4822 debug!(module_id = %spec.module_id, "supervised module enabled");
4823 Ok(true)
4824 } else {
4825 begin_forwarding_drain_if_configured(
4826 spec,
4827 runtime,
4828 registry,
4829 snapshot,
4830 Some(false),
4831 RouteCloseReason::Disable,
4832 )
4833 .await?;
4834 drain_optional_child(
4835 &spec.module_id,
4836 spec.protocol,
4837 registry,
4838 snapshot,
4839 &runtime.terminal_ring,
4840 &runtime.spawn_events,
4841 child,
4842 runtime.drain_timeout,
4843 ModuleState::Disabled,
4844 Some(false),
4845 )
4846 .await?;
4847 debug!(module_id = %spec.module_id, "supervised module disabled");
4848 Ok(true)
4849 }
4850}
4851
4852async fn on_child_exit(
4853 spec: &ModuleSpec,
4854 policy: RestartPolicy,
4855 registry: &Registry,
4856 snapshot: &SharedSnapshot,
4857 terminal_ring: &Arc<Mutex<TerminalRing>>,
4858 spawn_events: &SpawnEventFeed,
4859 exit_report: ExitReport,
4860) -> NextAction {
4861 match exit_report.kind {
4862 ExitKind::Clean => {
4863 info!(
4864 module_id = %spec.module_id,
4865 exit_code = ?exit_report.code,
4866 exit_signal = ?exit_report.signal,
4867 "supervised module exited cleanly"
4868 );
4869 if let Err(err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4870 state.state = ModuleState::Stopped;
4871 clear_current_process_facts(state);
4872 state.last_exit = Some(exit_report.clone());
4873 }) {
4874 error!(module_id = %spec.module_id, error = %err, "failed to record clean module exit");
4875 }
4876 record_terminal(
4877 &spec.module_id,
4878 terminal_ring,
4879 spawn_events,
4880 &exit_report,
4881 TerminalDisposition::Stopped,
4882 );
4883 let registration_released = match wait_for_registration_release(
4884 registry,
4885 &spec.module_id,
4886 REGISTRY_RELEASE_TIMEOUT,
4887 )
4888 .await
4889 {
4890 Ok(()) => true,
4891 Err(err) => {
4892 warn!(module_id = %spec.module_id, error = %err, "registration still active after clean exit");
4893 false
4894 }
4895 };
4896 NextAction::Stop {
4897 registration_released,
4898 }
4899 }
4900 ExitKind::Crash => {
4901 warn!(
4902 module_id = %spec.module_id,
4903 exit_code = ?exit_report.code,
4904 exit_signal = ?exit_report.signal,
4905 "supervised module exited abnormally (crash)"
4906 );
4907 let mut restart_schedule = None;
4908 let mut disposition = TerminalDisposition::Disabled;
4909 let mut disposition_detail = None;
4913 let now = Instant::now();
4914 if let Err(err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4915 clear_current_process_facts(state);
4916 state.last_exit = Some(exit_report.clone());
4917 if state.enabled {
4918 if let Some(schedule) = state.next_crash_restart(&policy, now) {
4919 state.state = ModuleState::Restarting;
4920 restart_schedule = Some(schedule);
4921 disposition = TerminalDisposition::Restarting;
4922 } else {
4923 state.state = ModuleState::Failed;
4924 disposition = TerminalDisposition::Failed;
4925 disposition_detail = Some(policy.budget_exhausted_detail());
4926 }
4927 } else {
4928 state.state = ModuleState::Disabled;
4929 disposition = TerminalDisposition::Disabled;
4930 }
4931 }) {
4932 error!(module_id = %spec.module_id, error = %err, "failed to record crashed module exit");
4933 return NextAction::Stop {
4934 registration_released: false,
4935 };
4936 }
4937 if disposition_detail.is_some() {
4938 error!(
4943 module_id = %spec.module_id,
4944 max_restarts = policy.max_restarts,
4945 window_secs = policy.window.as_secs(),
4946 "module stopped: {}",
4947 policy.budget_exhausted_detail()
4948 );
4949 }
4950 record_terminal_with_detail(
4951 &spec.module_id,
4952 terminal_ring,
4953 spawn_events,
4954 &exit_report,
4955 disposition,
4956 disposition_detail,
4957 );
4958
4959 if let Some(schedule) = restart_schedule {
4960 NextAction::Restart {
4961 schedule: Some(schedule),
4962 }
4963 } else {
4964 let registration_released = match wait_for_registration_release(
4965 registry,
4966 &spec.module_id,
4967 REGISTRY_RELEASE_TIMEOUT,
4968 )
4969 .await
4970 {
4971 Ok(()) => true,
4972 Err(err) => {
4973 warn!(module_id = %spec.module_id, error = %err, "registration still active after failed module");
4974 false
4975 }
4976 };
4977 NextAction::Stop {
4978 registration_released,
4979 }
4980 }
4981 }
4982 ExitKind::DeliberateSeverance => {
4983 warn!(
4984 module_id = %spec.module_id,
4985 exit_code = ?exit_report.code,
4986 exit_signal = ?exit_report.signal,
4987 "supervised module exited after deliberate connection severance"
4988 );
4989 let mut should_restart = false;
4990 let mut disposition = TerminalDisposition::Disabled;
4991 if let Err(err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4992 clear_current_process_facts(state);
4993 state.last_exit = Some(exit_report.clone());
4994 state.lifetime_restarts += 1;
4995 if state.enabled {
4996 state.state = ModuleState::Restarting;
4997 should_restart = true;
4998 disposition = TerminalDisposition::Restarting;
4999 } else {
5000 state.state = ModuleState::Disabled;
5001 }
5002 }) {
5003 error!(module_id = %spec.module_id, error = %err, "failed to record deliberately severed module exit");
5004 return NextAction::Stop {
5005 registration_released: false,
5006 };
5007 }
5008 record_terminal(
5009 &spec.module_id,
5010 terminal_ring,
5011 spawn_events,
5012 &exit_report,
5013 disposition,
5014 );
5015
5016 if should_restart {
5017 NextAction::Restart { schedule: None }
5018 } else {
5019 let registration_released = match wait_for_registration_release(
5020 registry,
5021 &spec.module_id,
5022 REGISTRY_RELEASE_TIMEOUT,
5023 )
5024 .await
5025 {
5026 Ok(()) => true,
5027 Err(err) => {
5028 warn!(module_id = %spec.module_id, error = %err, "registration still active after deliberately severed module exit");
5029 false
5030 }
5031 };
5032 NextAction::Stop {
5033 registration_released,
5034 }
5035 }
5036 }
5037 }
5038}
5039
5040fn record_wait_error_terminal(
5041 module_id: &str,
5042 terminal_ring: &Arc<Mutex<TerminalRing>>,
5043 spawn_events: &SpawnEventFeed,
5044) {
5045 record_terminal(
5046 module_id,
5047 terminal_ring,
5048 spawn_events,
5049 &wait_error_exit_report(),
5050 TerminalDisposition::Failed,
5051 );
5052}
5053
5054fn record_terminal(
5055 module_id: &str,
5056 terminal_ring: &Arc<Mutex<TerminalRing>>,
5057 spawn_events: &SpawnEventFeed,
5058 exit_report: &ExitReport,
5059 disposition: TerminalDisposition,
5060) {
5061 record_terminal_with_detail(
5062 module_id,
5063 terminal_ring,
5064 spawn_events,
5065 exit_report,
5066 disposition,
5067 None,
5068 );
5069}
5070
5071fn record_terminal_with_detail(
5072 module_id: &str,
5073 terminal_ring: &Arc<Mutex<TerminalRing>>,
5074 spawn_events: &SpawnEventFeed,
5075 exit_report: &ExitReport,
5076 disposition: TerminalDisposition,
5077 disposition_detail: Option<String>,
5078) {
5079 spawn_events.emit_exited(module_id, exit_report.code, exit_report.signal);
5080 let mut ring = terminal_ring
5081 .lock()
5082 .unwrap_or_else(|poisoned| poisoned.into_inner());
5083 let record = TerminalRecord {
5084 exit_code: exit_report.code,
5085 exit_signal: exit_report.signal,
5086 at_ms: exit_report.at_ms,
5087 disposition,
5088 exit_kind: exit_report.kind.into(),
5089 disposition_detail,
5090 };
5091 ring.append_journal(module_id, &record);
5092 ring.push(record);
5093}
5094
5095fn untrack_if_registration_released(
5096 process_liveness: &SupervisorProcessLiveness,
5097 registry: &Registry,
5098 module_id: &str,
5099 snapshot: &SharedSnapshot,
5100) {
5101 match registry.get_module(module_id) {
5102 Ok(None) => process_liveness.untrack_if_current(module_id, snapshot),
5103 Ok(Some(_)) => {}
5104 Err(err) => {
5105 warn!(module_id, error = %err, "could not determine whether supervisor liveness can be untracked");
5106 }
5107 }
5108}
5109
5110#[cfg(test)]
5124fn apply_wire_spawn_args(
5125 command: &mut Command,
5126 spec: &ModuleSpec,
5127 connection_file_path: Option<&std::path::Path>,
5128 handle: Option<&SupervisorHandle>,
5129) -> Result<(), SuperviseError> {
5130 apply_wire_spawn_args_for_role(
5131 command,
5132 spec,
5133 connection_file_path,
5134 handle,
5135 SpawnRole::Plain,
5136 )
5137}
5138
5139fn apply_wire_spawn_args_for_role(
5148 command: &mut Command,
5149 spec: &ModuleSpec,
5150 connection_file_path: Option<&std::path::Path>,
5151 handle: Option<&SupervisorHandle>,
5152 role: SpawnRole,
5153) -> Result<(), SuperviseError> {
5154 command.env(SUBC_MODULE_ID_ENV, &spec.module_id);
5155 if spec.protocol == ModuleProtocol::None {
5156 return Ok(());
5157 }
5158 if let Some(connection_file_path) = connection_file_path {
5159 command.arg(SUBC_ARG).arg(connection_file_path);
5160 }
5161
5162 let nonce = generate_launch_nonce()?;
5166 if let Some(handle) = handle {
5167 match role {
5168 SpawnRole::Plain => {
5169 handle.set_spawn_nonce(&spec.module_id, nonce.clone());
5170 if spec.reserved {
5171 handle.set_reserved_nonce(&spec.module_id, nonce.clone());
5172 }
5173 }
5174 SpawnRole::SwapCandidate => handle.open_swap(&spec.module_id, nonce.clone()),
5175 }
5176 }
5177 command.env(SUBC_LAUNCH_NONCE_ENV, nonce);
5178 Ok(())
5179}
5180
5181fn apply_child_env(command: &mut Command, spec: &ModuleSpec) {
5182 command.env_remove(CK_LOG_ENV);
5183 command.env_remove(SUBC_SPAWN_ROLE_ENV);
5190 for (key, value) in &spec.env {
5191 if matches!(
5195 key.as_str(),
5196 CAPTURE_MAX_FILE_MB_ENV | CAPTURE_KEEP_ENV | CAPTURE_MAX_AGE_DAYS_ENV
5197 ) || key == SUBC_SPAWN_ROLE_ENV
5198 {
5199 continue;
5200 }
5201 command.env(key, value);
5202 }
5203}
5204
5205#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5208enum SpawnRole {
5209 Plain,
5210 SwapCandidate,
5211}
5212
5213fn apply_spawn_role(command: &mut Command, role: SpawnRole) {
5216 if role == SpawnRole::SwapCandidate {
5217 command.env(SUBC_SPAWN_ROLE_ENV, SPAWN_ROLE_SWAP_CANDIDATE);
5218 }
5219}
5220
5221fn spawn_child(
5222 spec: &ModuleSpec,
5223 connection_file_path: Option<&std::path::Path>,
5224 handle: Option<&SupervisorHandle>,
5225 ring: &Arc<Mutex<StderrRing>>,
5226 capture_logs_dir: Option<&std::path::Path>,
5227 #[cfg(target_os = "linux")] cgroup_placement: Option<&subc_cgroup::Placement>,
5228) -> Result<SupervisedChild, SuperviseError> {
5229 spawn_child_in_slot(
5230 spec,
5231 connection_file_path,
5232 handle,
5233 ring,
5234 capture_logs_dir,
5235 #[cfg(target_os = "linux")]
5236 cgroup_placement,
5237 SpawnRole::Plain,
5238 false,
5239 )
5240}
5241
5242#[allow(clippy::too_many_arguments)]
5255fn spawn_child_in_slot(
5256 spec: &ModuleSpec,
5257 connection_file_path: Option<&std::path::Path>,
5258 handle: Option<&SupervisorHandle>,
5259 ring: &Arc<Mutex<StderrRing>>,
5260 capture_logs_dir: Option<&std::path::Path>,
5261 #[cfg(target_os = "linux")] cgroup_placement: Option<&subc_cgroup::Placement>,
5262 role: SpawnRole,
5263 alternate_slot: bool,
5264) -> Result<SupervisedChild, SuperviseError> {
5265 #[cfg(target_os = "linux")]
5266 let cgroup_name = swap::cgroup_name(&spec.module_id, alternate_slot);
5267 #[cfg(not(target_os = "linux"))]
5268 let _ = alternate_slot;
5269 let mut command = Command::new(&spec.program);
5270 command.args(&spec.args);
5271 apply_child_env(&mut command, spec);
5301 apply_spawn_role(&mut command, role);
5302 apply_wire_spawn_args_for_role(&mut command, spec, connection_file_path, handle, role)?;
5303
5304 #[cfg(target_os = "linux")]
5305 let cgroup_path = cgroup_placement
5306 .map(|placement| placement.module_path(&cgroup_name))
5307 .transpose()
5308 .map_err(|source| SuperviseError::Cgroup {
5309 module_id: spec.module_id.clone(),
5310 source,
5311 })?;
5312 #[cfg(not(target_os = "linux"))]
5313 let cgroup_path: Option<PathBuf> = None;
5314 #[cfg(target_os = "linux")]
5315 if let Some(path) = &cgroup_path {
5316 if let Err(error) = apply_cgroup_placement(&mut command, spec, path) {
5317 if let Some(placement) = cgroup_placement {
5318 remove_module_cgroup(placement, &cgroup_name);
5319 }
5320 return Err(error);
5321 }
5322 }
5323
5324 let output_sink = if let Some(logs_dir) = capture_logs_dir {
5325 let path = logs_dir.join(format!("{}.stderr.log", spec.module_id));
5326 match ChildOutputSink::open(&path, capture_retention(spec)) {
5327 Ok(sink) => sink,
5328 Err(error) => {
5329 warn!(
5330 module_id = %spec.module_id,
5331 path = %path.display(),
5332 error = %error,
5333 "could not open child output capture file; forwarding to stderr"
5334 );
5335 ChildOutputSink::Stderr
5336 }
5337 }
5338 } else {
5339 ChildOutputSink::Stderr
5340 };
5341
5342 command.stdout(Stdio::piped());
5343 command.stderr(Stdio::piped());
5344 command.kill_on_drop(true);
5345 let mut child = match command.spawn() {
5346 Ok(child) => child,
5347 Err(source) => {
5348 #[cfg(target_os = "linux")]
5349 if let Some(placement) = cgroup_placement {
5350 remove_module_cgroup(placement, &cgroup_name);
5351 }
5352 return Err(SuperviseError::Spawn {
5353 program: spec.program.clone(),
5354 source,
5355 cgroup_path,
5356 });
5357 }
5358 };
5359 let spawned_at_ms = unix_ms_now();
5360 let spawned_from = spec.program.clone();
5361 let spawned_file_identity = spawned_file_identity(&spawned_from);
5362 let pid = child.id().ok_or_else(|| SuperviseError::Spawn {
5363 program: spec.program.clone(),
5364 source: io::Error::other("spawned child exposed no live pid"),
5365 cgroup_path: cgroup_path.clone(),
5366 })?;
5367 let process_start_time = crate::provenance::process_start_time(pid);
5368 let process_identity = process_start_time.map(|start_time| ProcessIdentity { pid, start_time });
5369
5370 let stdout_pump = match child.stdout.take() {
5371 Some(stdout) => Some(tokio::spawn(pump_stdout_to(stdout, output_sink.clone()))),
5372 None => {
5373 warn!(
5374 module_id = %spec.module_id,
5375 "spawned child exposed no stdout pipe; file capture will be incomplete"
5376 );
5377 None
5378 }
5379 };
5380 let stderr_pump = match child.stderr.take() {
5381 Some(stderr) => {
5382 ring.lock()
5383 .unwrap_or_else(|poisoned| poisoned.into_inner())
5384 .push_process_start();
5385 Some(tokio::spawn(pump_stderr_to(
5386 stderr,
5387 Arc::clone(ring),
5388 output_sink,
5389 )))
5390 }
5391 None => {
5392 ring.lock()
5396 .unwrap_or_else(|poisoned| poisoned.into_inner())
5397 .mark_not_captured("stderr pipe was not available on spawn");
5398 warn!(
5399 module_id = %spec.module_id,
5400 "spawned child exposed no stderr pipe; tail will be unavailable"
5401 );
5402 None
5403 }
5404 };
5405
5406 Ok(SupervisedChild {
5407 child,
5408 #[cfg(target_os = "linux")]
5409 module_id: cgroup_name,
5410 #[cfg(target_os = "linux")]
5411 cgroup_placement: cgroup_placement.cloned(),
5412 stdout_pump,
5413 stderr_pump,
5414 stderr_ring: Arc::clone(ring),
5415 spawned_at_ms,
5416 spawned_from,
5417 spawned_file_identity,
5418 process_start_time,
5419 process_identity,
5420 pid,
5421 })
5422}
5423
5424#[cfg(target_os = "linux")]
5425fn remove_module_cgroup(placement: &subc_cgroup::Placement, module_id: &str) {
5426 match placement.remove_module(module_id) {
5427 Ok(()) => debug!(module_id, "removed module cgroup after process exit"),
5428 Err(error) => warn!(
5429 module_id,
5430 error = %error,
5431 "could not remove module cgroup after process exit; continuing teardown"
5432 ),
5433 }
5434}
5435
5436#[cfg(target_os = "linux")]
5437fn apply_cgroup_placement(
5438 command: &mut Command,
5439 spec: &ModuleSpec,
5440 path: &std::path::Path,
5441) -> Result<(), SuperviseError> {
5442 subc_cgroup::apply(command, path).map_err(|source| SuperviseError::Cgroup {
5443 module_id: spec.module_id.clone(),
5444 source,
5445 })
5446}
5447
5448fn capture_retention(spec: &ModuleSpec) -> Retention {
5449 let defaults = Retention::default();
5450 let value = |name: &str| {
5451 spec.env
5452 .iter()
5453 .rev()
5454 .find_map(|(key, value)| (key == name).then_some(value.as_str()))
5455 };
5456 Retention {
5457 max_file_mb: value(CAPTURE_MAX_FILE_MB_ENV)
5458 .and_then(|value| value.parse().ok())
5459 .unwrap_or(defaults.max_file_mb),
5460 keep: value(CAPTURE_KEEP_ENV)
5461 .and_then(|value| value.parse().ok())
5462 .unwrap_or(defaults.keep),
5463 max_age_days: value(CAPTURE_MAX_AGE_DAYS_ENV)
5464 .and_then(|value| value.parse().ok())
5465 .unwrap_or(defaults.max_age_days),
5466 }
5467}
5468
5469fn generate_launch_nonce() -> Result<String, SuperviseError> {
5472 let mut bytes = [0u8; 32];
5473 getrandom::getrandom(&mut bytes).map_err(|source| SuperviseError::LaunchNonce {
5474 reason: source.to_string(),
5475 })?;
5476 let mut hex = String::with_capacity(64);
5477 for b in bytes {
5478 use std::fmt::Write;
5479 let _ = write!(hex, "{b:02x}");
5480 }
5481 Ok(hex)
5482}
5483
5484fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
5487 if a.len() != b.len() {
5488 return false;
5489 }
5490 let mut diff = 0u8;
5491 for (x, y) in a.iter().zip(b.iter()) {
5492 diff |= x ^ y;
5493 }
5494 diff == 0
5495}
5496
5497fn spawn_and_mark_running(
5498 spec: &ModuleSpec,
5499 runtime: &SupervisorRuntimeConfig,
5500 snapshot: &SharedSnapshot,
5501) -> Result<SupervisedChild, SuperviseError> {
5502 let child = spawn_child(
5503 spec,
5504 runtime.connection_file_path.as_deref(),
5505 runtime.supervisor_handle.as_ref(),
5506 &runtime.stderr_ring,
5507 runtime.capture_logs_dir.as_deref(),
5508 #[cfg(target_os = "linux")]
5509 runtime.cgroup_placement.as_ref(),
5510 )?;
5511 set_running(snapshot, &child, &spec.module_id, &runtime.spawn_events)?;
5512 Ok(child)
5513}
5514
5515enum RegistrationWaitOutcome {
5516 Registered,
5517 Exited(ExitReport),
5518 TimedOut,
5519}
5520
5521struct ReloadRegistrationFailure {
5522 exit_report: ExitReport,
5523 reason: String,
5524}
5525
5526#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5527enum BusyGaugeObservation {
5528 Quiescent,
5529 Busy,
5530 Omitted,
5531}
5532
5533fn busy_gauge_observation(metrics: Option<&Value>, gauges: &[String]) -> BusyGaugeObservation {
5534 let Some(metrics) = metrics.and_then(Value::as_object) else {
5535 return BusyGaugeObservation::Omitted;
5536 };
5537 let mut sum = 0u128;
5538 for gauge in gauges {
5539 let Some(value) = metrics.get(gauge) else {
5540 return BusyGaugeObservation::Omitted;
5541 };
5542 let Some(value) = value.as_u64() else {
5543 return BusyGaugeObservation::Busy;
5544 };
5545 sum = sum.saturating_add(u128::from(value));
5546 }
5547 if sum == 0 {
5548 BusyGaugeObservation::Quiescent
5549 } else {
5550 BusyGaugeObservation::Busy
5551 }
5552}
5553
5554fn declared_busy_gauges(
5555 registry: &Registry,
5556 module_id: &str,
5557) -> Result<Vec<String>, SuperviseError> {
5558 busy_gauges_of(
5559 registry
5560 .get_module(module_id)
5561 .map_err(SuperviseError::Registry)?,
5562 )
5563}
5564
5565fn declared_busy_gauges_for_connection(
5569 registry: &Registry,
5570 connection_id: ConnectionId,
5571) -> Result<Vec<String>, SuperviseError> {
5572 busy_gauges_of(
5573 registry
5574 .get_module_by_connection(connection_id)
5575 .map_err(SuperviseError::Registry)?,
5576 )
5577}
5578
5579fn busy_gauges_of(
5580 registration: Option<crate::registry::ModuleRegistration>,
5581) -> Result<Vec<String>, SuperviseError> {
5582 let Some(registration) = registration else {
5583 return Ok(Vec::new());
5584 };
5585 let Some(self_signals) = registration.manifest.self_signals else {
5586 return Ok(Vec::new());
5587 };
5588
5589 let mut gauges = Vec::new();
5590 for declaration in self_signals {
5591 if declaration.kind != SelfSignalKind::Busy {
5592 continue;
5593 }
5594 match declaration.anchored_to {
5595 SignalAnchor::HealthGauges { gauges: declared } if !declared.is_empty() => {
5596 gauges.extend(declared)
5597 }
5598 _ => {
5599 gauges.push(String::new());
5602 }
5603 }
5604 }
5605 Ok(gauges)
5606}
5607
5608async fn wait_for_forwarding_quiescence(
5613 forwarding: &ForwardingTable,
5614 module_id: &str,
5615 runtime: &SupervisorRuntimeConfig,
5616 endpoint: crate::ModuleEndpointId,
5617 deadline: Instant,
5618 busy_gauges: &[String],
5619 scope: DrainScope,
5620) -> Result<bool, SuperviseError> {
5621 let mut gauges_quiescent = busy_gauges.is_empty();
5622 let mut next_probe_at = Instant::now();
5623 let mut omission_counted = false;
5624
5625 loop {
5626 let now = Instant::now();
5627 if !busy_gauges.is_empty() && now >= next_probe_at && now < deadline {
5628 let report = match scope {
5629 DrainScope::Active => probe_module_health(module_id, runtime, Some(deadline)).await,
5630 DrainScope::Endpoint(endpoint) => {
5631 probe_endpoint_health(endpoint, runtime, Some(deadline)).await
5632 }
5633 };
5634 gauges_quiescent = match report {
5635 Ok(report) => match busy_gauge_observation(report.metrics.as_ref(), busy_gauges) {
5636 BusyGaugeObservation::Quiescent => true,
5637 BusyGaugeObservation::Busy => false,
5638 BusyGaugeObservation::Omitted => {
5639 if !omission_counted {
5640 forwarding
5641 .counters()
5642 .increment_drains_with_undeclared_gauge();
5643 omission_counted = true;
5644 }
5645 false
5646 }
5647 },
5648 Err(err) => {
5649 warn!(
5650 module_id,
5651 error = %err,
5652 "drain health.check did not produce declared busy gauges; treating module as busy"
5653 );
5654 false
5655 }
5656 };
5657 next_probe_at = Instant::now() + runtime.health.cadence.max(REGISTRY_RELEASE_POLL);
5658 }
5659
5660 let in_flight = forwarding
5661 .endpoint_in_flight_count(endpoint)
5662 .map_err(SuperviseError::Forwarding)?;
5663 if in_flight == 0 && gauges_quiescent {
5664 return Ok(true);
5665 }
5666
5667 let now = Instant::now();
5668 if now >= deadline {
5669 return Ok(false);
5670 }
5671 let mut wait = deadline
5672 .saturating_duration_since(now)
5673 .min(REGISTRY_RELEASE_POLL);
5674 if !busy_gauges.is_empty() {
5675 wait = wait.min(next_probe_at.saturating_duration_since(now));
5676 }
5677 sleep(wait).await;
5678 }
5679}
5680
5681fn drained_after_quiescence_wait(wait_result: &Result<bool, SuperviseError>) -> bool {
5689 match wait_result {
5690 Ok(drained) => *drained,
5691 Err(_) => false,
5692 }
5693}
5694
5695fn send_route_goodbyes(forwarding: &ForwardingTable, released_routes: Vec<GoodbyeTarget>) {
5696 for released in released_routes {
5697 let frame = match Frame::build_with_version(
5698 released.negotiated_ver,
5699 FrameType::Goodbye,
5700 control_flags(),
5701 released.channel,
5702 released.epoch,
5703 0,
5704 Vec::new(),
5705 ) {
5706 Ok(frame) => frame,
5707 Err(err) => {
5708 warn!(
5709 route_channel = released.channel,
5710 error = %err,
5711 "failed to build supervisor drain route GOODBYE frame"
5712 );
5713 continue;
5714 }
5715 };
5716 if let Err(err) = released.sink.try_send(frame) {
5717 if released.close_on_delivery_failure() {
5718 warn!(
5719 target_connection_id = released.connection_id.get(),
5720 route_channel = released.channel,
5721 error = %err,
5722 "supervisor drain route GOODBYE was not delivered to client; closing target connection"
5723 );
5724 let _ = forwarding.escalate_client_delivery_failure(
5725 released.connection_id,
5726 released.channel,
5727 released.epoch,
5728 CloseReason::new(
5729 "route_goodbye_delivery_failed",
5730 format!(
5731 "failed to enqueue supervisor drain route GOODBYE for channel {}: {err}",
5732 released.channel
5733 ),
5734 ),
5735 );
5736 } else {
5737 warn!(
5738 target_connection_id = released.connection_id.get(),
5739 route_channel = released.channel,
5740 error = %err,
5741 "supervisor drain route GOODBYE to module dropped under backpressure; not closing shared module connection"
5742 );
5743 }
5744 }
5745 }
5746}
5747
5748fn send_module_draining(
5749 module_id: &str,
5750 reason: RouteCloseReason,
5751 deadline_ms: u64,
5752 target: &ModuleDrainTarget,
5753) {
5754 let body = match serde_json::to_vec(&ModuleControlCommand::Draining {
5755 reason,
5756 deadline_ms,
5757 }) {
5758 Ok(body) => body,
5759 Err(err) => {
5760 warn!(
5761 module_id,
5762 error = %err,
5763 "failed to encode module draining command"
5764 );
5765 return;
5766 }
5767 };
5768 let frame = match Frame::build_with_version(
5769 target.negotiated_ver,
5770 FrameType::Push,
5771 control_flags(),
5772 0,
5773 0,
5774 0,
5775 body,
5776 ) {
5777 Ok(frame) => frame,
5778 Err(err) => {
5779 warn!(
5780 module_id,
5781 error = %err,
5782 "failed to build module draining command frame"
5783 );
5784 return;
5785 }
5786 };
5787 if let Err(err) = target.sink.try_send(frame) {
5788 warn!(
5789 module_id,
5790 target_connection_id = target.endpoint.connection_id.get(),
5791 error = %err,
5792 "module draining command was not delivered to peer"
5793 );
5794 }
5795}
5796
5797fn send_module_goodbye(module_id: &str, forwarding: &ForwardingTable, target: &ModuleDrainTarget) {
5798 let frame = match Frame::build_with_version(
5799 target.negotiated_ver,
5800 FrameType::Goodbye,
5801 control_flags(),
5802 0,
5803 0,
5804 0,
5805 Vec::new(),
5806 ) {
5807 Ok(frame) => frame,
5808 Err(err) => {
5809 warn!(
5810 module_id,
5811 error = %err,
5812 "failed to build supervisor drain module GOODBYE frame"
5813 );
5814 return;
5815 }
5816 };
5817 if let Err(err) = target.sink.try_send(frame) {
5818 warn!(
5819 module_id,
5820 target_connection_id = target.endpoint.connection_id.get(),
5821 error = %err,
5822 "supervisor drain module GOODBYE was not delivered to peer; closing module connection"
5823 );
5824 forwarding.request_connection_close(
5825 target.endpoint.connection_id,
5826 CloseReason::new(
5827 "module_goodbye_delivery_failed",
5828 format!("failed to enqueue supervisor drain module GOODBYE for module '{module_id}': {err}"),
5829 ),
5830 );
5831 }
5832}
5833
5834#[derive(Clone, Copy)]
5835struct ForwardingDrainContext<'a> {
5836 spec: &'a ModuleSpec,
5837 runtime: &'a SupervisorRuntimeConfig,
5838 registry: &'a Registry,
5839 scope: DrainScope,
5840}
5841
5842#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5844enum DrainScope {
5845 Active,
5848 Endpoint(crate::ModuleEndpointId),
5853}
5854
5855async fn begin_forwarding_drain(
5856 spec: &ModuleSpec,
5857 runtime: &SupervisorRuntimeConfig,
5858 registry: &Registry,
5859 snapshot: &SharedSnapshot,
5860 enabled: Option<bool>,
5861 reason: RouteCloseReason,
5862) -> Result<(), SuperviseError> {
5863 let Some(forwarding) = runtime.forwarding.as_ref() else {
5864 return Err(SuperviseError::ReloadUnavailable {
5865 module_id: spec.module_id.clone(),
5866 reason: "supervisor was not configured with a forwarding table".to_string(),
5867 });
5868 };
5869
5870 begin_forwarding_drain_with(
5871 forwarding,
5872 ForwardingDrainContext {
5873 spec,
5874 runtime,
5875 registry,
5876 scope: DrainScope::Active,
5877 },
5878 snapshot,
5879 enabled,
5880 reason,
5881 runtime.drain_timeout,
5882 )
5883 .await
5884}
5885
5886async fn begin_forwarding_drain_if_configured(
5887 spec: &ModuleSpec,
5888 runtime: &SupervisorRuntimeConfig,
5889 registry: &Registry,
5890 snapshot: &SharedSnapshot,
5891 enabled: Option<bool>,
5892 reason: RouteCloseReason,
5893) -> Result<(), SuperviseError> {
5894 begin_forwarding_drain_with_timeout(
5895 spec,
5896 runtime,
5897 registry,
5898 snapshot,
5899 enabled,
5900 reason,
5901 runtime.drain_timeout,
5902 )
5903 .await
5904}
5905
5906async fn begin_forwarding_drain_with_timeout(
5910 spec: &ModuleSpec,
5911 runtime: &SupervisorRuntimeConfig,
5912 registry: &Registry,
5913 snapshot: &SharedSnapshot,
5914 enabled: Option<bool>,
5915 reason: RouteCloseReason,
5916 drain_timeout: Duration,
5917) -> Result<(), SuperviseError> {
5918 let Some(forwarding) = runtime.forwarding.as_ref() else {
5919 return Ok(());
5920 };
5921
5922 begin_forwarding_drain_with(
5923 forwarding,
5924 ForwardingDrainContext {
5925 spec,
5926 runtime,
5927 registry,
5928 scope: DrainScope::Active,
5929 },
5930 snapshot,
5931 enabled,
5932 reason,
5933 drain_timeout,
5934 )
5935 .await
5936}
5937
5938async fn begin_forwarding_drain_with(
5939 forwarding: &ForwardingTable,
5940 context: ForwardingDrainContext<'_>,
5941 snapshot: &SharedSnapshot,
5942 enabled: Option<bool>,
5943 reason: RouteCloseReason,
5944 drain_timeout: Duration,
5945) -> Result<(), SuperviseError> {
5946 let ForwardingDrainContext {
5947 spec,
5948 runtime,
5949 registry,
5950 scope,
5951 } = context;
5952 debug_assert_ne!(reason, RouteCloseReason::Crash);
5953 let terminal = matches!(reason, RouteCloseReason::Disable);
5954 let drain_started_at = Instant::now();
5955 let drain_deadline = drain_started_at + drain_timeout;
5956 let deadline_ms =
5957 unix_ms_now().saturating_add(u64::try_from(drain_timeout.as_millis()).unwrap_or(u64::MAX));
5958 let busy_gauges = match scope {
5959 DrainScope::Active => declared_busy_gauges(registry, &spec.module_id)?,
5960 DrainScope::Endpoint(endpoint) => {
5961 declared_busy_gauges_for_connection(registry, endpoint.connection_id)?
5962 }
5963 };
5964
5965 let drain_target = match scope {
5968 DrainScope::Active => forwarding.begin_module_drain(&spec.module_id, reason),
5969 DrainScope::Endpoint(endpoint) => forwarding.begin_endpoint_drain(endpoint, reason),
5970 }
5971 .map_err(SuperviseError::Forwarding)?;
5972 if scope == DrainScope::Active {
5973 update_snapshot(snapshot, Some(&spec.module_id), |state| {
5974 state.state = ModuleState::Draining;
5975 if let Some(enabled) = enabled {
5976 state.enabled = enabled;
5977 }
5978 })?;
5979 }
5980
5981 if let Some(target) = drain_target.as_ref() {
5982 send_module_draining(&spec.module_id, reason, deadline_ms, target);
5983 let routes = forwarding
5984 .endpoint_routes(target.endpoint)
5985 .map_err(SuperviseError::Forwarding)?;
5986 let routes_notified = routes.len();
5987 crate::control::send_route_control_pushes(
5988 forwarding,
5989 routes.clone(),
5990 ClientControlPush::RouteClosing {
5991 module_id: spec.module_id.clone(),
5992 reason,
5993 },
5994 );
5995 send_route_goodbyes(forwarding, target.abandoned_bindings.clone());
5996
5997 let wait_result = wait_for_forwarding_quiescence(
6003 forwarding,
6004 &spec.module_id,
6005 runtime,
6006 target.endpoint,
6007 drain_deadline,
6008 &busy_gauges,
6009 scope,
6010 )
6011 .await;
6012 let drained = drained_after_quiescence_wait(&wait_result);
6013 if let Err(err) = &wait_result {
6014 error!(
6015 module_id = %spec.module_id,
6016 ?reason,
6017 error = %err,
6018 "forwarding quiescence wait failed after route.closing; forcing route.closed(drained: false) so the client is not left waiting on an unfulfilled promise"
6019 );
6020 } else if !drained {
6021 let holdouts = forwarding
6027 .endpoint_drain_holdouts(target.endpoint)
6028 .unwrap_or_default();
6029 warn!(
6030 module_id = %spec.module_id,
6031 waited = ?drain_timeout,
6032 ?reason,
6033 held_requests = holdouts.requests,
6034 held_routes = holdouts.routes,
6035 total_routes = holdouts.total_routes,
6036 top_connections = ?holdouts.top_connections,
6037 "route drain timed out before request quiescence; forcing teardown"
6038 );
6039 }
6040 crate::control::send_route_control_pushes(
6041 forwarding,
6042 routes,
6043 ClientControlPush::RouteClosed {
6044 module_id: spec.module_id.clone(),
6045 reason,
6046 drained,
6047 abandoned: target.abandoned_bindings.len() as u32,
6048 excluded_subscriptions: target.excluded_subscriptions,
6049 terminal: Some(terminal),
6050 },
6051 );
6052 wait_result?;
6053
6054 let released_routes = match forwarding.release_module_endpoint_routes(target.endpoint) {
6060 Ok(routes) => routes,
6061 Err(err) => {
6062 warn!(
6063 module_id = %spec.module_id,
6064 ?reason,
6065 error = %err,
6066 "failed to release module endpoint routes after route.closed; module GOODBYE will still be sent"
6067 );
6068 send_module_goodbye(&spec.module_id, forwarding, target);
6069 return Err(SuperviseError::Forwarding(err));
6070 }
6071 };
6072 let route_goodbye_count = released_routes.len();
6073 send_route_goodbyes(forwarding, released_routes);
6074 send_module_goodbye(&spec.module_id, forwarding, target);
6075
6076 info!(
6082 module_id = %spec.module_id,
6083 ?reason,
6084 routes_notified,
6085 route_goodbyes = route_goodbye_count,
6086 abandoned_reservations = target.abandoned_bindings.len(),
6087 excluded_subscriptions = target.excluded_subscriptions,
6088 drained,
6089 "module drain complete; consumers notified via route.closing/route.closed pushes and per-route GOODBYE frames"
6090 );
6091 }
6092
6093 Ok(())
6094}
6095
6096async fn wait_for_registration_after_reload(
6099 registry: &Registry,
6100 module_id: &str,
6101 snapshot: &SharedSnapshot,
6102 child: &mut SupervisedChild,
6103 wait: Duration,
6104) -> Result<RegistrationWaitOutcome, SuperviseError> {
6105 wait_for_slot_registration(
6106 registry,
6107 crate::registry::RegistrationSlot::Active(module_id),
6108 module_id,
6109 snapshot,
6110 child,
6111 wait,
6112 )
6113 .await
6114}
6115
6116async fn wait_for_slot_registration(
6124 registry: &Registry,
6125 slot: crate::registry::RegistrationSlot<'_>,
6126 module_id: &str,
6127 snapshot: &SharedSnapshot,
6128 child: &mut SupervisedChild,
6129 wait: Duration,
6130) -> Result<RegistrationWaitOutcome, SuperviseError> {
6131 let deadline = Instant::now() + wait;
6132 loop {
6133 if registry
6134 .registration(slot)
6135 .map_err(SuperviseError::Registry)?
6136 .is_some()
6137 {
6138 return Ok(RegistrationWaitOutcome::Registered);
6139 }
6140
6141 let now = Instant::now();
6142 if now >= deadline {
6143 return Ok(RegistrationWaitOutcome::TimedOut);
6144 }
6145 let remaining = deadline.saturating_duration_since(now);
6146 let poll = remaining.min(REGISTRY_RELEASE_POLL);
6147
6148 tokio::select! {
6149 wait_result = child.wait() => {
6150 let status = wait_result.map_err(|source| SuperviseError::Wait {
6151 module_id: module_id.to_string(),
6152 source,
6153 })?;
6154 return Ok(RegistrationWaitOutcome::Exited(classify_reaped_child_exit(
6155 snapshot,
6156 child,
6157 &status,
6158 )));
6159 }
6160 _ = sleep(poll) => {}
6161 }
6162 }
6163}
6164
6165fn registration_failure_exit_report(mut exit_report: ExitReport) -> ExitReport {
6166 if exit_report.kind != ExitKind::DeliberateSeverance {
6169 exit_report.kind = ExitKind::Crash;
6170 }
6171 exit_report
6172}
6173
6174async fn handle_reload_child_registration_failure(
6175 spec: &ModuleSpec,
6176 runtime: &SupervisorRuntimeConfig,
6177 registry: &Registry,
6178 process_liveness: &SupervisorProcessLiveness,
6179 snapshot: &SharedSnapshot,
6180 child: &mut Option<SupervisedChild>,
6181 failure: ReloadRegistrationFailure,
6182) -> Result<(), SuperviseError> {
6183 let ReloadRegistrationFailure {
6184 exit_report,
6185 reason,
6186 } = failure;
6187 match on_child_exit(
6188 spec,
6189 runtime.restart_policy,
6190 registry,
6191 snapshot,
6192 &runtime.terminal_ring,
6193 &runtime.spawn_events,
6194 exit_report,
6195 )
6196 .await
6197 {
6198 NextAction::Stop {
6199 registration_released,
6200 } => {
6201 if registration_released {
6202 process_liveness.untrack_if_current(&spec.module_id, snapshot);
6203 }
6204 }
6205 NextAction::Restart { schedule } => {
6206 let delay = schedule.map_or(runtime.restart_policy.delay_for_restart(0), |schedule| {
6207 schedule.delay
6208 });
6209 if let Some(schedule) = schedule {
6210 log_crash_respawn(&spec.module_id, schedule);
6211 }
6212 sleep(delay).await;
6213 if respawn_still_pending(snapshot) {
6217 if let Err(err) = wait_for_registration_release(
6218 registry,
6219 &spec.module_id,
6220 REGISTRY_RELEASE_TIMEOUT,
6221 )
6222 .await
6223 {
6224 fail_snapshot(snapshot, Some(&spec.module_id), None);
6225 process_liveness.untrack_if_current(&spec.module_id, snapshot);
6226 return Err(SuperviseError::ReloadFailed {
6227 module_id: spec.module_id.clone(),
6228 reason: format!(
6229 "{reason}; registration did not release before policy retry: {err}"
6230 ),
6231 });
6232 }
6233 process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
6234 match spawn_and_mark_running(spec, runtime, snapshot) {
6235 Ok(next_child) => {
6236 *child = Some(next_child);
6237 }
6238 Err(err) => {
6239 fail_snapshot(snapshot, Some(&spec.module_id), None);
6240 process_liveness.untrack_if_current(&spec.module_id, snapshot);
6241 return Err(SuperviseError::ReloadFailed {
6242 module_id: spec.module_id.clone(),
6243 reason: format!("{reason}; policy retry spawn failed: {err}"),
6244 });
6245 }
6246 }
6247 }
6248 }
6249 }
6250
6251 Err(SuperviseError::ReloadFailed {
6252 module_id: spec.module_id.clone(),
6253 reason,
6254 })
6255}
6256
6257async fn handle_reload_spawn_failure(
6258 spec: &ModuleSpec,
6259 runtime: &SupervisorRuntimeConfig,
6260 process_liveness: &SupervisorProcessLiveness,
6261 snapshot: &SharedSnapshot,
6262 child: &mut Option<SupervisedChild>,
6263 reason: String,
6264) -> Result<(), SuperviseError> {
6265 let mut should_retry = false;
6266 let now = Instant::now();
6267 update_snapshot(snapshot, Some(&spec.module_id), |state| {
6268 clear_current_process_facts(state);
6269 if daemon_will_restart(state, &runtime.restart_policy, now) {
6270 state.record_crash_restart(&runtime.restart_policy, now);
6271 state.state = ModuleState::Restarting;
6272 should_retry = true;
6273 } else if state.enabled {
6274 state.state = ModuleState::Failed;
6275 } else {
6276 state.state = ModuleState::Disabled;
6277 }
6278 })?;
6279
6280 if should_retry {
6281 sleep(runtime.restart_policy.backoff).await;
6282 if respawn_still_pending(snapshot) {
6286 process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
6287 match spawn_and_mark_running(spec, runtime, snapshot) {
6288 Ok(next_child) => {
6289 *child = Some(next_child);
6290 }
6291 Err(err) => {
6292 fail_snapshot(snapshot, Some(&spec.module_id), None);
6293 process_liveness.untrack_if_current(&spec.module_id, snapshot);
6294 return Err(SuperviseError::ReloadFailed {
6295 module_id: spec.module_id.clone(),
6296 reason: format!("{reason}; policy retry spawn failed: {err}"),
6297 });
6298 }
6299 }
6300 }
6301 } else {
6302 process_liveness.untrack_if_current(&spec.module_id, snapshot);
6303 }
6304
6305 Err(SuperviseError::ReloadFailed {
6306 module_id: spec.module_id.clone(),
6307 reason,
6308 })
6309}
6310
6311fn control_flags() -> Flags {
6312 Flags::new(false, Priority::Passive, false)
6313}
6314
6315#[allow(clippy::too_many_arguments)]
6316async fn drain_optional_child(
6317 module_id: &str,
6318 protocol: ModuleProtocol,
6319 registry: &Registry,
6320 snapshot: &SharedSnapshot,
6321 terminal_ring: &Arc<Mutex<TerminalRing>>,
6322 spawn_events: &SpawnEventFeed,
6323 child: &mut Option<SupervisedChild>,
6324 drain_timeout: Duration,
6325 final_state: ModuleState,
6326 enabled: Option<bool>,
6327) -> Result<(), SuperviseError> {
6328 if let Some(child) = child.take() {
6329 drain_child_to_state(
6330 module_id,
6331 protocol,
6332 registry,
6333 snapshot,
6334 terminal_ring,
6335 spawn_events,
6336 child,
6337 drain_timeout,
6338 final_state,
6339 enabled,
6340 )
6341 .await
6342 } else {
6343 update_snapshot(snapshot, Some(module_id), |state| {
6344 state.state = final_state;
6345 if let Some(enabled) = enabled {
6346 state.enabled = enabled;
6347 }
6348 clear_current_process_facts(state);
6349 })?;
6350 wait_for_registration_release(registry, module_id, REGISTRY_RELEASE_TIMEOUT).await
6351 }
6352}
6353
6354#[allow(clippy::too_many_arguments)]
6355async fn drain_child_to_state(
6356 module_id: &str,
6357 protocol: ModuleProtocol,
6358 registry: &Registry,
6359 snapshot: &SharedSnapshot,
6360 terminal_ring: &Arc<Mutex<TerminalRing>>,
6361 spawn_events: &SpawnEventFeed,
6362 mut child: SupervisedChild,
6363 drain_timeout: Duration,
6364 final_state: ModuleState,
6365 enabled: Option<bool>,
6366) -> Result<(), SuperviseError> {
6367 update_snapshot(snapshot, Some(module_id), |state| {
6368 state.state = ModuleState::Draining;
6369 if let Some(enabled) = enabled {
6370 state.enabled = enabled;
6371 }
6372 })?;
6373
6374 if protocol == ModuleProtocol::None {
6380 request_graceful_stop(module_id, &child);
6381 }
6382
6383 let exit_report = match timeout(drain_timeout, child.wait()).await {
6384 Ok(Ok(status)) => classify_reaped_child_exit(snapshot, &child, &status),
6385 Ok(Err(source)) => {
6386 fail_snapshot(snapshot, Some(module_id), None);
6387 return Err(SuperviseError::Wait {
6388 module_id: module_id.to_string(),
6389 source,
6390 });
6391 }
6392 Err(_) => {
6393 child.start_kill().map_err(|source| {
6402 fail_snapshot(snapshot, Some(module_id), None);
6403 SuperviseError::Kill {
6404 module_id: module_id.to_string(),
6405 source,
6406 }
6407 })?;
6408 let status = child.wait().await.map_err(|source| {
6409 fail_snapshot(snapshot, Some(module_id), None);
6410 SuperviseError::Wait {
6411 module_id: module_id.to_string(),
6412 source,
6413 }
6414 })?;
6415 classify_reaped_child_exit(snapshot, &child, &status)
6416 }
6417 };
6418
6419 update_snapshot(snapshot, Some(module_id), |state| {
6420 state.state = final_state;
6421 if let Some(enabled) = enabled {
6422 state.enabled = enabled;
6423 }
6424 clear_current_process_facts(state);
6425 state.last_exit = Some(exit_report.clone());
6426 if exit_report.kind == ExitKind::DeliberateSeverance {
6427 state.lifetime_restarts += 1;
6428 }
6429 })?;
6430 record_terminal(
6431 module_id,
6432 terminal_ring,
6433 spawn_events,
6434 &exit_report,
6435 terminal_disposition(final_state),
6436 );
6437 child.drain_stderr(module_id).await;
6438
6439 wait_for_registration_release(registry, module_id, REGISTRY_RELEASE_TIMEOUT).await
6440}
6441
6442#[cfg(unix)]
6462fn request_graceful_stop(module_id: &str, child: &SupervisedChild) {
6463 let Some(pid) = child
6464 .id()
6465 .and_then(|pid| i32::try_from(pid).ok())
6466 .and_then(rustix::process::Pid::from_raw)
6467 else {
6468 debug!(
6469 module_id,
6470 "no pid to signal for protocol: none teardown; falling through to the drain wait"
6471 );
6472 return;
6473 };
6474 match rustix::process::kill_process(pid, rustix::process::Signal::TERM) {
6475 Ok(()) => debug!(module_id, "sent SIGTERM to protocol: none module"),
6476 Err(err) => debug!(
6477 module_id,
6478 error = %err,
6479 "SIGTERM to protocol: none module failed; the drain wait and kill still apply"
6480 ),
6481 }
6482}
6483
6484#[cfg(not(unix))]
6492fn request_graceful_stop(module_id: &str, _child: &SupervisedChild) {
6493 debug!(
6494 module_id,
6495 "no graceful stop signal exists on this platform; protocol: none teardown waits, then kills"
6496 );
6497}
6498
6499fn terminal_disposition(final_state: ModuleState) -> TerminalDisposition {
6500 match final_state {
6501 ModuleState::Stopped => TerminalDisposition::Stopped,
6502 ModuleState::Disabled => TerminalDisposition::Disabled,
6503 ModuleState::Restarting => TerminalDisposition::Restarting,
6504 ModuleState::Failed => TerminalDisposition::Failed,
6505 ModuleState::Starting
6506 | ModuleState::Running
6507 | ModuleState::Unresponsive
6508 | ModuleState::Draining => {
6509 unreachable!("terminal exits only finish in terminal or restarting states")
6510 }
6511 }
6512}
6513
6514async fn wait_for_registration_release(
6517 registry: &Registry,
6518 module_id: &str,
6519 wait: Duration,
6520) -> Result<(), SuperviseError> {
6521 wait_for_slot_registration_release(
6522 registry,
6523 crate::registry::RegistrationSlot::Active(module_id),
6524 wait,
6525 )
6526 .await
6527}
6528
6529async fn wait_for_slot_registration_release(
6537 registry: &Registry,
6538 slot: crate::registry::RegistrationSlot<'_>,
6539 wait: Duration,
6540) -> Result<(), SuperviseError> {
6541 let deadline = Instant::now() + wait;
6542 let mut release_events = registration_release_events().subscribe();
6543 let still_active = |registration: &crate::registry::ModuleRegistration| {
6544 SuperviseError::RegistrationStillActive {
6545 module_id: registration.manifest.module_id.clone(),
6546 waited: wait,
6547 }
6548 };
6549 loop {
6550 let _observed_generation = *release_events.borrow_and_update();
6551 let Some(registration) = registry
6552 .registration(slot)
6553 .map_err(SuperviseError::Registry)?
6554 else {
6555 return Ok(());
6556 };
6557
6558 let now = Instant::now();
6559 if now >= deadline {
6560 return Err(still_active(®istration));
6561 }
6562
6563 let remaining = deadline.saturating_duration_since(now);
6564 match timeout(remaining, release_events.changed()).await {
6565 Ok(Ok(())) | Ok(Err(_)) => {}
6566 Err(_) => return Err(still_active(®istration)),
6567 }
6568 }
6569}
6570
6571#[cfg(test)]
6572mod slot_registration_wait_tests {
6573 use super::*;
6574 use crate::registry::{ConnectionId, RegistrationSlot};
6575 use subc_protocol::manifest::ModuleManifest;
6576
6577 const INCUMBENT: u64 = 1;
6578 const CANDIDATE: u64 = 2;
6579
6580 fn swapped_registry() -> Arc<Registry> {
6581 let registry = Arc::new(Registry::default());
6582 let manifest = ModuleManifest::builder("m", "0.1.0").build();
6583 registry
6584 .register_with_control_ops(
6585 manifest.clone(),
6586 1,
6587 ConnectionId::new(INCUMBENT),
6588 Vec::new(),
6589 )
6590 .unwrap();
6591 registry
6592 .register_candidate_with_control_ops(
6593 manifest,
6594 1,
6595 ConnectionId::new(CANDIDATE),
6596 Vec::new(),
6597 )
6598 .unwrap();
6599 registry
6600 }
6601
6602 #[tokio::test]
6606 async fn incumbent_release_is_awaited_by_connection_not_by_module_id() {
6607 let registry = swapped_registry();
6608 registry.promote_candidate("m").unwrap().unwrap();
6609
6610 assert!(matches!(
6611 wait_for_registration_release(®istry, "m", Duration::from_millis(50)).await,
6612 Err(SuperviseError::RegistrationStillActive { .. })
6613 ));
6614
6615 assert!(matches!(
6617 wait_for_slot_registration_release(
6618 ®istry,
6619 RegistrationSlot::Connection(ConnectionId::new(INCUMBENT)),
6620 Duration::from_millis(50),
6621 )
6622 .await,
6623 Err(SuperviseError::RegistrationStillActive { .. })
6624 ));
6625
6626 let releaser = Arc::clone(®istry);
6627 let release = tokio::spawn(async move {
6628 sleep(Duration::from_millis(20)).await;
6629 releaser
6630 .deregister_connection(ConnectionId::new(INCUMBENT))
6631 .unwrap();
6632 notify_registration_release();
6633 });
6634 wait_for_slot_registration_release(
6635 ®istry,
6636 RegistrationSlot::Connection(ConnectionId::new(INCUMBENT)),
6637 Duration::from_secs(5),
6638 )
6639 .await
6640 .expect("the incumbent's own registration is released");
6641 release.await.unwrap();
6642 assert!(registry.get_module("m").unwrap().is_some());
6643 }
6644
6645 #[tokio::test]
6648 async fn candidate_slot_wait_ignores_the_incumbents_registration() {
6649 let registry = swapped_registry();
6650 assert!(matches!(
6651 wait_for_slot_registration_release(
6652 ®istry,
6653 RegistrationSlot::Candidate("m"),
6654 Duration::from_millis(50),
6655 )
6656 .await,
6657 Err(SuperviseError::RegistrationStillActive { .. })
6658 ));
6659 registry
6660 .deregister_connection(ConnectionId::new(CANDIDATE))
6661 .unwrap();
6662 wait_for_slot_registration_release(
6663 ®istry,
6664 RegistrationSlot::Candidate("m"),
6665 Duration::from_millis(50),
6666 )
6667 .await
6668 .expect("a candidate slot with no candidate is released");
6669 assert!(registry
6670 .registration(RegistrationSlot::Active("m"))
6671 .unwrap()
6672 .is_some());
6673 }
6674}
6675
6676fn classify_exit(status: &ExitStatus) -> ExitReport {
6677 ExitReport {
6678 kind: if status.success() {
6679 ExitKind::Clean
6680 } else {
6681 ExitKind::Crash
6682 },
6683 code: status.code(),
6684 signal: exit_signal(status),
6685 at_ms: unix_ms_now(),
6686 }
6687}
6688
6689fn wait_error_exit_report() -> ExitReport {
6695 ExitReport {
6696 kind: ExitKind::Crash,
6697 code: None,
6698 signal: None,
6699 at_ms: unix_ms_now(),
6700 }
6701}
6702
6703#[cfg(unix)]
6704fn exit_signal(status: &ExitStatus) -> Option<i32> {
6705 use std::os::unix::process::ExitStatusExt;
6706
6707 status.signal()
6708}
6709
6710#[cfg(not(unix))]
6711fn exit_signal(_status: &ExitStatus) -> Option<i32> {
6712 None
6713}
6714
6715fn reset_restart_count(snapshot: &SharedSnapshot, module_id: &str) -> Result<(), SuperviseError> {
6721 update_snapshot(snapshot, Some(module_id), |state| {
6722 state.clear_crash_restarts();
6723 })
6724}
6725
6726fn set_running(
6727 snapshot: &SharedSnapshot,
6728 child: &SupervisedChild,
6729 module_id: &str,
6730 spawn_events: &SpawnEventFeed,
6731) -> Result<(), SuperviseError> {
6732 let mut state = snapshot.lock().map_err(|_| SuperviseError::StatePoisoned {
6733 module_id: Some(module_id.to_string()),
6734 })?;
6735 state.spawn_generation = spawn_events.emit_spawned(module_id, child.pid, child.spawned_at_ms);
6736 state.in_alternate_slot = false;
6739 state.state = ModuleState::Running;
6740 state.enabled = true;
6741 state.process_alive = true;
6742 state.pid = child.id();
6743 state.spawned_at_ms = Some(child.spawned_at_ms);
6744 state.spawned_from = Some(child.spawned_from.clone());
6745 state.spawned_file_identity = child.spawned_file_identity;
6746 state.process_start_time = child.process_start_time;
6747 Ok(())
6748}
6749
6750fn clear_current_process_facts(state: &mut SupervisorSnapshot) {
6751 state.process_alive = false;
6752 state.pid = None;
6753 state.spawned_at_ms = None;
6754 state.spawned_from = None;
6755 state.spawned_file_identity = None;
6756 state.process_start_time = None;
6757 state.deliberate_severance = None;
6758}
6759
6760#[cfg(test)]
6761fn record_deliberate_severance(
6762 snapshot: &SharedSnapshot,
6763 identity: ProcessIdentity,
6764) -> Result<(), SuperviseError> {
6765 update_snapshot(snapshot, None, |state| {
6766 state.deliberate_severance = Some(identity);
6767 })
6768}
6769
6770fn apply_deliberate_severance_marker(
6771 snapshot: &SharedSnapshot,
6772 exited_identity: Option<ProcessIdentity>,
6773 mut exit_report: ExitReport,
6774) -> ExitReport {
6775 let marker = lock_snapshot(snapshot)
6776 .ok()
6777 .and_then(|mut state| state.deliberate_severance.take());
6778 if marker.is_some() && marker == exited_identity {
6779 exit_report.kind = ExitKind::DeliberateSeverance;
6780 }
6781 exit_report
6782}
6783
6784fn classify_reaped_child_exit(
6785 snapshot: &SharedSnapshot,
6786 child: &SupervisedChild,
6787 status: &ExitStatus,
6788) -> ExitReport {
6789 apply_deliberate_severance_marker(snapshot, child.process_identity(), classify_exit(status))
6790}
6791
6792fn fail_snapshot(
6793 snapshot: &SharedSnapshot,
6794 module_id: Option<&str>,
6795 last_exit: Option<ExitReport>,
6796) {
6797 if let Err(err) = update_snapshot(snapshot, module_id, |state| {
6798 state.state = ModuleState::Failed;
6799 clear_current_process_facts(state);
6800 if let Some(last_exit) = last_exit {
6801 state.last_exit = Some(last_exit);
6802 }
6803 }) {
6804 error!(error = %err, "failed to mark supervisor state failed");
6805 }
6806}
6807
6808fn update_snapshot(
6809 snapshot: &SharedSnapshot,
6810 module_id: Option<&str>,
6811 update: impl FnOnce(&mut SupervisorSnapshot),
6812) -> Result<(), SuperviseError> {
6813 let mut state = snapshot.lock().map_err(|_| SuperviseError::StatePoisoned {
6814 module_id: module_id.map(ToOwned::to_owned),
6815 })?;
6816 update(&mut state);
6817 Ok(())
6818}
6819
6820const SLOW_SNAPSHOT_LOCK_THRESHOLD: Duration = Duration::from_millis(250);
6821
6822fn lock_snapshot_for_control<'a>(
6823 snapshot: &'a SharedSnapshot,
6824 module_id: &str,
6825 caller: &'static str,
6826) -> Result<std::sync::MutexGuard<'a, SupervisorSnapshot>, SuperviseError> {
6827 let started_at = Instant::now();
6828 let guard = lock_snapshot(snapshot)?;
6829 let waited = started_at.elapsed();
6830 if waited >= SLOW_SNAPSHOT_LOCK_THRESHOLD {
6831 warn!(
6832 module_id = %module_id,
6833 waited_ms = waited.as_millis() as u64,
6834 caller = %caller,
6835 "slow snapshot lock"
6836 );
6837 }
6838 Ok(guard)
6839}
6840
6841fn lock_snapshot(
6842 snapshot: &SharedSnapshot,
6843) -> Result<std::sync::MutexGuard<'_, SupervisorSnapshot>, SuperviseError> {
6844 snapshot
6845 .lock()
6846 .map_err(|_| SuperviseError::StatePoisoned { module_id: None })
6847}
6848
6849#[cfg(test)]
6850mod terminal_history_tests {
6851 use std::{
6852 path::PathBuf,
6853 sync::Arc,
6854 time::{Duration, Instant},
6855 };
6856
6857 use tokio::time::sleep;
6858
6859 use super::{
6860 apply_deliberate_severance_marker, daemon_will_restart, drain_child_to_state,
6861 drained_after_quiescence_wait, handle_reload_spawn_failure, health_restart_child,
6862 lock_snapshot, on_child_exit, record_deliberate_severance, record_wait_error_terminal,
6863 reset_restart_count, spawn_and_mark_running, update_snapshot, wait_error_exit_report,
6864 ExitKind, ExitReport, ModuleProtocol, ModuleSpec, ModuleState, NextAction, ProcessIdentity,
6865 RestartPolicy, SpawnEventKind, SuperviseError, SupervisedModule, Supervisor,
6866 SupervisorHandle, SupervisorHealthStatus, SupervisorSnapshot,
6867 };
6868 use super::Instant as ClockInstant;
6873 use crate::{
6874 registry::Registry,
6875 terminal_ring::{TerminalRing, TerminalRingConfig},
6876 };
6877 use std::sync::Mutex;
6878 use subc_control::TerminalDisposition;
6879
6880 fn fake_aft_stub_path() -> PathBuf {
6885 let mut path = std::env::current_exe().expect("current_exe available in tests");
6886 path.pop();
6887 path.pop();
6888 path.push(if cfg!(windows) {
6889 "fake-aft-stub.exe"
6890 } else {
6891 "fake-aft-stub"
6892 });
6893 assert!(
6894 path.exists(),
6895 "fake-aft-stub not built at {}: run `cargo test -p subc-core` (which builds \
6896 [[bin]] targets) rather than `cargo test -p subc-core --lib` (which does not)",
6897 path.display()
6898 );
6899 path
6900 }
6901
6902 #[test]
6903 fn reserved_never_spawned_refuses_every_hello() {
6904 let supervisor = SupervisorHandle::default();
6909 supervisor.apply_identity_configuration(&ModuleSpec {
6910 module_id: "never-spawned".to_string(),
6911 program: PathBuf::from("/usr/bin/false"),
6912 args: Vec::new(),
6913 env: Vec::new(),
6914 reserved: true,
6915 reserved_prefixes: Vec::new(),
6916 protocol: ModuleProtocol::Subc,
6917 overlap: Default::default(),
6918 });
6919 assert!(
6920 supervisor
6921 .reserved_hello_rejection("never-spawned", Some("any-forged-nonce"))
6922 .is_some(),
6923 "forged nonce must refuse on a reserved never-spawned id"
6924 );
6925 assert!(
6926 supervisor
6927 .reserved_hello_rejection("never-spawned", None)
6928 .is_some(),
6929 "absent nonce must refuse on a reserved never-spawned id"
6930 );
6931 supervisor.set_spawn_nonce("never-spawned", "minted".to_string());
6933 supervisor.apply_identity_configuration(&ModuleSpec {
6934 module_id: "never-spawned".to_string(),
6935 program: PathBuf::from("/usr/bin/false"),
6936 args: Vec::new(),
6937 env: Vec::new(),
6938 reserved: true,
6939 reserved_prefixes: Vec::new(),
6940 protocol: ModuleProtocol::Subc,
6941 overlap: Default::default(),
6942 });
6943 assert!(supervisor
6944 .reserved_hello_rejection("never-spawned", Some("minted"))
6945 .is_none());
6946 assert!(supervisor
6947 .reserved_hello_rejection("never-spawned", Some("forged"))
6948 .is_some());
6949 }
6950
6951 fn seed_crash_restarts(state: &mut SupervisorSnapshot, count: u32) {
6954 let now = ClockInstant::now();
6955 for _ in 0..count {
6956 state.crash_restarts.push_back(now);
6957 }
6958 }
6959
6960 fn age_oldest_crash_restart_out_of_window(state: &mut SupervisorSnapshot, window: Duration) {
6964 let aged = state
6965 .crash_restarts
6966 .front()
6967 .expect("a crash restart must be recorded before it can be aged")
6968 .checked_sub(window + Duration::from_secs(1))
6969 .expect("the test clock is far enough from its origin to age an instant");
6970 state.crash_restarts[0] = aged;
6971 }
6972
6973 fn snapshot_with_restarts(enabled: bool, count: u32) -> SupervisorSnapshot {
6974 let mut state = SupervisorSnapshot::new(ModuleState::Running, enabled);
6975 seed_crash_restarts(&mut state, count);
6976 state
6977 }
6978
6979 #[test]
6980 fn daemon_owned_recovery_predicate_uses_the_pre_increment_budget() {
6981 let policy = RestartPolicy::new(3, Duration::ZERO);
6982 let now = ClockInstant::now();
6983 assert!(daemon_will_restart(
6984 &mut snapshot_with_restarts(true, 2),
6985 &policy,
6986 now
6987 ));
6988 assert!(!daemon_will_restart(
6989 &mut snapshot_with_restarts(true, 3),
6990 &policy,
6991 now
6992 ));
6993 assert!(!daemon_will_restart(
6994 &mut snapshot_with_restarts(false, 0),
6995 &policy,
6996 now
6997 ));
6998 }
6999
7000 #[test]
7001 fn crash_restart_backoff_escalates_with_in_window_count() {
7002 let policy = RestartPolicy::new(4, Duration::from_millis(100))
7003 .with_max_backoff(Duration::from_secs(30));
7004 let now = ClockInstant::now();
7005 let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
7006 let schedules = (0..4)
7007 .map(|_| {
7008 state
7009 .next_crash_restart(&policy, now)
7010 .expect("the test policy allows four crash restarts")
7011 })
7012 .collect::<Vec<_>>();
7013
7014 assert_eq!(
7015 schedules
7016 .iter()
7017 .map(|schedule| schedule.restart_in_window)
7018 .collect::<Vec<_>>(),
7019 vec![0, 1, 2, 3]
7020 );
7021 assert_eq!(
7022 schedules
7023 .iter()
7024 .map(|schedule| schedule.delay)
7025 .collect::<Vec<_>>(),
7026 vec![
7027 Duration::from_millis(100),
7028 Duration::from_secs(1),
7029 Duration::from_secs(10),
7030 Duration::from_secs(30),
7031 ]
7032 );
7033 }
7034
7035 #[test]
7036 fn crash_restart_backoff_resets_after_ring_clear() {
7037 let policy = RestartPolicy::new(3, Duration::from_millis(100));
7038 let now = ClockInstant::now();
7039 let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
7040 assert_eq!(
7041 state.next_crash_restart(&policy, now).unwrap().delay,
7042 Duration::from_millis(100)
7043 );
7044 assert_eq!(
7045 state.next_crash_restart(&policy, now).unwrap().delay,
7046 Duration::from_secs(1)
7047 );
7048
7049 state.clear_crash_restarts();
7050 let schedule = state
7051 .next_crash_restart(&policy, now)
7052 .expect("a cleared ring must allow another restart");
7053 assert_eq!(schedule.restart_in_window, 0);
7054 assert_eq!(schedule.delay, Duration::from_millis(100));
7055 }
7056
7057 #[test]
7058 fn crash_restart_backoff_ignores_aged_restarts() {
7059 let policy = RestartPolicy::new(3, Duration::from_millis(100));
7060 let now = ClockInstant::now();
7061 let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
7062 state
7063 .next_crash_restart(&policy, now)
7064 .expect("the first restart is allowed");
7065 state
7066 .next_crash_restart(&policy, now)
7067 .expect("the second restart is allowed");
7068 state.crash_restarts[0] = now
7069 .checked_sub(policy.window + Duration::from_secs(1))
7070 .expect("the fake clock can age a restart past the window");
7071
7072 let schedule = state
7073 .next_crash_restart(&policy, now)
7074 .expect("an aged restart must release its slot");
7075 assert_eq!(schedule.restart_in_window, 1);
7076 assert_eq!(schedule.delay, Duration::from_secs(1));
7077 assert_eq!(state.crash_restarts.len(), 2);
7078 }
7079
7080 #[test]
7084 fn a_budget_spent_before_the_window_no_longer_refuses() {
7085 let policy = RestartPolicy::new(3, Duration::ZERO);
7086 let mut state = snapshot_with_restarts(true, 3);
7087 let now = ClockInstant::now();
7088 assert!(!daemon_will_restart(&mut state, &policy, now));
7089
7090 assert!(daemon_will_restart(
7091 &mut state,
7092 &policy,
7093 now + policy.window + Duration::from_secs(1)
7094 ));
7095 assert!(
7096 state.crash_restarts.is_empty(),
7097 "reading the budget must drop the instants that left the window"
7098 );
7099 }
7100
7101 fn module_with_recovery_snapshot(
7102 state: ModuleState,
7103 enabled: bool,
7104 restart_count: u32,
7105 ) -> SupervisedModule {
7106 let registry = Arc::new(Registry::default());
7107 let supervisor =
7108 Supervisor::new(Arc::clone(®istry), RestartPolicy::new(3, Duration::ZERO));
7109 let module = supervisor
7110 .spawn(ModuleSpec {
7111 module_id: "recovery-snapshot".to_string(),
7112 program: fake_aft_stub_path(),
7113 args: Vec::new(),
7114 env: Vec::new(),
7115 reserved: false,
7116 reserved_prefixes: Vec::new(),
7117 protocol: ModuleProtocol::Subc,
7118 overlap: Default::default(),
7119 })
7120 .unwrap();
7121 update_snapshot(
7122 &module.inner.snapshot,
7123 Some("recovery-snapshot"),
7124 |snapshot| {
7125 snapshot.state = state;
7126 snapshot.enabled = enabled;
7127 seed_crash_restarts(snapshot, restart_count);
7128 },
7129 )
7130 .unwrap();
7131 module
7132 }
7133
7134 #[cfg(target_os = "linux")]
7135 #[tokio::test]
7136 async fn no_cgroup_placement_does_not_block_fake_aft_stub_spawn() {
7137 let supervisor = Supervisor::new(Arc::new(Registry::default()), RestartPolicy::default())
7138 .with_cgroup_placement(None);
7139 let result = supervisor.spawn(ModuleSpec {
7140 module_id: "no-cgroup-placement".to_string(),
7141 program: fake_aft_stub_path(),
7142 args: Vec::new(),
7143 env: Vec::new(),
7144 reserved: false,
7145 reserved_prefixes: Vec::new(),
7146 protocol: ModuleProtocol::Subc,
7147 overlap: Default::default(),
7148 });
7149
7150 assert!(
7151 result.is_ok(),
7152 "no delegation must not turn an otherwise valid spawn into a failure: {result:?}"
7153 );
7154 }
7155
7156 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
7157 async fn undecided_snapshot_uses_shared_restart_predicate() {
7158 assert!(module_with_recovery_snapshot(ModuleState::Running, true, 2)
7159 .will_recover_after_connection_loss()
7160 .unwrap());
7161 assert!(
7162 !module_with_recovery_snapshot(ModuleState::Running, true, 3)
7163 .will_recover_after_connection_loss()
7164 .unwrap()
7165 );
7166 }
7167
7168 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
7169 async fn restarting_snapshot_at_exhausted_budget_is_non_terminal() {
7170 assert!(
7171 module_with_recovery_snapshot(ModuleState::Restarting, true, 3)
7172 .will_recover_after_connection_loss()
7173 .unwrap()
7174 );
7175 }
7176
7177 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
7178 async fn terminal_phase_snapshots_are_terminal_before_budget_exhaustion() {
7179 assert!(!module_with_recovery_snapshot(ModuleState::Failed, true, 0)
7180 .will_recover_after_connection_loss()
7181 .unwrap());
7182 assert!(
7183 !module_with_recovery_snapshot(ModuleState::Disabled, true, 0)
7184 .will_recover_after_connection_loss()
7185 .unwrap()
7186 );
7187 }
7188
7189 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
7190 async fn warming_snapshot_is_limited_to_startup_phases() {
7191 for state in [
7192 ModuleState::Starting,
7193 ModuleState::Running,
7194 ModuleState::Restarting,
7195 ] {
7196 assert!(
7197 module_with_recovery_snapshot(state, true, 0)
7198 .is_warming()
7199 .unwrap(),
7200 "{state:?} should be warming"
7201 );
7202 }
7203 for state in [
7204 ModuleState::Unresponsive,
7205 ModuleState::Draining,
7206 ModuleState::Stopped,
7207 ModuleState::Failed,
7208 ModuleState::Disabled,
7209 ] {
7210 assert!(
7211 !module_with_recovery_snapshot(state, true, 0)
7212 .is_warming()
7213 .unwrap(),
7214 "{state:?} should not be warming"
7215 );
7216 }
7217 }
7218
7219 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
7220 async fn terminal_history_survives_respawn_and_keeps_both_crashes_in_order() {
7221 let registry = Arc::new(Registry::default());
7222 let supervisor =
7223 Supervisor::new(Arc::clone(®istry), RestartPolicy::new(1, Duration::ZERO));
7224 let module = supervisor
7225 .spawn(ModuleSpec {
7226 module_id: "terminal-history".to_string(),
7227 program: fake_aft_stub_path(),
7228 args: Vec::new(),
7229 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
7230 reserved: false,
7231 reserved_prefixes: Vec::new(),
7232 protocol: ModuleProtocol::Subc,
7233 overlap: Default::default(),
7234 })
7235 .unwrap();
7236
7237 let deadline = Instant::now() + Duration::from_secs(5);
7238 loop {
7239 let history = module.terminal_history();
7240 if history.entries.len() == 2 {
7241 assert_eq!(module.status().unwrap().state, ModuleState::Failed);
7242 assert_eq!(history.dropped, 0);
7243 assert_eq!(
7244 history
7245 .entries
7246 .iter()
7247 .map(|entry| entry.exit_code)
7248 .collect::<Vec<_>>(),
7249 vec![Some(23), Some(23)]
7250 );
7251 assert!(history.entries[0].at_ms <= history.entries[1].at_ms);
7252 return;
7253 }
7254 assert!(
7255 Instant::now() < deadline,
7256 "module did not retain two terminal exits: {history:?}"
7257 );
7258 sleep(Duration::from_millis(10)).await;
7259 }
7260 }
7261
7262 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
7266 async fn disable_during_crash_backoff_cancels_pending_respawn() {
7267 let backoff = Duration::from_secs(2);
7268 let supervisor = Supervisor::new(
7269 Arc::new(Registry::default()),
7270 RestartPolicy::new(10, backoff),
7271 );
7272 let module = supervisor
7273 .spawn(ModuleSpec {
7274 module_id: "disable-during-backoff".to_string(),
7275 program: fake_aft_stub_path(),
7276 args: Vec::new(),
7277 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
7278 reserved: false,
7279 reserved_prefixes: Vec::new(),
7280 protocol: ModuleProtocol::Subc,
7281 overlap: Default::default(),
7282 })
7283 .unwrap();
7284
7285 let deadline = Instant::now() + Duration::from_secs(5);
7287 loop {
7288 if module.status().unwrap().state == ModuleState::Restarting {
7289 break;
7290 }
7291 assert!(
7292 Instant::now() < deadline,
7293 "module never entered the crash backoff"
7294 );
7295 sleep(Duration::from_millis(10)).await;
7296 }
7297
7298 let started = Instant::now();
7299 module.set_enabled(false).await.unwrap();
7300 let waited = started.elapsed();
7301
7302 assert!(
7303 waited < backoff / 2,
7304 "disable waited {waited:?} behind the {backoff:?} crash backoff; the operator command must preempt the pending respawn"
7305 );
7306 assert_eq!(module.status().unwrap().state, ModuleState::Disabled);
7307
7308 sleep(backoff + Duration::from_millis(500)).await;
7310 let status = module.status().unwrap();
7311 assert_eq!(status.state, ModuleState::Disabled);
7312 assert_eq!(
7313 status.spawn_generation, 1,
7314 "module respawned after the operator disabled it"
7315 );
7316 }
7317
7318 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
7322 async fn every_restart_increment_path_advances_lifetime_count() {
7323 let supervisor = Supervisor::new(
7324 Arc::new(Registry::default()),
7325 RestartPolicy::new(1, Duration::ZERO),
7326 );
7327 let runtime = supervisor.runtime_config();
7328 let spec = ModuleSpec {
7329 module_id: "lifetime-increment-path".to_string(),
7330 program: PathBuf::from("/unused/lifetime-increment-path"),
7331 args: Vec::new(),
7332 env: Vec::new(),
7333 reserved: false,
7334 reserved_prefixes: Vec::new(),
7335 protocol: ModuleProtocol::Subc,
7336 overlap: Default::default(),
7337 };
7338
7339 let crash_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
7340 assert!(matches!(
7341 on_child_exit(
7342 &spec,
7343 runtime.restart_policy,
7344 &supervisor.registry,
7345 &crash_snapshot,
7346 &runtime.terminal_ring,
7347 &runtime.spawn_events,
7348 ExitReport {
7349 kind: ExitKind::Crash,
7350 code: Some(1),
7351 signal: None,
7352 at_ms: 1,
7353 },
7354 )
7355 .await,
7356 NextAction::Restart { schedule: _ }
7357 ));
7358 let (crash_restarts, crash_lifetime) = {
7359 let state = lock_snapshot(&crash_snapshot).unwrap();
7360 (state.crash_restarts.len(), state.lifetime_restarts)
7361 };
7362 assert_eq!(crash_restarts, 1);
7363 assert_eq!(crash_lifetime, 1);
7364
7365 let health_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
7366 let mut health_child = None;
7367 assert!(matches!(
7368 health_restart_child(
7369 &spec,
7370 &runtime,
7371 &supervisor.registry,
7372 &supervisor.process_liveness,
7373 &health_snapshot,
7374 &mut health_child,
7375 SupervisorHealthStatus::Failing,
7376 None,
7377 2,
7378 )
7379 .await,
7380 Err(SuperviseError::Spawn { .. })
7381 ));
7382 let (health_restarts, health_lifetime) = {
7383 let state = lock_snapshot(&health_snapshot).unwrap();
7384 (state.crash_restarts.len(), state.lifetime_restarts)
7385 };
7386 assert_eq!(health_restarts, 1);
7387 assert_eq!(health_lifetime, 1);
7388
7389 let reload_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
7390 let mut reload_child = None;
7391 assert!(matches!(
7392 handle_reload_spawn_failure(
7393 &spec,
7394 &runtime,
7395 &supervisor.process_liveness,
7396 &reload_snapshot,
7397 &mut reload_child,
7398 "forced reload spawn failure".to_string(),
7399 )
7400 .await,
7401 Err(SuperviseError::ReloadFailed { .. })
7402 ));
7403 let (reload_restarts, reload_lifetime) = {
7404 let state = lock_snapshot(&reload_snapshot).unwrap();
7405 (state.crash_restarts.len(), state.lifetime_restarts)
7406 };
7407 assert_eq!(reload_restarts, 1);
7408 assert_eq!(reload_lifetime, 1);
7409 }
7410
7411 #[tokio::test]
7412 async fn deliberately_severed_live_child_records_lifetime_without_spending_restart_budget() {
7413 let supervisor = Supervisor::new(
7414 Arc::new(Registry::default()),
7415 RestartPolicy::new(3, Duration::ZERO),
7416 );
7417 let runtime = supervisor.runtime_config();
7418 let spec = ModuleSpec {
7419 module_id: "deliberately-severed".to_string(),
7420 program: PathBuf::from("/unused/deliberately-severed"),
7421 args: Vec::new(),
7422 env: Vec::new(),
7423 reserved: false,
7424 reserved_prefixes: Vec::new(),
7425 protocol: ModuleProtocol::Subc,
7426 overlap: Default::default(),
7427 };
7428 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
7429 let process = ProcessIdentity {
7430 pid: 41,
7431 start_time: 101,
7432 };
7433 record_deliberate_severance(&snapshot, process).unwrap();
7434 let exit_report = apply_deliberate_severance_marker(
7435 &snapshot,
7436 Some(process),
7437 ExitReport {
7438 kind: ExitKind::Crash,
7439 code: Some(1),
7440 signal: None,
7441 at_ms: 1,
7442 },
7443 );
7444 assert_eq!(exit_report.kind, ExitKind::DeliberateSeverance);
7445
7446 assert!(matches!(
7447 on_child_exit(
7448 &spec,
7449 runtime.restart_policy,
7450 &supervisor.registry,
7451 &snapshot,
7452 &runtime.terminal_ring,
7453 &runtime.spawn_events,
7454 exit_report,
7455 )
7456 .await,
7457 NextAction::Restart { schedule: _ }
7458 ));
7459 let state = lock_snapshot(&snapshot).unwrap();
7460 assert_eq!(state.lifetime_restarts, 1);
7461 assert_eq!(state.crash_restarts.len(), 0);
7462 }
7463
7464 #[tokio::test]
7465 async fn genuine_crash_spends_restart_budget_and_records_lifetime() {
7466 let supervisor = Supervisor::new(
7467 Arc::new(Registry::default()),
7468 RestartPolicy::new(3, Duration::ZERO),
7469 );
7470 let runtime = supervisor.runtime_config();
7471 let spec = ModuleSpec {
7472 module_id: "genuine-crash".to_string(),
7473 program: PathBuf::from("/unused/genuine-crash"),
7474 args: Vec::new(),
7475 env: Vec::new(),
7476 reserved: false,
7477 reserved_prefixes: Vec::new(),
7478 protocol: ModuleProtocol::Subc,
7479 overlap: Default::default(),
7480 };
7481 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
7482
7483 assert!(matches!(
7484 on_child_exit(
7485 &spec,
7486 runtime.restart_policy,
7487 &supervisor.registry,
7488 &snapshot,
7489 &runtime.terminal_ring,
7490 &runtime.spawn_events,
7491 ExitReport {
7492 kind: ExitKind::Crash,
7493 code: Some(1),
7494 signal: None,
7495 at_ms: 1,
7496 },
7497 )
7498 .await,
7499 NextAction::Restart { schedule: _ }
7500 ));
7501 let state = lock_snapshot(&snapshot).unwrap();
7502 assert_eq!(state.lifetime_restarts, 1);
7503 assert_eq!(state.crash_restarts.len(), 1);
7504 }
7505
7506 fn crash_exit_report(at_ms: u64) -> ExitReport {
7507 ExitReport {
7508 kind: ExitKind::Crash,
7509 code: Some(1),
7510 signal: None,
7511 at_ms,
7512 }
7513 }
7514
7515 fn windowed_crash_spec(module_id: &str) -> ModuleSpec {
7516 ModuleSpec {
7517 module_id: module_id.to_string(),
7518 program: PathBuf::from("/unused").join(module_id),
7519 args: Vec::new(),
7520 env: Vec::new(),
7521 reserved: false,
7522 reserved_prefixes: Vec::new(),
7523 protocol: ModuleProtocol::Subc,
7524 overlap: Default::default(),
7525 }
7526 }
7527
7528 #[tokio::test]
7534 async fn three_crashes_inside_the_window_stop_the_module_and_name_the_window() {
7535 let (logs, _guard) = crate::router::test_log::log_capture(tracing::Level::ERROR);
7536 let supervisor = Supervisor::new(
7537 Arc::new(Registry::default()),
7538 RestartPolicy::new(2, Duration::ZERO),
7539 );
7540 let runtime = supervisor.runtime_config();
7541 let spec = windowed_crash_spec("crash-loop-in-window");
7542 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
7543
7544 for attempt in 1..=2 {
7545 assert!(
7546 matches!(
7547 on_child_exit(
7548 &spec,
7549 runtime.restart_policy,
7550 &supervisor.registry,
7551 &snapshot,
7552 &runtime.terminal_ring,
7553 &runtime.spawn_events,
7554 crash_exit_report(attempt),
7555 )
7556 .await,
7557 NextAction::Restart { schedule: _ }
7558 ),
7559 "crash {attempt} is inside the budget and must respawn"
7560 );
7561 }
7562
7563 assert!(matches!(
7564 on_child_exit(
7565 &spec,
7566 runtime.restart_policy,
7567 &supervisor.registry,
7568 &snapshot,
7569 &runtime.terminal_ring,
7570 &runtime.spawn_events,
7571 crash_exit_report(3),
7572 )
7573 .await,
7574 NextAction::Stop { .. }
7575 ));
7576
7577 {
7578 let state = lock_snapshot(&snapshot).unwrap();
7579 assert_eq!(state.state, ModuleState::Failed);
7580 assert_eq!(state.crash_restarts.len(), 2);
7581 assert_eq!(state.lifetime_restarts, 2);
7582 }
7583
7584 let history = runtime
7585 .terminal_ring
7586 .lock()
7587 .expect("terminal ring is not poisoned")
7588 .snapshot();
7589 let last = history
7590 .entries
7591 .last()
7592 .expect("the refused crash is retained");
7593 assert_eq!(last.disposition, TerminalDisposition::Failed);
7594 assert_eq!(
7595 last.disposition_detail.as_deref(),
7596 Some("crash budget exhausted: max_restarts=2 within window_secs=600")
7597 );
7598
7599 let captured = crate::router::test_log::captured_logs(&logs);
7600 assert!(
7601 captured.contains("crash budget exhausted: max_restarts=2 within window_secs=600"),
7602 "the stop must be logged with its window: {captured}"
7603 );
7604 }
7605
7606 #[tokio::test]
7614 async fn a_crash_older_than_the_window_frees_its_slot_for_a_later_crash() {
7615 let supervisor = Supervisor::new(
7616 Arc::new(Registry::default()),
7617 RestartPolicy::new(2, Duration::ZERO),
7618 );
7619 let runtime = supervisor.runtime_config();
7620 let spec = windowed_crash_spec("crash-across-windows");
7621 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
7622
7623 for attempt in 1..=2 {
7624 assert!(matches!(
7625 on_child_exit(
7626 &spec,
7627 runtime.restart_policy,
7628 &supervisor.registry,
7629 &snapshot,
7630 &runtime.terminal_ring,
7631 &runtime.spawn_events,
7632 crash_exit_report(attempt),
7633 )
7634 .await,
7635 NextAction::Restart { schedule: _ }
7636 ));
7637 }
7638
7639 update_snapshot(&snapshot, Some(&spec.module_id), |state| {
7642 age_oldest_crash_restart_out_of_window(state, runtime.restart_policy.window);
7643 })
7644 .unwrap();
7645
7646 assert!(
7647 matches!(
7648 on_child_exit(
7649 &spec,
7650 runtime.restart_policy,
7651 &supervisor.registry,
7652 &snapshot,
7653 &runtime.terminal_ring,
7654 &runtime.spawn_events,
7655 crash_exit_report(3),
7656 )
7657 .await,
7658 NextAction::Restart { schedule: _ }
7659 ),
7660 "a crash older than the window must not hold a budget slot"
7661 );
7662
7663 let state = lock_snapshot(&snapshot).unwrap();
7664 assert_eq!(state.state, ModuleState::Restarting);
7665 assert_eq!(
7666 state.crash_restarts.len(),
7667 2,
7668 "the aged instant is dropped and the new one takes its place"
7669 );
7670 assert_eq!(
7671 state.lifetime_restarts, 3,
7672 "the ledger counts every restart, including the ones the window forgot"
7673 );
7674 }
7675
7676 #[tokio::test]
7681 async fn an_operator_restart_clears_the_ring_and_leaves_the_ledger_alone() {
7682 let supervisor = Supervisor::new(
7683 Arc::new(Registry::default()),
7684 RestartPolicy::new(2, Duration::ZERO),
7685 );
7686 let runtime = supervisor.runtime_config();
7687 let spec = windowed_crash_spec("operator-cleared-budget");
7688 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
7689
7690 for attempt in 1..=2 {
7691 assert!(matches!(
7692 on_child_exit(
7693 &spec,
7694 runtime.restart_policy,
7695 &supervisor.registry,
7696 &snapshot,
7697 &runtime.terminal_ring,
7698 &runtime.spawn_events,
7699 crash_exit_report(attempt),
7700 )
7701 .await,
7702 NextAction::Restart { schedule: _ }
7703 ));
7704 }
7705
7706 reset_restart_count(&snapshot, &spec.module_id).unwrap();
7707 {
7708 let state = lock_snapshot(&snapshot).unwrap();
7709 assert!(
7710 state.crash_restarts.is_empty(),
7711 "an operator restart returns the full budget"
7712 );
7713 assert_eq!(
7714 state.lifetime_restarts, 2,
7715 "clearing the budget must not unmake the crashes"
7716 );
7717 }
7718
7719 assert!(
7720 matches!(
7721 on_child_exit(
7722 &spec,
7723 runtime.restart_policy,
7724 &supervisor.registry,
7725 &snapshot,
7726 &runtime.terminal_ring,
7727 &runtime.spawn_events,
7728 crash_exit_report(3),
7729 )
7730 .await,
7731 NextAction::Restart { schedule: _ }
7732 ),
7733 "the cleared budget must be spendable again"
7734 );
7735 let state = lock_snapshot(&snapshot).unwrap();
7736 assert_eq!(state.crash_restarts.len(), 1);
7737 assert_eq!(state.lifetime_restarts, 3);
7738 }
7739
7740 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
7741 async fn severance_marker_for_a_dead_child_does_not_label_its_successor() {
7742 let severed = ProcessIdentity {
7743 pid: 41,
7744 start_time: 101,
7745 };
7746 let successor = ProcessIdentity {
7747 pid: 41,
7748 start_time: 202,
7749 };
7750 let module = module_with_recovery_snapshot(ModuleState::Running, true, 0);
7751 update_snapshot(&module.inner.snapshot, Some("recovery-snapshot"), |state| {
7752 state.pid = Some(successor.pid);
7753 state.process_start_time = Some(successor.start_time);
7754 })
7755 .unwrap();
7756 assert!(!module.record_deliberate_severance(severed).unwrap());
7757
7758 let exit_report = apply_deliberate_severance_marker(
7759 &module.inner.snapshot,
7760 Some(successor),
7761 ExitReport {
7762 kind: ExitKind::Crash,
7763 code: Some(1),
7764 signal: None,
7765 at_ms: 1,
7766 },
7767 );
7768
7769 assert_eq!(exit_report.kind, ExitKind::Crash);
7770 }
7771
7772 #[tokio::test]
7773 async fn drain_reap_marks_deliberate_severance_and_records_lifetime_without_budget() {
7774 let registry = Registry::default();
7775 let supervisor = Supervisor::new(
7776 Arc::new(Registry::default()),
7777 RestartPolicy::new(3, Duration::ZERO),
7778 );
7779 let runtime = supervisor.runtime_config();
7780 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
7781 let spec = ModuleSpec {
7782 module_id: "drain-deliberate-severance".to_string(),
7783 program: fake_aft_stub_path(),
7784 args: Vec::new(),
7785 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
7786 reserved: false,
7787 reserved_prefixes: Vec::new(),
7788 protocol: ModuleProtocol::Subc,
7789 overlap: Default::default(),
7790 };
7791 let mut child = spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap();
7792 let process = ProcessIdentity {
7793 pid: 41,
7794 start_time: 101,
7795 };
7796 child.process_identity = Some(process);
7797 update_snapshot(&snapshot, Some(&spec.module_id), |state| {
7798 state.pid = Some(process.pid);
7799 state.process_start_time = Some(process.start_time);
7800 })
7801 .unwrap();
7802 record_deliberate_severance(&snapshot, process).unwrap();
7803
7804 drain_child_to_state(
7805 &spec.module_id,
7806 spec.protocol,
7807 ®istry,
7808 &snapshot,
7809 &runtime.terminal_ring,
7810 &runtime.spawn_events,
7811 child,
7812 Duration::from_secs(1),
7813 ModuleState::Stopped,
7814 Some(false),
7815 )
7816 .await
7817 .unwrap();
7818
7819 let state = lock_snapshot(&snapshot).unwrap();
7820 assert_eq!(
7821 state.last_exit.as_ref().map(|exit| exit.kind),
7822 Some(ExitKind::DeliberateSeverance)
7823 );
7824 assert_eq!(state.lifetime_restarts, 1);
7825 assert_eq!(state.crash_restarts.len(), 0);
7826 drop(state);
7827 let history = runtime.terminal_ring.lock().unwrap().snapshot();
7828 assert_eq!(
7829 history.entries[0].exit_kind,
7830 subc_control::TerminalExitKind::DeliberateSeverance
7831 );
7832 }
7833
7834 #[tokio::test]
7835 async fn ordinary_drain_reap_does_not_record_a_lifetime_restart() {
7836 let registry = Registry::default();
7837 let supervisor = Supervisor::new(
7838 Arc::new(Registry::default()),
7839 RestartPolicy::new(3, Duration::ZERO),
7840 );
7841 let runtime = supervisor.runtime_config();
7842 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
7843 let spec = ModuleSpec {
7844 module_id: "ordinary-drain".to_string(),
7845 program: fake_aft_stub_path(),
7846 args: Vec::new(),
7847 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
7848 reserved: false,
7849 reserved_prefixes: Vec::new(),
7850 protocol: ModuleProtocol::Subc,
7851 overlap: Default::default(),
7852 };
7853 let child = spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap();
7854
7855 drain_child_to_state(
7856 &spec.module_id,
7857 spec.protocol,
7858 ®istry,
7859 &snapshot,
7860 &runtime.terminal_ring,
7861 &runtime.spawn_events,
7862 child,
7863 Duration::from_secs(1),
7864 ModuleState::Stopped,
7865 Some(false),
7866 )
7867 .await
7868 .unwrap();
7869
7870 let state = lock_snapshot(&snapshot).unwrap();
7871 assert_eq!(
7872 state.last_exit.as_ref().map(|exit| exit.kind),
7873 Some(ExitKind::Crash)
7874 );
7875 assert_eq!(state.lifetime_restarts, 0);
7876 assert_eq!(state.crash_restarts.len(), 0);
7877 }
7878
7879 #[test]
7880 fn fatal_connection_teardown_cannot_arm_a_marker_for_a_surviving_process() {
7881 assert!(!include_str!("server.rs")
7887 .contains("router.record_deliberate_connection_severance(ctx.connection_id)"));
7888 }
7889
7890 #[test]
7897 fn drained_after_quiescence_wait_passes_ok_through_and_forces_false_on_err() {
7898 assert!(drained_after_quiescence_wait(&Ok(true)));
7899 assert!(!drained_after_quiescence_wait(&Ok(false)));
7900 assert!(!drained_after_quiescence_wait(&Err(
7901 SuperviseError::StatePoisoned { module_id: None }
7902 )));
7903 }
7904
7905 #[test]
7914 fn wait_error_exit_report_records_a_failed_terminal_with_no_code_or_signal() {
7915 let ring = Arc::new(Mutex::new(TerminalRing::new(
7916 TerminalRingConfig::default(),
7917 0,
7918 )));
7919 record_wait_error_terminal("wait-error", &ring, &super::SpawnEventFeed::default());
7920
7921 let snapshot = ring.lock().unwrap().snapshot();
7922 assert_eq!(snapshot.entries.len(), 1);
7923 let entry = &snapshot.entries[0];
7924 assert_eq!(entry.exit_code, None);
7925 assert_eq!(entry.exit_signal, None);
7926 assert_eq!(entry.disposition, TerminalDisposition::Failed);
7927 }
7928
7929 #[test]
7930 fn wait_error_exit_path_preserves_spawn_event_density() {
7931 let feed = super::SpawnEventFeed::default();
7932 feed.configure_incarnation("wait-error-density".to_string());
7933 feed.emit_spawned("wait-error", 41, 1);
7934 let ring = Arc::new(Mutex::new(TerminalRing::new(
7935 TerminalRingConfig::default(),
7936 0,
7937 )));
7938
7939 record_wait_error_terminal("wait-error", &ring, &feed);
7940 feed.emit_spawned("after-wait-error", 42, 2);
7941
7942 let state = feed.0.lock().unwrap();
7943 let sequences = state
7944 .events
7945 .iter()
7946 .map(|event| event.cursor.seq)
7947 .collect::<Vec<_>>();
7948 assert_eq!(sequences, vec![1, 2, 3]);
7949 assert_eq!(state.events[1].kind, SpawnEventKind::Exited);
7950 assert_eq!(state.events[1].exit_code, None);
7951 assert_eq!(state.events[1].exit_signal, None);
7952 }
7953
7954 #[test]
7958 fn wait_error_exit_report_is_classified_as_a_crash() {
7959 assert_eq!(wait_error_exit_report().kind, ExitKind::Crash);
7960 }
7961}
7962
7963#[cfg(test)]
7964mod health_evidence_tests {
7965 use super::{HealthProbeError, HealthProbeEvidence};
7966 use std::collections::HashSet;
7967
7968 #[test]
7976 fn only_a_dead_lane_is_proof_of_death() {
7977 assert!(HealthProbeError::lane_dead("gone").is_proof_of_death());
7978 assert!(!HealthProbeError::no_answer("timed out").is_proof_of_death());
7982 assert!(!HealthProbeError::bad_answer("garbage").is_proof_of_death());
7983 assert!(!HealthProbeError::misconfigured("no table").is_proof_of_death());
7984 }
7985
7986 #[test]
7992 fn every_evidence_class_has_a_distinct_label() {
7993 let labels = [
7994 HealthProbeError::lane_dead("").label(),
7995 HealthProbeError::no_answer("").label(),
7996 HealthProbeError::bad_answer("").label(),
7997 HealthProbeError::misconfigured("").label(),
7998 ];
7999 let unique: HashSet<_> = labels.iter().collect();
8000 assert_eq!(unique.len(), labels.len(), "labels collided: {labels:?}");
8001 }
8002
8003 #[test]
8009 fn classification_preserves_the_original_message() {
8010 let err = HealthProbeError::no_answer("module did not answer within 5s");
8011 assert_eq!(err.to_string(), "module did not answer within 5s");
8012 assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
8013 }
8014}
8015
8016#[cfg(test)]
8017mod health_tombstone_tests {
8018 use std::{path::PathBuf, sync::Arc, time::Duration};
8019
8020 use subc_protocol::{
8021 manifest::Concurrency,
8022 session::{HealthStatus, ModuleControlResponse},
8023 };
8024 use tokio::sync::mpsc;
8025
8026 use super::{
8027 probe_module_health, HealthAction, HealthConfig, HealthProbeEvidence, ModuleProtocol,
8028 ModuleSpec, RestartPolicy, Supervisor, SupervisorRuntimeConfig,
8029 };
8030 use crate::{
8031 control::ControlHandler,
8032 forwarding::{ForwardingTable, ModuleControlRpcCompletion, ModuleControlRpcOutcome},
8033 registry::{ConnectionId, Registry},
8034 router::FrameSink,
8035 };
8036
8037 struct ProbeHarness {
8038 spec: ModuleSpec,
8039 runtime: SupervisorRuntimeConfig,
8040 forwarding: Arc<ForwardingTable>,
8041 module_connection: ConnectionId,
8042 module_rx: mpsc::Receiver<crate::router::OutboundFrame>,
8043 handler: ControlHandler,
8044 module: super::SupervisedModule,
8045 }
8046
8047 fn probe_harness() -> ProbeHarness {
8048 let registry = Arc::new(Registry::default());
8049 let forwarding = Arc::new(ForwardingTable::default());
8050 let supervisor_handle = super::SupervisorHandle::new();
8051 let health = HealthConfig {
8052 cadence: Duration::from_secs(30),
8053 deadline: Duration::from_secs(5),
8054 failure_threshold: 3,
8055 on_degraded: HealthAction::Report,
8056 on_failing: HealthAction::Report,
8057 critical: false,
8058 };
8059 let supervisor = Supervisor::new(Arc::clone(®istry), RestartPolicy::default())
8060 .with_forwarding(Arc::clone(&forwarding))
8061 .with_handle(supervisor_handle.clone())
8062 .with_health_config(health);
8063 let spec = ModuleSpec {
8064 module_id: "late-health-module".to_string(),
8065 program: PathBuf::from("disabled-module"),
8066 args: Vec::new(),
8067 env: Vec::new(),
8068 reserved: false,
8069 reserved_prefixes: Vec::new(),
8070 protocol: ModuleProtocol::Subc,
8071 overlap: Default::default(),
8072 };
8073 let module = supervisor
8074 .supervise_configured(spec.clone(), false)
8075 .unwrap();
8076 let runtime = supervisor.runtime_config();
8077 let handler = ControlHandler::with_forwarding(registry, Arc::clone(&forwarding))
8078 .with_supervisor(supervisor_handle);
8079 let module_connection = ConnectionId::new(700);
8080 let (module_tx, module_rx) = mpsc::channel(8);
8081 forwarding
8082 .register_module_connection(
8083 module_connection,
8084 spec.module_id.clone(),
8085 subc_protocol::PROTOCOL_VERSION,
8086 Concurrency::ModuleManaged,
8087 FrameSink::new(module_tx),
8088 )
8089 .unwrap();
8090
8091 ProbeHarness {
8092 spec,
8093 runtime,
8094 forwarding,
8095 module_connection,
8096 module_rx,
8097 handler,
8098 module,
8099 }
8100 }
8101
8102 async fn finish_after(
8103 harness: &mut ProbeHarness,
8104 stall: Duration,
8105 ) -> ModuleControlRpcCompletion {
8106 assert!(stall > harness.runtime.health.deadline);
8107 let deadline = harness.runtime.health.deadline;
8108 let probe = probe_module_health(&harness.spec.module_id, &harness.runtime, None);
8109 let answer = async {
8110 let frame = harness.module_rx.recv().await.expect("health.check frame");
8111 tokio::time::advance(deadline).await;
8112 tokio::task::yield_now().await;
8113 tokio::time::advance(stall - deadline).await;
8114 harness
8115 .forwarding
8116 .complete_module_control_rpc(
8117 harness.module_connection,
8118 frame.header.corr,
8119 Some("health.check"),
8120 ModuleControlRpcOutcome::Response(ModuleControlResponse::HealthCheck {
8121 status: HealthStatus::Ok,
8122 detail: None,
8123 metrics: None,
8124 }),
8125 )
8126 .unwrap()
8127 };
8128 let (probe_result, completion) = tokio::join!(probe, answer);
8129 let err = probe_result.expect_err("probe must miss its deadline");
8130 assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
8131 completion
8132 }
8133
8134 async fn time_out_without_answer(harness: &mut ProbeHarness) {
8135 let deadline = harness.runtime.health.deadline;
8136 let probe = probe_module_health(&harness.spec.module_id, &harness.runtime, None);
8137 let exhaust_deadline = async {
8138 let _frame = harness.module_rx.recv().await.expect("health.check frame");
8139 tokio::time::advance(deadline).await;
8140 tokio::task::yield_now().await;
8141 };
8142 let (probe_result, ()) = tokio::join!(probe, exhaust_deadline);
8143 let err = probe_result.expect_err("probe must miss its deadline");
8144 assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
8145 }
8146
8147 #[tokio::test(start_paused = true)]
8148 async fn late_health_answers_record_start_anchored_latency_for_two_stalls() {
8149 let mut harness = probe_harness();
8150
8151 let first = finish_after(&mut harness, Duration::from_secs(8)).await;
8152 let first_latency = match &first {
8153 ModuleControlRpcCompletion::LateHealthAnswer { latency, .. } => *latency,
8154 other => panic!("late answer was not retained: {other:?}"),
8155 };
8156 assert!(harness.handler.observe_module_control_completion(first));
8157
8158 let second = finish_after(&mut harness, Duration::from_secs(11)).await;
8159 let second_latency = match &second {
8160 ModuleControlRpcCompletion::LateHealthAnswer { latency, .. } => *latency,
8161 other => panic!("late answer was not retained: {other:?}"),
8162 };
8163 assert!(harness.handler.observe_module_control_completion(second));
8164
8165 assert_eq!(first_latency, Duration::from_secs(8));
8166 assert_eq!(
8167 second_latency - first_latency,
8168 Duration::from_secs(3),
8169 "latency must grow linearly with the additional stall"
8170 );
8171 let health = harness.module.status().unwrap().health;
8172 assert_eq!(health.late_answer_count, 2);
8173 assert_eq!(health.last_late_answer_latency_ms, Some(11_000));
8174 }
8175
8176 #[tokio::test(start_paused = true)]
8184 async fn late_answer_clears_the_consecutive_failure_streak() {
8185 let mut harness = probe_harness();
8186
8187 time_out_without_answer(&mut harness).await;
8189 harness
8190 .module
8191 .record_health_probe_failure_for_test("[no-answer] test miss")
8192 .unwrap();
8193 assert_eq!(
8194 harness.module.status().unwrap().health.consecutive_failures,
8195 1,
8196 "precondition: the miss must be on the streak before the late answer"
8197 );
8198
8199 let late = finish_after(&mut harness, Duration::from_secs(9)).await;
8201 assert!(matches!(
8202 late,
8203 ModuleControlRpcCompletion::LateHealthAnswer { .. }
8204 ));
8205 assert!(harness.handler.observe_module_control_completion(late));
8206
8207 let health = harness.module.status().unwrap().health;
8208 assert_eq!(
8209 health.consecutive_failures, 0,
8210 "a late answer is an answer: the streak must reset"
8211 );
8212 assert_eq!(health.late_answer_count, 1);
8213 }
8214
8215 #[tokio::test(start_paused = true)]
8216 async fn repeated_serial_probe_cycles_keep_one_tombstone_per_endpoint() {
8217 let mut harness = probe_harness();
8218
8219 for _ in 0..20 {
8220 time_out_without_answer(&mut harness).await;
8221 assert_eq!(
8222 harness.forwarding.health_probe_tombstone_count().unwrap(),
8223 1
8224 );
8225 }
8226 }
8227}
8228
8229#[cfg(test)]
8230mod child_env_tests {
8231 use super::{
8232 apply_child_env, apply_spawn_role, apply_wire_spawn_args, ModuleProtocol, ModuleSpec,
8233 SpawnRole, SupervisorHandle, SPAWN_ROLE_SWAP_CANDIDATE, SUBC_ARG, SUBC_LAUNCH_NONCE_ENV,
8234 SUBC_MODULE_ID_ENV, SUBC_SPAWN_ROLE_ENV,
8235 };
8236 use std::{ffi::OsStr, path::PathBuf};
8237 use tokio::process::Command;
8238
8239 fn spec(env: Vec<(String, String)>) -> ModuleSpec {
8240 ModuleSpec {
8241 module_id: "env-plan".to_string(),
8242 program: PathBuf::from("/nonexistent"),
8243 args: Vec::new(),
8244 env,
8245 reserved: false,
8246 reserved_prefixes: Vec::new(),
8247 protocol: ModuleProtocol::Subc,
8248 overlap: Default::default(),
8249 }
8250 }
8251
8252 #[test]
8266 fn ambient_ck_log_is_removed_and_a_configured_one_survives() {
8267 let mut command = Command::new("/nonexistent");
8268 apply_child_env(&mut command, &spec(Vec::new()));
8269 let removed = command
8270 .as_std()
8271 .get_envs()
8272 .any(|(key, value)| key == OsStr::new("CK_LOG") && value.is_none());
8273 assert!(
8274 removed,
8275 "ambient CK_LOG must be explicitly removed for an unconfigured module"
8276 );
8277
8278 let mut configured = Command::new("/nonexistent");
8279 apply_child_env(
8280 &mut configured,
8281 &spec(vec![("CK_LOG".to_string(), "debug".to_string())]),
8282 );
8283 let effective = configured
8284 .as_std()
8285 .get_envs()
8286 .filter(|(key, _)| *key == OsStr::new("CK_LOG"))
8287 .last()
8288 .map(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()));
8289 assert_eq!(
8290 effective,
8291 Some(Some("debug".to_string())),
8292 "a module's configured CK_LOG must survive the ambient removal"
8293 );
8294 }
8295
8296 #[test]
8305 fn protocol_none_spawn_carries_no_subc_argument_and_no_nonce() {
8306 let connection_file = std::path::Path::new("/run/subc-connection.json");
8307 let handle = SupervisorHandle::new();
8308
8309 let mut none_spec = spec(Vec::new());
8310 none_spec.protocol = ModuleProtocol::None;
8311 let mut none = Command::new("/nonexistent");
8312 apply_wire_spawn_args(&mut none, &none_spec, Some(connection_file), Some(&handle))
8313 .expect("protocol-none spawn args apply");
8314 let none_args: Vec<String> = none
8315 .as_std()
8316 .get_args()
8317 .map(|a| a.to_string_lossy().into_owned())
8318 .collect();
8319 assert!(
8320 !none_args.iter().any(|a| a == SUBC_ARG),
8321 "protocol:none argv must not carry --subc; got {none_args:?}"
8322 );
8323 let none_has_nonce = none
8324 .as_std()
8325 .get_envs()
8326 .any(|(key, value)| key == OsStr::new(SUBC_LAUNCH_NONCE_ENV) && value.is_some());
8327 assert!(
8328 !none_has_nonce,
8329 "protocol:none spawn must not receive a launch nonce"
8330 );
8331 let none_has_module_id = none
8332 .as_std()
8333 .get_envs()
8334 .any(|(key, value)| key == OsStr::new(SUBC_MODULE_ID_ENV) && value.is_some());
8335 assert!(
8336 none_has_module_id,
8337 "SUBC_MODULE_ID is inert and stays on every path"
8338 );
8339 assert!(
8340 handle.spawn_nonce(&none_spec.module_id).is_none(),
8341 "no nonce record for a process that will never present one"
8342 );
8343
8344 let wire_spec = spec(Vec::new());
8346 let mut wire = Command::new("/nonexistent");
8347 apply_wire_spawn_args(&mut wire, &wire_spec, Some(connection_file), Some(&handle))
8348 .expect("subc-wire spawn args apply");
8349 let wire_args: Vec<String> = wire
8350 .as_std()
8351 .get_args()
8352 .map(|a| a.to_string_lossy().into_owned())
8353 .collect();
8354 assert_eq!(
8355 wire_args,
8356 vec![
8357 SUBC_ARG.to_string(),
8358 connection_file.to_string_lossy().into_owned()
8359 ],
8360 "a subc-wire spawn still carries --subc <path>"
8361 );
8362 assert!(wire
8363 .as_std()
8364 .get_envs()
8365 .any(|(key, value)| key == OsStr::new(SUBC_LAUNCH_NONCE_ENV) && value.is_some()));
8366 assert!(handle.spawn_nonce(&wire_spec.module_id).is_some());
8367 }
8368
8369 #[test]
8379 fn plain_spawn_removes_the_spawn_role_even_when_the_spec_sets_it() {
8380 let role = |command: &Command| {
8381 command
8382 .as_std()
8383 .get_envs()
8384 .filter(|(key, _)| *key == OsStr::new(SUBC_SPAWN_ROLE_ENV))
8385 .last()
8386 .map(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()))
8387 };
8388 let forged = spec(vec![(
8389 SUBC_SPAWN_ROLE_ENV.to_string(),
8390 SPAWN_ROLE_SWAP_CANDIDATE.to_string(),
8391 )]);
8392
8393 let mut plain = Command::new("/nonexistent");
8394 apply_child_env(&mut plain, &forged);
8395 apply_spawn_role(&mut plain, SpawnRole::Plain);
8396 assert_eq!(
8397 role(&plain),
8398 Some(None),
8399 "a plain spawn must remove SUBC_SPAWN_ROLE, whatever the spec says"
8400 );
8401
8402 let mut candidate = Command::new("/nonexistent");
8403 apply_child_env(&mut candidate, &spec(Vec::new()));
8404 apply_spawn_role(&mut candidate, SpawnRole::SwapCandidate);
8405 assert_eq!(
8406 role(&candidate),
8407 Some(Some(SPAWN_ROLE_SWAP_CANDIDATE.to_string()))
8408 );
8409 }
8410
8411 #[test]
8417 fn daemon_private_capture_keys_are_not_passed_to_the_child() {
8418 let mut command = Command::new("/nonexistent");
8419 apply_child_env(
8420 &mut command,
8421 &spec(vec![
8422 (super::CAPTURE_KEEP_ENV.to_string(), "5".to_string()),
8423 ("KEPT".to_string(), "yes".to_string()),
8424 ]),
8425 );
8426 let keys: Vec<String> = command
8427 .as_std()
8428 .get_envs()
8429 .filter(|(_, value)| value.is_some())
8430 .map(|(key, _)| key.to_string_lossy().into_owned())
8431 .collect();
8432 assert!(keys.contains(&"KEPT".to_string()), "got {keys:?}");
8433 assert!(
8434 !keys.contains(&super::CAPTURE_KEEP_ENV.to_string()),
8435 "daemon-private capture key leaked to the child: {keys:?}"
8436 );
8437 }
8438}
8439
8440#[cfg(test)]
8441mod jitter_tests {
8442 use super::jittered_health_delay;
8443 use std::{collections::HashSet, time::Duration};
8444
8445 const FLEET: [&str; 14] = [
8454 "aft",
8455 "alfonso-core",
8456 "magic-context",
8457 "broca",
8458 "thalamus",
8459 "quota",
8460 "engram",
8461 "plexus",
8462 "cerebellum",
8463 "astrocyte",
8464 "synapse",
8465 "subc-mcp",
8466 "cortexkit-credentials",
8467 "subc-federation",
8468 ];
8469
8470 #[test]
8478 fn probe_delays_disperse_across_the_fleet() {
8479 let cadence = Duration::from_secs(30);
8480 let delays: HashSet<Duration> = FLEET
8481 .iter()
8482 .map(|id| jittered_health_delay(id, 0, cadence))
8483 .collect();
8484 assert_eq!(
8485 delays.len(),
8486 FLEET.len(),
8487 "every supervised module must land on its own probe offset"
8488 );
8489 }
8490
8491 #[test]
8497 fn jitter_only_delays_and_stays_within_one_tenth_of_cadence() {
8498 let cadence = Duration::from_secs(30);
8499 let span = cadence / 10;
8500 for id in FLEET {
8501 for probe_index in 0..8 {
8502 let delay = jittered_health_delay(id, probe_index, cadence);
8503 assert!(
8504 delay >= cadence,
8505 "{id}#{probe_index}: jitter must not shorten the cadence"
8506 );
8507 assert!(
8508 delay < cadence + span,
8509 "{id}#{probe_index}: jitter must stay inside one tenth of the cadence"
8510 );
8511 }
8512 }
8513 }
8514
8515 #[test]
8521 fn a_module_offset_is_stable_across_restarts() {
8522 let cadence = Duration::from_secs(30);
8523 for id in FLEET {
8524 assert_eq!(
8525 jittered_health_delay(id, 0, cadence),
8526 jittered_health_delay(id, 0, cadence),
8527 "{id}: the same module and probe index must produce the same offset"
8528 );
8529 }
8530 }
8531
8532 #[test]
8534 fn zero_cadence_yields_zero_delay() {
8535 assert_eq!(
8536 jittered_health_delay("aft", 0, Duration::ZERO),
8537 Duration::ZERO
8538 );
8539 }
8540}
8541
8542#[cfg(all(test, target_os = "linux"))]
8543mod cgroup_placement_tests {
8544 use super::{
8545 apply_cgroup_placement, remove_module_cgroup, ModuleProtocol, ModuleSpec, SuperviseError,
8546 SupervisedChild,
8547 };
8548 use crate::{
8549 stderr_tail::{StderrRing, StderrTailConfig},
8550 test_support::TestTempDir,
8551 };
8552 use std::{
8553 fs, io,
8554 path::{Path, PathBuf},
8555 sync::{Arc, Mutex},
8556 };
8557 use tokio::process::Command;
8558
8559 #[test]
8560 fn failed_parent_cgroup_open_is_a_cgroup_supervision_error() {
8561 let path = Path::new("/definitely-missing-subc-cgroup");
8562 let mut command = Command::new("true");
8563 let error = apply_cgroup_placement(
8564 &mut command,
8565 &ModuleSpec {
8566 module_id: "broken-cgroup".to_string(),
8567 program: PathBuf::from("true"),
8568 args: Vec::new(),
8569 env: Vec::new(),
8570 reserved: false,
8571 reserved_prefixes: Vec::new(),
8572 protocol: ModuleProtocol::Subc,
8573 overlap: Default::default(),
8574 },
8575 path,
8576 )
8577 .expect_err("a parent cgroup open failure must reject the supervised spawn");
8578 let reason = error.to_string();
8579
8580 assert!(
8581 matches!(error, SuperviseError::Cgroup { .. }),
8582 "parent cgroup open must be reported as a cgroup supervision error: {reason}"
8583 );
8584 assert!(
8585 reason.contains("/definitely-missing-subc-cgroup/cgroup.procs"),
8586 "parent cgroup open failure must name cgroup.procs: {reason}"
8587 );
8588 }
8589
8590 #[tokio::test]
8591 async fn reaping_a_child_removes_its_empty_module_cgroup() {
8592 let root = TestTempDir::new("supervisor-reap-cgroup");
8593 fs::write(root.join("cgroup.procs"), b"").expect("write scratch cgroup marker");
8594 let placement = subc_cgroup::prepare_at(&root)
8595 .expect("prepare scratch cgroup root")
8596 .expect("scratch root has a cgroup.procs marker");
8597 let module_id = "reaped-module";
8598 let module = placement
8599 .module_path(module_id)
8600 .expect("create scratch module cgroup");
8601 let child = Command::new("true")
8602 .spawn()
8603 .expect("spawn short-lived child");
8604 let pid = child.id().expect("spawned child has pid");
8605 let mut child = SupervisedChild {
8606 child,
8607 module_id: module_id.to_string(),
8608 cgroup_placement: Some(placement),
8609 stdout_pump: None,
8610 stderr_pump: None,
8611 stderr_ring: Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default()))),
8612 spawned_at_ms: 0,
8613 spawned_from: PathBuf::from("true"),
8614 spawned_file_identity: None,
8615 process_start_time: None,
8616 process_identity: None,
8617 pid,
8618 };
8619
8620 child.wait().await.expect("reap short-lived child");
8621
8622 assert!(
8623 !module.exists(),
8624 "reaping the supervised child must remove its empty cgroup"
8625 );
8626 }
8627
8628 #[test]
8629 fn non_empty_cgroup_removal_is_reported_without_blocking_teardown() {
8630 let root = TestTempDir::new("supervisor-non-empty-cgroup");
8631 fs::write(root.join("cgroup.procs"), b"").expect("write scratch cgroup marker");
8632 let placement = subc_cgroup::prepare_at(&root)
8633 .expect("prepare scratch cgroup root")
8634 .expect("scratch root has a cgroup.procs marker");
8635 let module = placement
8636 .module_path("surviving-module")
8637 .expect("create scratch module cgroup");
8638 fs::write(module.join("surviving-process"), b"still present")
8639 .expect("make scratch cgroup non-empty");
8640 let (logs, _guard) = crate::router::test_log::log_capture(tracing::Level::WARN);
8641
8642 remove_module_cgroup(&placement, "surviving-module");
8643
8644 let logs = crate::router::test_log::captured_logs(&logs);
8645 assert!(
8646 module.exists(),
8647 "failed removal must leave the cgroup intact"
8648 );
8649 assert!(
8650 logs.contains("could not remove module cgroup after process exit; continuing teardown")
8651 && logs.contains("surviving-module"),
8652 "best-effort removal must report the failure without returning it: {logs}"
8653 );
8654 }
8655
8656 #[test]
8657 fn cgroup_pre_exec_spawn_failure_names_the_cgroup_path() {
8658 let cgroup_path = PathBuf::from("/sys/fs/cgroup/subc-modules/broken-module");
8659 let reason = SuperviseError::Spawn {
8660 program: PathBuf::from("/bin/true"),
8661 source: io::Error::from_raw_os_error(13),
8662 cgroup_path: Some(cgroup_path.clone()),
8663 }
8664 .to_string();
8665
8666 assert!(
8667 reason.contains(&cgroup_path.display().to_string()),
8668 "a pre_exec spawn failure must name the cgroup path: {reason}"
8669 );
8670 }
8671}