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