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;
86pub(crate) const SPAWN_SUBSCRIBER_LAGGED_CODE: &str = "spawn_subscriber_lagged";
91
92struct SupervisedChild {
93 child: Child,
94 #[cfg(target_os = "linux")]
97 module_id: String,
98 #[cfg(target_os = "linux")]
99 cgroup_placement: Option<subc_cgroup::Placement>,
100 stdout_pump: Option<JoinHandle<()>>,
101 stderr_pump: Option<JoinHandle<()>>,
102 stderr_ring: Arc<Mutex<StderrRing>>,
103 spawned_at_ms: u64,
104 spawned_from: PathBuf,
105 spawned_file_identity: Option<SpawnedFileIdentity>,
106 process_start_time: Option<u64>,
107 process_identity: Option<ProcessIdentity>,
108 pid: u32,
109 roster_guard: Option<crate::child_roster::RosterGuard>,
112}
113
114impl SupervisedChild {
115 fn id(&self) -> Option<u32> {
116 Some(self.pid)
117 }
118
119 fn process_identity(&self) -> Option<ProcessIdentity> {
120 self.process_identity
121 }
122
123 async fn wait(&mut self) -> io::Result<ExitStatus> {
124 let result = self.child.wait().await;
125 if result.is_ok() {
126 self.roster_guard = None;
129 }
130 #[cfg(target_os = "linux")]
131 if result.is_ok() {
132 if let Some(placement) = self.cgroup_placement.take() {
133 remove_module_cgroup(&placement, &self.module_id);
134 }
135 }
136 result
137 }
138
139 fn start_kill(&mut self) -> io::Result<()> {
140 self.child.start_kill()
141 }
142
143 async fn drain_stderr(&mut self, module_id: &str) {
144 if let Some(mut pump) = self.stdout_pump.take() {
145 match timeout(STDERR_PUMP_DRAIN_TIMEOUT, &mut pump).await {
146 Ok(Ok(())) => {}
147 Ok(Err(error)) => {
148 warn!(module_id, error = %error, "stdout pump ended unexpectedly");
149 }
150 Err(_) => {
151 pump.abort();
152 warn!(
153 module_id,
154 waited = ?STDERR_PUMP_DRAIN_TIMEOUT,
155 "stdout pump did not drain before restart; stopped it before the next process"
156 );
157 }
158 }
159 }
160
161 let Some(mut pump) = self.stderr_pump.take() else {
162 return;
163 };
164 match timeout(STDERR_PUMP_DRAIN_TIMEOUT, &mut pump).await {
165 Ok(Ok(())) => {}
166 Ok(Err(err)) => {
167 self.stderr_ring
168 .lock()
169 .unwrap_or_else(|poisoned| poisoned.into_inner())
170 .mark_incomplete(format!("stderr pump ended unexpectedly: {err}"));
171 warn!(module_id, error = %err, "stderr pump ended before clean EOF");
172 }
173 Err(_) => {
174 pump.abort();
175 self.stderr_ring
176 .lock()
177 .unwrap_or_else(|poisoned| poisoned.into_inner())
178 .mark_incomplete(format!(
179 "stderr pump did not reach EOF within {:?} before restart",
180 STDERR_PUMP_DRAIN_TIMEOUT
181 ));
182 warn!(
183 module_id,
184 waited = ?STDERR_PUMP_DRAIN_TIMEOUT,
185 "stderr pump did not drain before restart; stopped it before marking the new process"
186 );
187 }
188 }
189 }
190}
191
192fn registration_release_events() -> &'static watch::Sender<u64> {
193 static EVENTS: OnceLock<watch::Sender<u64>> = OnceLock::new();
194 EVENTS.get_or_init(|| {
195 let (sender, _receiver) = watch::channel(0);
196 sender
197 })
198}
199
200pub(crate) fn notify_registration_release() {
201 let events = registration_release_events();
202 let next_generation = (*events.borrow()).wrapping_add(1);
203 events.send_replace(next_generation);
204}
205
206#[derive(Debug, Clone, PartialEq, Eq)]
208pub struct ModuleSpec {
209 pub module_id: String,
210 pub program: PathBuf,
211 pub args: Vec<String>,
212 pub env: Vec<(String, String)>,
213 pub reserved: bool,
218 pub reserved_prefixes: Vec<String>,
223 pub protocol: ModuleProtocol,
239 pub overlap: ModuleOverlap,
244}
245
246#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
253pub enum ModuleOverlap {
254 #[default]
256 Exclusive,
257 Safe,
269}
270
271impl ModuleOverlap {
272 pub fn as_str(self) -> &'static str {
273 match self {
274 Self::Exclusive => "exclusive",
275 Self::Safe => "safe",
276 }
277 }
278}
279
280pub const SUBC_SPAWN_ROLE_ENV: &str = "SUBC_SPAWN_ROLE";
290pub const SPAWN_ROLE_SWAP_CANDIDATE: &str = "swap_candidate";
292pub const DEFAULT_SWAP_READY_TIMEOUT: Duration = Duration::from_secs(100);
297
298#[derive(Debug, Clone, Copy, PartialEq, Eq)]
316pub struct RestartPolicy {
317 pub max_restarts: u32,
318 pub backoff: Duration,
321 pub max_backoff: Duration,
323 pub window: Duration,
327}
328
329impl RestartPolicy {
330 pub fn new(max_restarts: u32, backoff: Duration) -> Self {
334 Self {
335 max_restarts,
336 backoff,
337 max_backoff: DEFAULT_MAX_BACKOFF,
338 window: DEFAULT_RESTART_WINDOW,
339 }
340 }
341
342 pub fn with_max_backoff(mut self, max_backoff: Duration) -> Self {
343 self.max_backoff = max_backoff;
344 self
345 }
346
347 pub fn with_window(mut self, window: Duration) -> Self {
348 self.window = window;
349 self
350 }
351
352 fn delay_for_restart(&self, restart_in_window: u32) -> Duration {
357 if self.backoff.is_zero() || self.max_backoff.is_zero() {
358 return Duration::ZERO;
359 }
360
361 let mut delay = self.backoff;
362 for _ in 0..restart_in_window {
363 if delay >= self.max_backoff {
364 return self.max_backoff;
365 }
366 delay = delay
367 .checked_mul(10)
368 .unwrap_or(self.max_backoff)
369 .min(self.max_backoff);
370 }
371 delay.min(self.max_backoff)
372 }
373
374 fn budget_exhausted_detail(&self) -> String {
379 format!(
380 "crash budget exhausted: max_restarts={} within window_secs={}",
381 self.max_restarts,
382 self.window.as_secs()
383 )
384 }
385}
386
387impl Default for RestartPolicy {
388 fn default() -> Self {
389 Self {
390 max_restarts: DEFAULT_MAX_RESTARTS,
391 backoff: DEFAULT_BACKOFF,
392 max_backoff: DEFAULT_MAX_BACKOFF,
393 window: DEFAULT_RESTART_WINDOW,
394 }
395 }
396}
397
398#[derive(Debug, Clone, Copy, PartialEq, Eq)]
399struct CrashRestartSchedule {
400 restart_in_window: u32,
401 delay: Duration,
402}
403
404fn daemon_will_restart(
411 state: &mut SupervisorSnapshot,
412 policy: &RestartPolicy,
413 now: Instant,
414) -> bool {
415 state.enabled && state.crash_restarts_in_window(policy.window, now) < policy.max_restarts
416}
417
418const DEFAULT_HEALTH_CADENCE: Duration = Duration::from_secs(30);
419const DEFAULT_HEALTH_DEADLINE: Duration = Duration::from_secs(5);
420const DEFAULT_HEALTH_FAILURE_THRESHOLD: u32 = 3;
421const MAX_HEALTH_METRICS_BYTES: usize = 16 * 1024;
422
423#[derive(Debug, Clone, Copy, PartialEq, Eq)]
424pub enum HealthAction {
425 Report,
426 Restart,
427 Alert,
428}
429
430impl fmt::Display for HealthAction {
431 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
432 f.write_str(match self {
433 Self::Report => "report",
434 Self::Restart => "restart",
435 Self::Alert => "alert",
436 })
437 }
438}
439
440#[derive(Debug, Clone, Copy, PartialEq, Eq)]
441pub struct HealthConfig {
442 pub cadence: Duration,
443 pub deadline: Duration,
444 pub failure_threshold: u32,
445 pub on_degraded: HealthAction,
446 pub on_failing: HealthAction,
447 pub critical: bool,
448}
449
450impl Default for HealthConfig {
451 fn default() -> Self {
452 Self {
453 cadence: DEFAULT_HEALTH_CADENCE,
454 deadline: DEFAULT_HEALTH_DEADLINE,
455 failure_threshold: DEFAULT_HEALTH_FAILURE_THRESHOLD,
456 on_degraded: HealthAction::Report,
457 on_failing: HealthAction::Report,
458 critical: false,
459 }
460 }
461}
462
463#[derive(Debug, Clone, PartialEq)]
481pub struct ModuleHealthStatus {
482 pub status: SupervisorHealthStatus,
483 pub last_probe_ms: Option<u64>,
484 pub detail: Option<String>,
485 pub metrics: Option<Value>,
486 pub consecutive_failures: u32,
487 pub late_answer_count: u64,
490 pub last_late_answer_latency_ms: Option<u64>,
492 pub last_action: Option<String>,
493 pub last_action_ms: Option<u64>,
497}
498
499impl Default for ModuleHealthStatus {
500 fn default() -> Self {
501 Self {
502 status: SupervisorHealthStatus::Unknown,
503 last_probe_ms: None,
504 detail: None,
505 metrics: None,
506 consecutive_failures: 0,
507 late_answer_count: 0,
508 last_late_answer_latency_ms: None,
509 last_action: None,
510 last_action_ms: None,
511 }
512 }
513}
514
515#[derive(Debug, Clone, Copy, PartialEq, Eq)]
517pub enum ModuleState {
518 Starting,
519 Running,
520 Unresponsive,
521 Restarting,
522 Draining,
523 Stopped,
524 Failed,
525 Disabled,
526}
527
528impl fmt::Display for ModuleState {
529 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
530 f.write_str(match self {
531 Self::Starting => "starting",
532 Self::Running => "running",
533 Self::Unresponsive => "unresponsive",
534 Self::Restarting => "restarting",
535 Self::Draining => "draining",
536 Self::Stopped => "stopped",
537 Self::Failed => "failed",
538 Self::Disabled => "disabled",
539 })
540 }
541}
542
543#[derive(Debug, Clone, Copy, PartialEq, Eq)]
545pub enum ExitKind {
546 Clean,
547 Crash,
548 DeliberateSeverance,
549}
550
551impl From<ExitKind> for TerminalExitKind {
552 fn from(kind: ExitKind) -> Self {
553 match kind {
554 ExitKind::Clean => Self::Clean,
555 ExitKind::Crash => Self::Crash,
556 ExitKind::DeliberateSeverance => Self::DeliberateSeverance,
557 }
558 }
559}
560
561#[derive(Debug, Clone, Copy, PartialEq, Eq)]
564pub(crate) struct ProcessIdentity {
565 pub(crate) pid: u32,
566 pub(crate) start_time: u64,
567}
568
569#[derive(Debug, Clone, PartialEq, Eq)]
571pub struct ExitReport {
572 pub kind: ExitKind,
573 pub code: Option<i32>,
574 pub signal: Option<i32>,
575 pub at_ms: u64,
576}
577
578#[derive(Debug, Clone, PartialEq)]
581pub struct ModuleStatus {
582 pub module_id: String,
583 pub state: ModuleState,
584 pub enabled: bool,
585 pub process_alive: bool,
586 pub registration_active: bool,
587 pub protocol: ModuleProtocol,
590 pub live: bool,
601 pub restart_count: u32,
605 pub lifetime_restarts: u32,
609 pub spawn_generation: u64,
610 pub max_restarts: u32,
615 pub restart_window: Duration,
619 pub drain_timeout: Duration,
623 pub restart_backoff: Duration,
624 pub restart_max_backoff: Duration,
625 pub pid: Option<u32>,
626 pub spawned_at_ms: Option<u64>,
627 pub spawned_from: Option<PathBuf>,
628 pub process_start_time: Option<u64>,
629 pub last_exit: Option<ExitReport>,
630 pub health: ModuleHealthStatus,
631}
632
633#[derive(Debug, Clone, PartialEq)]
634struct SupervisorSnapshot {
635 state: ModuleState,
636 enabled: bool,
637 process_alive: bool,
638 crash_restarts: VecDeque<Instant>,
644 lifetime_restarts: u32,
645 spawn_generation: u64,
654 pid: Option<u32>,
655 spawned_at_ms: Option<u64>,
656 spawned_from: Option<PathBuf>,
657 spawned_file_identity: Option<SpawnedFileIdentity>,
658 process_start_time: Option<u64>,
659 deliberate_severance: Option<ProcessIdentity>,
660 last_exit: Option<ExitReport>,
661 health: ModuleHealthStatus,
662 in_alternate_slot: bool,
667}
668
669impl SupervisorSnapshot {
670 fn starting() -> Self {
671 Self::new(ModuleState::Starting, true)
672 }
673
674 fn disabled() -> Self {
675 Self::new(ModuleState::Disabled, false)
676 }
677
678 fn failed() -> Self {
679 Self::new(ModuleState::Failed, true)
680 }
681
682 fn crash_restarts_in_window(&mut self, window: Duration, now: Instant) -> u32 {
686 while let Some(oldest) = self.crash_restarts.front() {
687 if now.duration_since(*oldest) > window {
688 self.crash_restarts.pop_front();
689 } else {
690 break;
691 }
692 }
693 u32::try_from(self.crash_restarts.len()).unwrap_or(u32::MAX)
694 }
695
696 fn record_crash_restart(&mut self, policy: &RestartPolicy, now: Instant) {
702 self.crash_restarts.push_back(now);
703 while self.crash_restarts.len() > policy.max_restarts as usize {
704 self.crash_restarts.pop_front();
705 }
706 self.lifetime_restarts += 1;
707 }
708
709 fn next_crash_restart(
713 &mut self,
714 policy: &RestartPolicy,
715 now: Instant,
716 ) -> Option<CrashRestartSchedule> {
717 let restart_in_window = self.crash_restarts_in_window(policy.window, now);
718 if restart_in_window >= policy.max_restarts {
719 return None;
720 }
721 self.record_crash_restart(policy, now);
722 Some(CrashRestartSchedule {
723 restart_in_window,
724 delay: policy.delay_for_restart(restart_in_window),
725 })
726 }
727
728 fn clear_crash_restarts(&mut self) {
733 self.crash_restarts.clear();
734 }
735
736 fn new(state: ModuleState, enabled: bool) -> Self {
737 Self {
738 state,
739 enabled,
740 process_alive: false,
741 crash_restarts: VecDeque::new(),
742 lifetime_restarts: 0,
743 spawn_generation: 0,
744 pid: None,
745 spawned_at_ms: None,
746 spawned_from: None,
747 spawned_file_identity: None,
748 process_start_time: None,
749 deliberate_severance: None,
750 last_exit: None,
751 health: ModuleHealthStatus::default(),
752 in_alternate_slot: false,
753 }
754 }
755}
756
757type SharedSnapshot = Arc<Mutex<SupervisorSnapshot>>;
758
759type SpawnSubscriberKey = (ConnectionId, u64);
760
761#[derive(Debug)]
762struct SpawnSubscriber {
763 version: u8,
764 frames: mpsc::Sender<Frame>,
765 lagged: Option<oneshot::Sender<SpawnCursor>>,
769}
770
771#[derive(Debug)]
772struct SpawnEventState {
773 daemon_incarnation: String,
774 seq: u64,
775 capacity: usize,
776 live: HashMap<String, LiveSpawn>,
777 generations: HashMap<String, u64>,
778 events: VecDeque<SpawnEvent>,
779 subscribers: HashMap<SpawnSubscriberKey, SpawnSubscriber>,
780}
781
782impl Default for SpawnEventState {
783 fn default() -> Self {
784 Self {
785 daemon_incarnation: "unconfigured".to_string(),
786 seq: 0,
787 capacity: SPAWN_EVENT_RING_CAPACITY,
788 live: HashMap::new(),
789 generations: HashMap::new(),
790 events: VecDeque::new(),
791 subscribers: HashMap::new(),
792 }
793 }
794}
795
796#[derive(Debug, Clone, Default)]
797struct SpawnEventFeed(Arc<Mutex<SpawnEventState>>);
798
799#[derive(Debug, Clone, PartialEq, Eq)]
800pub(crate) enum SpawnSubscribeRefusal {
801 ForeignIncarnation { current: String },
802 TooOld { oldest: SpawnCursor },
803 Frame(String),
804}
805
806impl SpawnEventFeed {
807 fn configure_incarnation(&self, daemon_incarnation: String) {
808 let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
809 state.daemon_incarnation = daemon_incarnation;
810 state.seq = 0;
811 state.live.clear();
812 state.generations.clear();
813 state.events.clear();
814 state.subscribers.clear();
815 }
816
817 fn cursor(state: &SpawnEventState) -> SpawnCursor {
818 SpawnCursor {
819 daemon_incarnation: state.daemon_incarnation.clone(),
820 seq: state.seq,
821 }
822 }
823
824 fn snapshot(&self) -> SpawnSnapshot {
825 let state = self.0.lock().unwrap_or_else(|p| p.into_inner());
826 let mut live = state.live.values().cloned().collect::<Vec<_>>();
827 live.sort_by(|left, right| left.module_id.cmp(&right.module_id));
828 SpawnSnapshot {
829 cursor: Self::cursor(&state),
830 ring_bound: state.capacity as u64,
831 live,
832 }
833 }
834
835 fn emit_spawned(&self, module_id: &str, pid: u32, spawned_at_ms: u64) -> u64 {
836 let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
837 let generation = state
838 .generations
839 .get(module_id)
840 .copied()
841 .unwrap_or(0)
842 .checked_add(1)
843 .expect("spawn generation exhausted");
844 state.generations.insert(module_id.to_string(), generation);
845 let live = LiveSpawn {
846 module_id: module_id.to_string(),
847 spawn_generation: generation,
848 pid,
849 spawned_at_ms,
850 };
851 state.live.insert(module_id.to_string(), live);
852 Self::emit_locked(
853 &mut state,
854 SpawnEventKind::Spawned,
855 module_id.to_string(),
856 generation,
857 pid,
858 None,
859 None,
860 );
861 generation
862 }
863
864 fn emit_exited(&self, module_id: &str, exit_code: Option<i32>, exit_signal: Option<i32>) {
865 let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
866 let Some(live) = state.live.remove(module_id) else {
867 warn!(
868 module_id,
869 "terminal record had no live spawn event identity"
870 );
871 return;
872 };
873 Self::emit_locked(
874 &mut state,
875 SpawnEventKind::Exited,
876 module_id.to_string(),
877 live.spawn_generation,
878 live.pid,
879 exit_code,
880 exit_signal,
881 );
882 }
883
884 fn emit_superseded_exited(
891 &self,
892 module_id: &str,
893 spawn_generation: u64,
894 pid: u32,
895 exit_code: Option<i32>,
896 exit_signal: Option<i32>,
897 ) {
898 let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
899 if state
900 .live
901 .get(module_id)
902 .is_some_and(|live| live.spawn_generation == spawn_generation)
903 {
904 state.live.remove(module_id);
905 }
906 Self::emit_locked(
907 &mut state,
908 SpawnEventKind::Exited,
909 module_id.to_string(),
910 spawn_generation,
911 pid,
912 exit_code,
913 exit_signal,
914 );
915 }
916
917 #[allow(clippy::too_many_arguments)]
918 fn emit_locked(
919 state: &mut SpawnEventState,
920 kind: SpawnEventKind,
921 module_id: String,
922 spawn_generation: u64,
923 pid: u32,
924 exit_code: Option<i32>,
925 exit_signal: Option<i32>,
926 ) {
927 state.seq = state
928 .seq
929 .checked_add(1)
930 .expect("spawn event sequence exhausted");
931 let event = SpawnEvent {
932 cursor: Self::cursor(state),
933 kind,
934 module_id,
935 spawn_generation,
936 pid,
937 exit_code,
938 exit_signal,
939 };
940 state.events.push_back(event.clone());
941 while state.events.len() > state.capacity {
942 state.events.pop_front();
943 }
944 let body = match serde_json::to_vec(&event) {
945 Ok(body) => body,
946 Err(error) => {
947 error!(%error, "failed to serialize supervisor spawn event");
948 return;
949 }
950 };
951 state.subscribers.retain(|(connection_id, corr), subscriber| {
952 let frame = Frame::build_with_version(
953 subscriber.version,
954 FrameType::StreamData,
955 control_flags(),
956 0,
957 0,
958 *corr,
959 body.clone(),
960 );
961 match frame {
962 Ok(frame) => {
963 if subscriber.frames.try_send(frame).is_ok() {
964 true
965 } else {
966 warn!(connection_id = connection_id.get(), corr, "dropping lagged supervisor spawn subscriber");
967 if let Some(lagged) = subscriber.lagged.take() {
968 let _ = lagged.send(event.cursor.clone());
969 }
970 false
971 }
972 }
973 Err(error) => {
974 warn!(connection_id = connection_id.get(), corr, %error, "dropping supervisor spawn subscriber after frame build failure");
975 false
976 }
977 }
978 });
979 }
980
981 fn subscribe(
982 &self,
983 connection_id: ConnectionId,
984 corr: u64,
985 version: u8,
986 since: Option<SpawnCursor>,
987 sink: FrameSink,
988 ) -> Result<(), SpawnSubscribeRefusal> {
989 let (frames, mut receiver) = mpsc::channel(SPAWN_SUBSCRIBER_BUFFER);
990 let (lagged, mut lagged_rx) = oneshot::channel::<SpawnCursor>();
991 {
992 let mut state = self.0.lock().unwrap_or_else(|p| p.into_inner());
993 let replay = if let Some(since) = since {
994 if since.daemon_incarnation != state.daemon_incarnation {
995 return Err(SpawnSubscribeRefusal::ForeignIncarnation {
996 current: state.daemon_incarnation.clone(),
997 });
998 }
999 if let Some(oldest) = state.events.front().map(|event| event.cursor.clone()) {
1000 if since.seq < oldest.seq.saturating_sub(1) {
1001 return Err(SpawnSubscribeRefusal::TooOld { oldest });
1002 }
1003 }
1004 state
1005 .events
1006 .iter()
1007 .filter(|event| event.cursor.seq > since.seq)
1008 .cloned()
1009 .collect::<Vec<_>>()
1010 } else {
1011 Vec::new()
1012 };
1013 for event in replay {
1014 let body = serde_json::to_vec(&event)
1015 .map_err(|error| SpawnSubscribeRefusal::Frame(error.to_string()))?;
1016 let frame = Frame::build_with_version(
1017 version,
1018 FrameType::StreamData,
1019 control_flags(),
1020 0,
1021 0,
1022 corr,
1023 body,
1024 )
1025 .map_err(|error| SpawnSubscribeRefusal::Frame(error.to_string()))?;
1026 frames
1027 .try_send(frame)
1028 .map_err(|error| SpawnSubscribeRefusal::Frame(error.to_string()))?;
1029 }
1030 state.subscribers.insert(
1031 (connection_id, corr),
1032 SpawnSubscriber {
1033 version,
1034 frames,
1035 lagged: Some(lagged),
1036 },
1037 );
1038 }
1039 tokio::spawn(async move {
1050 while let Some(frame) = receiver.recv().await {
1051 if sink.send(frame).await.is_err() {
1052 return;
1053 }
1054 }
1055 let Ok(first_undelivered) = lagged_rx.try_recv() else {
1056 return;
1057 };
1058 match spawn_subscriber_lagged_frame(version, corr, first_undelivered) {
1059 Ok(frame) => {
1060 let _ = sink.send(frame).await;
1061 }
1062 Err(error) => {
1063 error!(%error, corr, "failed to build lagged spawn subscriber terminal frame");
1064 }
1065 }
1066 });
1067 Ok(())
1068 }
1069
1070 fn cancel(&self, connection_id: ConnectionId, corr: u64) -> bool {
1071 let Some(subscriber) = self
1072 .0
1073 .lock()
1074 .unwrap_or_else(|p| p.into_inner())
1075 .subscribers
1076 .remove(&(connection_id, corr))
1077 else {
1078 return false;
1079 };
1080 if let Ok(frame) = Frame::build_with_version(
1081 subscriber.version,
1082 FrameType::StreamEnd,
1083 control_flags(),
1084 0,
1085 0,
1086 corr,
1087 Vec::new(),
1088 ) {
1089 tokio::spawn(async move {
1090 let _ = subscriber.frames.send(frame).await;
1091 });
1092 }
1093 true
1094 }
1095
1096 fn remove_connection(&self, connection_id: ConnectionId) {
1097 self.0
1098 .lock()
1099 .unwrap_or_else(|p| p.into_inner())
1100 .subscribers
1101 .retain(|(subscriber_connection, _), _| *subscriber_connection != connection_id);
1102 }
1103
1104 #[cfg(any(test, feature = "test-support"))]
1105 fn set_capacity(&self, capacity: usize) {
1106 self.0.lock().unwrap_or_else(|p| p.into_inner()).capacity = capacity;
1107 }
1108
1109 #[cfg(any(test, feature = "test-support"))]
1110 fn subscriber_count(&self) -> usize {
1111 self.0
1112 .lock()
1113 .unwrap_or_else(|p| p.into_inner())
1114 .subscribers
1115 .len()
1116 }
1117}
1118
1119fn spawn_subscriber_lagged_frame(
1122 version: u8,
1123 corr: u64,
1124 first_undelivered: SpawnCursor,
1125) -> Result<Frame, String> {
1126 let body = serde_json::to_vec(&subc_protocol::ErrorBody {
1127 code: SPAWN_SUBSCRIBER_LAGGED_CODE.to_string(),
1128 message: "spawn subscriber fell behind and was dropped; resubscribe from the last cursor received"
1129 .to_string(),
1130 detail: Some(serde_json::json!({
1131 "first_undelivered_cursor": first_undelivered
1132 })),
1133 })
1134 .map_err(|error| error.to_string())?;
1135 Frame::build_with_version(version, FrameType::Error, control_flags(), 0, 0, corr, body)
1136 .map_err(|error| error.to_string())
1137}
1138
1139pub trait ModuleProcessLiveness: Send + Sync {
1140 fn process_live(&self, module_id: &str) -> Option<bool>;
1141}
1142
1143#[derive(Debug, Clone, Default)]
1145pub struct SupervisorProcessLiveness {
1146 snapshots: Arc<Mutex<HashMap<String, SharedSnapshot>>>,
1147}
1148
1149impl SupervisorProcessLiveness {
1150 pub fn new() -> Self {
1151 Self::default()
1152 }
1153
1154 fn track(&self, module_id: String, snapshot: SharedSnapshot) {
1155 let mut snapshots = self
1156 .snapshots
1157 .lock()
1158 .unwrap_or_else(|poisoned| poisoned.into_inner());
1159 snapshots.insert(module_id, snapshot);
1160 }
1161
1162 fn untrack_if_current(&self, module_id: &str, snapshot: &SharedSnapshot) {
1163 let mut snapshots = self
1164 .snapshots
1165 .lock()
1166 .unwrap_or_else(|poisoned| poisoned.into_inner());
1167 let is_current = snapshots
1168 .get(module_id)
1169 .map(|tracked| Arc::ptr_eq(tracked, snapshot))
1170 .unwrap_or(false);
1171 if is_current {
1172 snapshots.remove(module_id);
1173 }
1174 }
1175}
1176
1177impl ModuleProcessLiveness for SupervisorProcessLiveness {
1178 fn process_live(&self, module_id: &str) -> Option<bool> {
1179 let snapshot = {
1180 let snapshots = self
1181 .snapshots
1182 .lock()
1183 .unwrap_or_else(|poisoned| poisoned.into_inner());
1184 snapshots.get(module_id).cloned()
1185 }?;
1186 let snapshot = snapshot
1187 .lock()
1188 .unwrap_or_else(|poisoned| poisoned.into_inner());
1189 Some(snapshot.state == ModuleState::Running && snapshot.process_alive)
1190 }
1191}
1192
1193#[derive(Debug, Clone)]
1194struct SupervisorRuntimeConfig {
1195 restart_policy: RestartPolicy,
1196 drain_timeout: Duration,
1199 effective_drain_timeout: Arc<Mutex<Duration>>,
1202 default_drain_timeout: Duration,
1205 health: HealthConfig,
1206 connection_file_path: Option<PathBuf>,
1207 capture_logs_dir: Option<PathBuf>,
1208 forwarding: Option<Arc<ForwardingTable>>,
1209 supervisor_handle: Option<SupervisorHandle>,
1212 stderr_ring: Arc<Mutex<StderrRing>>,
1219 terminal_ring: Arc<Mutex<TerminalRing>>,
1220 spawn_events: SpawnEventFeed,
1221 child_roster: ChildRoster,
1222 #[cfg(target_os = "linux")]
1223 cgroup_placement: Option<subc_cgroup::Placement>,
1224 #[cfg(test)]
1225 test_seed_stale_facts_before_enable_spawn: bool,
1226}
1227
1228#[derive(Debug, Clone, PartialEq, Eq)]
1229struct SupervisedConfiguration {
1230 spec: ModuleSpec,
1231 health: HealthConfig,
1232}
1233
1234#[derive(Debug, Clone, Default)]
1240pub struct SupervisorHandle {
1241 modules: Arc<Mutex<HashMap<String, SupervisedModule>>>,
1242 spawn_events: SpawnEventFeed,
1243 reserved_nonces: Arc<Mutex<HashMap<String, Option<String>>>>,
1254 removal_tombstones: Arc<Mutex<HashMap<String, u64>>>,
1260 spawn_nonces: Arc<Mutex<HashMap<String, String>>>,
1264 reserved_prefix_owners: Arc<Mutex<HashMap<String, String>>>,
1272 swaps: Arc<Mutex<HashMap<String, OpenSwap>>>,
1278 promotion_observer: PromotionObserverSlot,
1280 operation_lock: Arc<AsyncMutex<()>>,
1284}
1285
1286pub(crate) trait SwapPromotionObserver: Send + Sync {
1295 fn swap_promoted(&self, registration: &crate::registry::ModuleRegistration);
1296}
1297
1298#[derive(Clone, Default)]
1302struct PromotionObserverSlot(Arc<Mutex<Option<std::sync::Weak<dyn SwapPromotionObserver>>>>);
1303
1304impl fmt::Debug for PromotionObserverSlot {
1305 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1306 f.write_str("PromotionObserverSlot")
1307 }
1308}
1309
1310#[derive(Debug, Clone)]
1312struct OpenSwap {
1313 candidate_nonce: String,
1316 incumbent_nonce: Option<String>,
1321 candidate_admitted: bool,
1325}
1326
1327#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1330pub(crate) enum SwapHelloAdmission {
1331 NotSwapping,
1334 Candidate,
1336 Refused,
1339}
1340
1341#[derive(Debug, Clone, PartialEq, Eq)]
1342pub(crate) enum ReservedHelloRejection {
1343 Exact {
1344 module_id: String,
1345 },
1346 Prefix {
1347 prefix: String,
1348 owner_module_id: String,
1349 },
1350}
1351
1352impl SupervisorHandle {
1353 pub fn new() -> Self {
1354 Self::default()
1355 }
1356
1357 pub(crate) fn spawn_snapshot(&self) -> SpawnSnapshot {
1358 self.spawn_events.snapshot()
1359 }
1360
1361 pub(crate) fn subscribe_spawns(
1362 &self,
1363 connection_id: ConnectionId,
1364 corr: u64,
1365 version: u8,
1366 since: Option<SpawnCursor>,
1367 sink: FrameSink,
1368 ) -> Result<(), SpawnSubscribeRefusal> {
1369 self.spawn_events
1370 .subscribe(connection_id, corr, version, since, sink)
1371 }
1372
1373 pub(crate) fn cancel_spawn_subscription(&self, connection_id: ConnectionId, corr: u64) -> bool {
1374 self.spawn_events.cancel(connection_id, corr)
1375 }
1376
1377 pub(crate) fn remove_spawn_subscribers(&self, connection_id: ConnectionId) {
1378 self.spawn_events.remove_connection(connection_id);
1379 }
1380
1381 #[cfg(any(test, feature = "test-support"))]
1382 pub fn set_spawn_event_capacity_for_test(&self, capacity: usize) {
1383 assert!(capacity > 0, "spawn event capacity must be non-zero");
1384 self.spawn_events.set_capacity(capacity);
1385 }
1386
1387 #[cfg(any(test, feature = "test-support"))]
1388 pub fn spawn_subscriber_count_for_test(&self) -> usize {
1389 self.spawn_events.subscriber_count()
1390 }
1391
1392 pub fn set_spawn_nonce(&self, module_id: &str, nonce: String) {
1395 self.spawn_nonces
1396 .lock()
1397 .unwrap_or_else(|poisoned| poisoned.into_inner())
1398 .insert(module_id.to_string(), nonce);
1399 }
1400
1401 pub fn set_reserved_nonce(&self, module_id: &str, nonce: String) {
1404 self.reserved_nonces
1405 .lock()
1406 .unwrap_or_else(|poisoned| poisoned.into_inner())
1407 .insert(module_id.to_string(), Some(nonce));
1408 }
1409
1410 pub fn set_reserved_prefixes(&self, owner_module_id: &str, prefixes: &[String]) {
1412 let mut owners = self
1413 .reserved_prefix_owners
1414 .lock()
1415 .unwrap_or_else(|poisoned| poisoned.into_inner());
1416 owners.retain(|_, owner| owner != owner_module_id);
1417 for prefix in prefixes {
1418 owners.insert(prefix.clone(), owner_module_id.to_string());
1419 }
1420 }
1421
1422 #[cfg(test)]
1424 pub(crate) fn spawn_nonce(&self, module_id: &str) -> Option<String> {
1425 self.spawn_nonces
1426 .lock()
1427 .unwrap_or_else(|poisoned| poisoned.into_inner())
1428 .get(module_id)
1429 .cloned()
1430 }
1431
1432 fn apply_identity_configuration(&self, spec: &ModuleSpec) {
1433 self.set_reserved_prefixes(&spec.module_id, &spec.reserved_prefixes);
1434 let spawn_nonce = self
1435 .spawn_nonces
1436 .lock()
1437 .unwrap_or_else(|poisoned| poisoned.into_inner())
1438 .get(&spec.module_id)
1439 .cloned();
1440 let mut reserved_nonces = self
1441 .reserved_nonces
1442 .lock()
1443 .unwrap_or_else(|poisoned| poisoned.into_inner());
1444 if spec.reserved {
1445 reserved_nonces.insert(spec.module_id.clone(), spawn_nonce);
1450 }
1451 drop(reserved_nonces);
1452 self.removal_tombstones
1456 .lock()
1457 .unwrap_or_else(|poisoned| poisoned.into_inner())
1458 .remove(&spec.module_id);
1459 }
1460
1461 pub fn reserved_hello_authorized(&self, module_id: &str, presented: Option<&str>) -> bool {
1466 self.reserved_hello_rejection(module_id, presented)
1467 .is_none()
1468 }
1469
1470 pub(crate) fn reserved_hello_rejection(
1471 &self,
1472 module_id: &str,
1473 presented: Option<&str>,
1474 ) -> Option<ReservedHelloRejection> {
1475 let nonces = self
1476 .reserved_nonces
1477 .lock()
1478 .unwrap_or_else(|poisoned| poisoned.into_inner());
1479 if let Some(expected) = nonces.get(module_id) {
1480 let authorized = match expected {
1484 Some(expected) => {
1485 presented.is_some_and(|p| constant_time_eq(expected.as_bytes(), p.as_bytes()))
1486 }
1487 None => false,
1488 };
1489 if authorized {
1490 return None;
1491 }
1492 return Some(ReservedHelloRejection::Exact {
1493 module_id: module_id.to_string(),
1494 });
1495 }
1496 drop(nonces);
1497
1498 let matched_prefix = self
1499 .reserved_prefix_owners
1500 .lock()
1501 .unwrap_or_else(|poisoned| poisoned.into_inner())
1502 .iter()
1503 .filter(|(prefix, _)| module_id.starts_with(prefix.as_str()))
1504 .max_by_key(|(prefix, _)| prefix.len())
1505 .map(|(prefix, owner)| (prefix.clone(), owner.clone()));
1506 let (prefix, owner_module_id) = matched_prefix?;
1507
1508 let authorized = presented.is_some_and(|presented| {
1509 self.spawn_nonces
1510 .lock()
1511 .unwrap_or_else(|poisoned| poisoned.into_inner())
1512 .get(&owner_module_id)
1513 .is_some_and(|expected| constant_time_eq(expected.as_bytes(), presented.as_bytes()))
1514 || self.swap_nonce_matches(&owner_module_id, presented)
1517 });
1518 if authorized {
1519 None
1520 } else {
1521 Some(ReservedHelloRejection::Prefix {
1522 prefix,
1523 owner_module_id,
1524 })
1525 }
1526 }
1527
1528 pub fn spawned_consumer_authorized(&self, module_id: &str, presented: &str) -> bool {
1533 if presented.is_empty() {
1534 return false;
1535 }
1536 let nonces = self
1537 .spawn_nonces
1538 .lock()
1539 .unwrap_or_else(|poisoned| poisoned.into_inner());
1540 let current = nonces
1541 .get(module_id)
1542 .is_some_and(|expected| constant_time_eq(expected.as_bytes(), presented.as_bytes()));
1543 drop(nonces);
1544 current || self.swap_nonce_matches(module_id, presented)
1549 }
1550
1551 fn swap_nonce_matches(&self, module_id: &str, presented: &str) -> bool {
1553 let swaps = self
1554 .swaps
1555 .lock()
1556 .unwrap_or_else(|poisoned| poisoned.into_inner());
1557 swaps.get(module_id).is_some_and(|swap| {
1558 constant_time_eq(swap.candidate_nonce.as_bytes(), presented.as_bytes())
1559 || swap.incumbent_nonce.as_deref().is_some_and(|incumbent| {
1560 constant_time_eq(incumbent.as_bytes(), presented.as_bytes())
1561 })
1562 })
1563 }
1564
1565 pub(crate) fn open_swap(&self, module_id: &str, candidate_nonce: String) {
1568 let incumbent_nonce = self
1569 .spawn_nonces
1570 .lock()
1571 .unwrap_or_else(|poisoned| poisoned.into_inner())
1572 .get(module_id)
1573 .cloned();
1574 self.swaps
1575 .lock()
1576 .unwrap_or_else(|poisoned| poisoned.into_inner())
1577 .insert(
1578 module_id.to_string(),
1579 OpenSwap {
1580 candidate_nonce,
1581 incumbent_nonce,
1582 candidate_admitted: false,
1583 },
1584 );
1585 }
1586
1587 pub(crate) fn close_swap(&self, module_id: &str) {
1590 self.swaps
1591 .lock()
1592 .unwrap_or_else(|poisoned| poisoned.into_inner())
1593 .remove(module_id);
1594 }
1595
1596 pub(crate) fn set_swap_promotion_observer(
1599 &self,
1600 observer: std::sync::Weak<dyn SwapPromotionObserver>,
1601 ) {
1602 *self
1603 .promotion_observer
1604 .0
1605 .lock()
1606 .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(observer);
1607 }
1608
1609 fn notify_swap_promoted(&self, registration: &crate::registry::ModuleRegistration) {
1612 let observer = self
1613 .promotion_observer
1614 .0
1615 .lock()
1616 .unwrap_or_else(|poisoned| poisoned.into_inner())
1617 .as_ref()
1618 .and_then(std::sync::Weak::upgrade);
1619 if let Some(observer) = observer {
1620 observer.swap_promoted(registration);
1621 }
1622 }
1623
1624 pub(crate) fn swap_open(&self, module_id: &str) -> bool {
1626 self.swaps
1627 .lock()
1628 .unwrap_or_else(|poisoned| poisoned.into_inner())
1629 .contains_key(module_id)
1630 }
1631
1632 fn promote_swap_nonce(&self, module_id: &str, reserved: bool) {
1637 let candidate_nonce = self
1638 .swaps
1639 .lock()
1640 .unwrap_or_else(|poisoned| poisoned.into_inner())
1641 .get(module_id)
1642 .map(|swap| swap.candidate_nonce.clone());
1643 let Some(nonce) = candidate_nonce else {
1644 return;
1645 };
1646 self.set_spawn_nonce(module_id, nonce.clone());
1647 if reserved {
1648 self.set_reserved_nonce(module_id, nonce);
1649 }
1650 }
1651
1652 pub(crate) fn swap_hello_admission(
1667 &self,
1668 module_id: &str,
1669 presented: Option<&str>,
1670 ) -> SwapHelloAdmission {
1671 let swaps = self
1672 .swaps
1673 .lock()
1674 .unwrap_or_else(|poisoned| poisoned.into_inner());
1675 let Some(swap) = swaps.get(module_id) else {
1676 return SwapHelloAdmission::NotSwapping;
1677 };
1678 let Some(presented) = presented else {
1679 return SwapHelloAdmission::Refused;
1680 };
1681 if constant_time_eq(swap.candidate_nonce.as_bytes(), presented.as_bytes()) {
1682 return if swap.candidate_admitted {
1683 SwapHelloAdmission::Refused
1684 } else {
1685 SwapHelloAdmission::Candidate
1686 };
1687 }
1688 if swap
1689 .incumbent_nonce
1690 .as_deref()
1691 .is_some_and(|incumbent| constant_time_eq(incumbent.as_bytes(), presented.as_bytes()))
1692 {
1693 return SwapHelloAdmission::NotSwapping;
1694 }
1695 SwapHelloAdmission::Refused
1696 }
1697
1698 pub(crate) fn mark_swap_candidate_admitted(&self, module_id: &str) {
1701 if let Some(swap) = self
1702 .swaps
1703 .lock()
1704 .unwrap_or_else(|poisoned| poisoned.into_inner())
1705 .get_mut(module_id)
1706 {
1707 swap.candidate_admitted = true;
1708 }
1709 }
1710
1711 pub fn spawn_launch_nonce_for(&self, module_id: &str) -> Option<String> {
1713 self.spawn_nonces
1714 .lock()
1715 .unwrap_or_else(|poisoned| poisoned.into_inner())
1716 .get(module_id)
1717 .cloned()
1718 }
1719
1720 pub fn reserved_launch_nonce_for(&self, module_id: &str) -> Option<String> {
1722 self.reserved_nonces
1723 .lock()
1724 .unwrap_or_else(|poisoned| poisoned.into_inner())
1725 .get(module_id)
1726 .cloned()
1727 .flatten()
1728 }
1729
1730 pub fn insert(&self, module: SupervisedModule) -> Option<SupervisedModule> {
1731 let mut modules = self
1732 .modules
1733 .lock()
1734 .unwrap_or_else(|poisoned| poisoned.into_inner());
1735 modules.insert(module.module_id().to_string(), module)
1736 }
1737
1738 pub fn get(&self, module_id: &str) -> Option<SupervisedModule> {
1739 let modules = self
1740 .modules
1741 .lock()
1742 .unwrap_or_else(|poisoned| poisoned.into_inner());
1743 modules.get(module_id).cloned()
1744 }
1745
1746 pub(crate) fn record_late_health_answer(
1747 &self,
1748 module_id: &str,
1749 latency_ms: u64,
1750 ) -> Result<bool, SuperviseError> {
1751 let Some(module) = self.get(module_id) else {
1752 return Ok(false);
1753 };
1754 update_snapshot(&module.inner.snapshot, Some(module_id), |state| {
1755 state.health.late_answer_count = state.health.late_answer_count.saturating_add(1);
1756 state.health.last_late_answer_latency_ms = Some(latency_ms);
1757 state.health.consecutive_failures = 0;
1765 })?;
1766 Ok(true)
1767 }
1768
1769 pub fn record_deliberate_severance(&self, module_id: &str) -> Result<bool, SuperviseError> {
1775 let Some(module) = self.get(module_id) else {
1776 return Ok(false);
1777 };
1778 let status = module.status()?;
1779 let Some((pid, start_time)) = status.pid.zip(status.process_start_time) else {
1780 return Ok(false);
1781 };
1782 module.record_deliberate_severance(ProcessIdentity { pid, start_time })
1783 }
1784
1785 pub fn list(&self) -> Vec<SupervisedModule> {
1786 let modules = self
1787 .modules
1788 .lock()
1789 .unwrap_or_else(|poisoned| poisoned.into_inner());
1790 let mut modules = modules.values().cloned().collect::<Vec<_>>();
1791 modules.sort_by(|left, right| left.module_id().cmp(right.module_id()));
1792 modules
1793 }
1794
1795 pub(crate) fn retire(&self, module_id: &str) -> Option<SupervisedModule> {
1796 self.spawn_nonces
1797 .lock()
1798 .unwrap_or_else(|poisoned| poisoned.into_inner())
1799 .remove(module_id);
1800 self.close_swap(module_id);
1801 let mut reserved_nonces = self
1802 .reserved_nonces
1803 .lock()
1804 .unwrap_or_else(|poisoned| poisoned.into_inner());
1805 if reserved_nonces.contains_key(module_id) {
1806 reserved_nonces.insert(module_id.to_string(), None);
1809 }
1810 drop(reserved_nonces);
1811 self.reserved_prefix_owners
1812 .lock()
1813 .unwrap_or_else(|poisoned| poisoned.into_inner())
1814 .retain(|_, owner| owner != module_id);
1815 self.modules
1816 .lock()
1817 .unwrap_or_else(|poisoned| poisoned.into_inner())
1818 .remove(module_id)
1819 }
1820
1821 pub(crate) fn record_rescan_removal(&self, module_id: &str) {
1824 self.removal_tombstones
1825 .lock()
1826 .unwrap_or_else(|poisoned| poisoned.into_inner())
1827 .insert(module_id.to_string(), unix_ms_now());
1828 }
1829
1830 pub(crate) fn removal_tombstone_age_ms(&self, module_id: &str) -> Option<u64> {
1832 self.removal_tombstones
1833 .lock()
1834 .unwrap_or_else(|poisoned| poisoned.into_inner())
1835 .get(module_id)
1836 .copied()
1837 .map(|removed_at_ms| unix_ms_now().saturating_sub(removed_at_ms))
1838 }
1839
1840 pub(crate) fn release_retained_reserved_gate(&self, module_id: &str) -> bool {
1845 if self.get(module_id).is_some() {
1846 return false;
1847 }
1848 let mut reserved_nonces = self
1849 .reserved_nonces
1850 .lock()
1851 .unwrap_or_else(|poisoned| poisoned.into_inner());
1852 if !matches!(reserved_nonces.get(module_id), Some(None)) {
1853 return false;
1854 }
1855 reserved_nonces.remove(module_id);
1856 true
1857 }
1858
1859 pub(crate) fn operation_lock(&self) -> Arc<AsyncMutex<()>> {
1860 Arc::clone(&self.operation_lock)
1861 }
1862}
1863
1864#[derive(Debug, Clone)]
1866pub struct Supervisor {
1867 registry: Arc<Registry>,
1868 restart_policy: RestartPolicy,
1869 drain_timeout: Duration,
1870 connection_file_path: Option<PathBuf>,
1871 capture_logs_dir: Option<PathBuf>,
1872 forwarding: Option<Arc<ForwardingTable>>,
1873 process_liveness: Arc<SupervisorProcessLiveness>,
1874 supervisor_handle: Option<SupervisorHandle>,
1875 health: HealthConfig,
1876 daemon_start_clock: crate::clock::StartClock,
1877 terminal_journal: Option<Arc<crate::terminal_journal::TerminalJournal>>,
1878 spawn_events: SpawnEventFeed,
1879 provenance_probe: ExecutableIdentityProbe,
1880 child_roster: ChildRoster,
1883 #[cfg(target_os = "linux")]
1884 cgroup_placement: Option<subc_cgroup::Placement>,
1885}
1886
1887impl Supervisor {
1888 #[cfg(unix)]
1899 pub(crate) fn begin_daemon_shutdown(&self) {
1900 self.child_roster.close();
1901 if let Some(journal) = &self.terminal_journal {
1902 journal.stamp_shutdown();
1903 }
1904 }
1905
1906 #[cfg(unix)]
1910 pub(crate) async fn drain_for_daemon_shutdown(&self) -> Result<(), SuperviseError> {
1911 const NOTICE_BUDGET: Duration = Duration::from_millis(500);
1912 const DRAIN_BUDGET: Duration = Duration::from_secs(2);
1913 let Some(forwarding) = &self.forwarding else {
1914 return Ok(());
1915 };
1916 let module_ids = forwarding
1917 .begin_daemon_drain()
1918 .map_err(SuperviseError::Forwarding)?;
1919 let deadline_ms =
1920 unix_ms_now().saturating_add((NOTICE_BUDGET + DRAIN_BUDGET).as_millis() as u64);
1921 let mut notices = tokio::task::JoinSet::new();
1922 let mut drains = Vec::new();
1923 for module_id in module_ids {
1924 let Some(target) = forwarding
1925 .begin_module_drain(&module_id, RouteCloseReason::Restart)
1926 .map_err(SuperviseError::Forwarding)?
1927 else {
1928 continue;
1929 };
1930 let routes = forwarding
1931 .endpoint_routes(target.endpoint)
1932 .map_err(SuperviseError::Forwarding)?;
1933 let command = serde_json::to_vec(&ModuleControlCommand::Draining {
1939 reason: RouteCloseReason::Restart,
1940 deadline_ms,
1941 })
1942 .expect("module draining serializes");
1943 let closing = serde_json::to_vec(&ClientControlPush::RouteClosing {
1944 module_id: module_id.clone(),
1945 reason: RouteCloseReason::Restart,
1946 })
1947 .expect("route closing serializes");
1948 let mut recipients = vec![(target.sink.clone(), target.negotiated_ver, command)];
1949 let mut seen = std::collections::HashSet::new();
1950 for route in routes {
1951 let client = route.goodbye_target;
1952 if seen.insert(client.connection_id) {
1953 recipients.push((client.sink, client.negotiated_ver, closing.clone()));
1954 }
1955 }
1956 for (sink, version, body) in recipients {
1957 notices.spawn(async move {
1958 let frame = Frame::build_with_version(
1959 version,
1960 FrameType::Push,
1961 control_flags(),
1962 0,
1963 0,
1964 0,
1965 body,
1966 )
1967 .expect("bounded lifecycle notice frame builds");
1968 sink.send_flushed(frame).await
1969 });
1970 }
1971 let gauges = declared_busy_gauges(&self.registry, &module_id)?;
1972 drains.push((module_id, target.endpoint, gauges));
1973 }
1974 let notice_deadline = Instant::now() + NOTICE_BUDGET;
1977 while let Ok(Some(result)) = timeout_at(notice_deadline, notices.join_next()).await {
1978 if !matches!(result, Ok(Ok(()))) {
1979 warn!(?result, "daemon shutdown notice delivery failed");
1980 }
1981 }
1982 notices.abort_all();
1983 let deadline = Instant::now() + DRAIN_BUDGET;
1984 let mut waits = tokio::task::JoinSet::new();
1985 for (module_id, endpoint, gauges) in drains {
1986 let forwarding = Arc::clone(forwarding);
1987 let mut runtime = self.runtime_config();
1988 runtime.health.cadence = Duration::from_millis(100);
1989 waits.spawn(async move {
1990 wait_for_forwarding_quiescence(
1991 &forwarding,
1992 &module_id,
1993 &runtime,
1994 endpoint,
1995 deadline,
1996 &gauges,
1997 DrainScope::Active,
1998 )
1999 .await
2000 });
2001 }
2002 while let Ok(Some(result)) = timeout_at(deadline, waits.join_next()).await {
2003 if !matches!(result, Ok(Ok(true))) {
2004 warn!(?result, "daemon shutdown drain did not reach quiescence");
2005 }
2006 }
2007 Ok(())
2008 }
2009
2010 #[cfg(unix)]
2020 pub(crate) async fn end_children_for_daemon_shutdown(
2021 &self,
2022 already_escalated: bool,
2023 escalate: impl std::future::Future<Output = ()>,
2024 ) {
2025 if let Some(forwarding) = &self.forwarding {
2026 let closed = forwarding.close_all_connections(&CloseReason::new(
2027 "daemon_shutdown",
2028 "the daemon is exiting after its shutdown notice and drain",
2029 ));
2030 debug!(closed, "closed established connections for daemon shutdown");
2031 }
2032 crate::child_roster::end_children_for_daemon_shutdown(
2033 &self.child_roster,
2034 already_escalated,
2035 escalate,
2036 )
2037 .await;
2038 }
2039
2040 pub fn new(registry: Arc<Registry>, restart_policy: RestartPolicy) -> Self {
2041 Self {
2042 registry,
2043 restart_policy,
2044 drain_timeout: DEFAULT_DRAIN_TIMEOUT,
2045 connection_file_path: None,
2046 capture_logs_dir: None,
2047 forwarding: None,
2048 process_liveness: Arc::new(SupervisorProcessLiveness::default()),
2049 supervisor_handle: None,
2050 health: HealthConfig::default(),
2051 daemon_start_clock: crate::clock::StartClock::capture(),
2052 terminal_journal: None,
2053 spawn_events: SpawnEventFeed::default(),
2054 provenance_probe: ExecutableIdentityProbe::default(),
2055 child_roster: ChildRoster::default(),
2056 #[cfg(target_os = "linux")]
2057 cgroup_placement: None,
2058 }
2059 }
2060
2061 pub fn with_drain_timeout(mut self, drain_timeout: Duration) -> Self {
2062 self.drain_timeout = drain_timeout;
2063 self
2064 }
2065
2066 pub fn with_process_liveness(
2067 mut self,
2068 process_liveness: Arc<SupervisorProcessLiveness>,
2069 ) -> Self {
2070 self.process_liveness = process_liveness;
2071 self
2072 }
2073
2074 pub fn with_connection_file_path(mut self, connection_file_path: impl Into<PathBuf>) -> Self {
2075 self.connection_file_path = Some(connection_file_path.into());
2076 self
2077 }
2078
2079 pub fn with_capture_logs_dir(mut self, logs_dir: impl Into<PathBuf>) -> Self {
2081 self.capture_logs_dir = Some(logs_dir.into());
2082 self
2083 }
2084
2085 pub fn with_daemon_incarnation(self, daemon_incarnation: String) -> Self {
2088 self.spawn_events.configure_incarnation(daemon_incarnation);
2092 self
2093 }
2094
2095 pub fn with_terminal_journal(self, path: PathBuf, daemon_incarnation: String) -> Self {
2098 let mut this = self.with_daemon_incarnation(daemon_incarnation.clone());
2099 this.terminal_journal = Some(Arc::new(crate::terminal_journal::TerminalJournal::open(
2100 path,
2101 daemon_incarnation,
2102 )));
2103 this
2104 }
2105
2106 pub fn with_forwarding(mut self, forwarding: Arc<ForwardingTable>) -> Self {
2107 self.forwarding = Some(forwarding);
2108 self
2109 }
2110
2111 pub fn with_handle(mut self, supervisor_handle: SupervisorHandle) -> Self {
2112 self.spawn_events = supervisor_handle.spawn_events.clone();
2113 self.supervisor_handle = Some(supervisor_handle);
2114 self
2115 }
2116
2117 pub fn with_health_config(mut self, health: HealthConfig) -> Self {
2118 self.health = health;
2119 self
2120 }
2121
2122 #[cfg(target_os = "linux")]
2123 pub fn with_cgroup_placement(
2124 mut self,
2125 cgroup_placement: Option<subc_cgroup::Placement>,
2126 ) -> Self {
2127 self.cgroup_placement = cgroup_placement;
2128 self
2129 }
2130
2131 pub fn spawn(&self, spec: ModuleSpec) -> Result<SupervisedModule, SuperviseError> {
2137 validate_spec(&spec)?;
2138
2139 let runtime = self.runtime_config();
2140 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
2141 let child = spawn_child(
2142 &spec,
2143 runtime.connection_file_path.as_deref(),
2144 self.supervisor_handle.as_ref(),
2145 &runtime.stderr_ring,
2146 runtime.capture_logs_dir.as_deref(),
2147 &runtime.child_roster,
2148 #[cfg(target_os = "linux")]
2149 runtime.cgroup_placement.as_ref(),
2150 )?;
2151 set_running(&snapshot, &child, &spec.module_id, &runtime.spawn_events)?;
2152 self.process_liveness
2153 .track(spec.module_id.clone(), Arc::clone(&snapshot));
2154
2155 Ok(self.supervised_module(spec, runtime, snapshot, Some(child)))
2156 }
2157
2158 pub fn supervise_configured(
2164 &self,
2165 spec: ModuleSpec,
2166 enabled: bool,
2167 ) -> Result<SupervisedModule, SuperviseError> {
2168 validate_spec(&spec)?;
2169
2170 let runtime = self.runtime_config();
2171 if !enabled {
2172 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::disabled()));
2173 return Ok(self.supervised_module(spec, runtime, snapshot, None));
2174 }
2175
2176 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
2177 match spawn_child(
2178 &spec,
2179 runtime.connection_file_path.as_deref(),
2180 self.supervisor_handle.as_ref(),
2181 &runtime.stderr_ring,
2182 runtime.capture_logs_dir.as_deref(),
2183 &runtime.child_roster,
2184 #[cfg(target_os = "linux")]
2185 runtime.cgroup_placement.as_ref(),
2186 ) {
2187 Ok(child) => {
2188 set_running(&snapshot, &child, &spec.module_id, &runtime.spawn_events)?;
2189 self.process_liveness
2190 .track(spec.module_id.clone(), Arc::clone(&snapshot));
2191 Ok(self.supervised_module(spec, runtime, snapshot, Some(child)))
2192 }
2193 Err(err) => {
2194 error!(
2195 module_id = %spec.module_id,
2196 program = %spec.program.display(),
2197 error = %err,
2198 "configured module failed to spawn; marking failed and continuing"
2199 );
2200 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::failed()));
2201 Ok(self.supervised_module(spec, runtime, snapshot, None))
2202 }
2203 }
2204 }
2205
2206 pub fn supervise_configured_with_health(
2212 &self,
2213 spec: ModuleSpec,
2214 enabled: bool,
2215 health: HealthConfig,
2216 drain_timeout_ms: Option<u64>,
2217 restart_policy: RestartPolicy,
2218 ) -> Result<SupervisedModule, SuperviseError> {
2219 validate_spec(&spec)?;
2220
2221 let mut runtime = self.runtime_config();
2222 runtime.health = health;
2223 runtime.restart_policy = restart_policy;
2224 if let Some(ms) = drain_timeout_ms {
2225 runtime.drain_timeout = Duration::from_millis(ms);
2226 *runtime
2227 .effective_drain_timeout
2228 .lock()
2229 .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.drain_timeout;
2230 }
2231 if !enabled {
2232 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::disabled()));
2233 return Ok(self.supervised_module(spec, runtime, snapshot, None));
2234 }
2235
2236 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
2237 match spawn_child(
2238 &spec,
2239 runtime.connection_file_path.as_deref(),
2240 self.supervisor_handle.as_ref(),
2241 &runtime.stderr_ring,
2242 runtime.capture_logs_dir.as_deref(),
2243 &runtime.child_roster,
2244 #[cfg(target_os = "linux")]
2245 runtime.cgroup_placement.as_ref(),
2246 ) {
2247 Ok(child) => {
2248 set_running(&snapshot, &child, &spec.module_id, &runtime.spawn_events)?;
2249 self.process_liveness
2250 .track(spec.module_id.clone(), Arc::clone(&snapshot));
2251 Ok(self.supervised_module(spec, runtime, snapshot, Some(child)))
2252 }
2253 Err(err) => {
2254 if health.critical {
2255 error!(
2256 module_id = %spec.module_id,
2257 program = %spec.program.display(),
2258 error = %err,
2259 "critical configured module failed to spawn; marking failed and alerting"
2260 );
2261 } else {
2262 error!(
2263 module_id = %spec.module_id,
2264 program = %spec.program.display(),
2265 error = %err,
2266 "configured module failed to spawn; marking failed and continuing"
2267 );
2268 }
2269 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::failed()));
2270 Ok(self.supervised_module(spec, runtime, snapshot, None))
2271 }
2272 }
2273 }
2274
2275 fn runtime_config(&self) -> SupervisorRuntimeConfig {
2276 let effective_drain_timeout = Arc::new(Mutex::new(self.drain_timeout));
2277 SupervisorRuntimeConfig {
2278 restart_policy: self.restart_policy,
2279 drain_timeout: self.drain_timeout,
2280 child_roster: self
2283 .child_roster
2284 .for_module(Arc::clone(&effective_drain_timeout)),
2285 effective_drain_timeout,
2286 default_drain_timeout: self.drain_timeout,
2287 health: self.health,
2288 connection_file_path: self.connection_file_path.clone(),
2289 capture_logs_dir: self.capture_logs_dir.clone(),
2290 forwarding: self.forwarding.clone(),
2291 supervisor_handle: self.supervisor_handle.clone(),
2292 stderr_ring: Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default()))),
2293 terminal_ring: Arc::new(Mutex::new(
2294 TerminalRing::new(
2295 TerminalRingConfig::default(),
2296 self.daemon_start_clock.started_at_ms(),
2297 )
2298 .with_start_clock(self.daemon_start_clock)
2299 .with_journal(self.terminal_journal.clone())
2300 .with_daemon_shutdown(self.child_roster.shutdown_flag()),
2301 )),
2302 spawn_events: self.spawn_events.clone(),
2303 #[cfg(target_os = "linux")]
2304 cgroup_placement: self.cgroup_placement.clone(),
2305 #[cfg(test)]
2306 test_seed_stale_facts_before_enable_spawn: false,
2307 }
2308 }
2309
2310 fn supervised_module(
2311 &self,
2312 spec: ModuleSpec,
2313 runtime: SupervisorRuntimeConfig,
2314 snapshot: SharedSnapshot,
2315 child: Option<SupervisedChild>,
2316 ) -> SupervisedModule {
2317 let configuration = Arc::new(Mutex::new(SupervisedConfiguration {
2318 spec: spec.clone(),
2319 health: runtime.health,
2320 }));
2321 let stderr_ring = Arc::clone(&runtime.stderr_ring);
2322 let terminal_ring = Arc::clone(&runtime.terminal_ring);
2323 let restart_policy = runtime.restart_policy;
2327 let effective_drain_timeout = Arc::clone(&runtime.effective_drain_timeout);
2328 let (tx, rx) = mpsc::channel(4);
2329 let monitor = tokio::spawn(supervise_loop(
2330 spec.clone(),
2331 runtime,
2332 Arc::clone(&self.registry),
2333 Arc::clone(&self.process_liveness),
2334 Arc::clone(&snapshot),
2335 child,
2336 rx,
2337 ));
2338
2339 let module_id = spec.module_id.clone();
2340 let module = SupervisedModule {
2341 inner: Arc::new(SupervisedModuleInner {
2342 module_id: module_id.clone(),
2343 registry: Arc::clone(&self.registry),
2344 snapshot,
2345 configuration,
2346 stderr_ring,
2347 terminal_ring,
2348 commands: tx,
2349 monitor: Mutex::new(Some(monitor)),
2350 restart_policy,
2351 effective_drain_timeout,
2352 provenance_probe: self.provenance_probe.clone(),
2353 }),
2354 };
2355 if let Some(supervisor_handle) = &self.supervisor_handle {
2356 supervisor_handle.apply_identity_configuration(&spec);
2357 supervisor_handle.insert(module.clone());
2358 }
2359 module
2360 }
2361}
2362
2363impl Default for Supervisor {
2364 fn default() -> Self {
2365 Self::new(Arc::new(Registry::default()), RestartPolicy::default())
2366 }
2367}
2368
2369#[derive(Clone)]
2371pub struct SupervisedModule {
2372 inner: Arc<SupervisedModuleInner>,
2373}
2374
2375struct SupervisedModuleInner {
2376 module_id: String,
2377 registry: Arc<Registry>,
2378 snapshot: SharedSnapshot,
2379 configuration: Arc<Mutex<SupervisedConfiguration>>,
2380 stderr_ring: Arc<Mutex<StderrRing>>,
2381 terminal_ring: Arc<Mutex<TerminalRing>>,
2382 commands: mpsc::Sender<SupervisorCommand>,
2383 monitor: Mutex<Option<JoinHandle<()>>>,
2384 restart_policy: RestartPolicy,
2388 effective_drain_timeout: Arc<Mutex<Duration>>,
2389 provenance_probe: ExecutableIdentityProbe,
2390}
2391
2392impl fmt::Debug for SupervisedModule {
2393 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2394 f.debug_struct("SupervisedModule")
2395 .field("module_id", &self.inner.module_id)
2396 .field("status", &self.status())
2397 .finish_non_exhaustive()
2398 }
2399}
2400
2401impl SupervisedModule {
2402 pub fn module_id(&self) -> &str {
2403 &self.inner.module_id
2404 }
2405
2406 #[cfg(test)]
2410 pub(crate) fn record_health_probe_failure_for_test(
2411 &self,
2412 detail: &str,
2413 ) -> Result<(), SuperviseError> {
2414 update_snapshot(&self.inner.snapshot, Some(&self.inner.module_id), |state| {
2415 state.health.consecutive_failures = state.health.consecutive_failures.saturating_add(1);
2416 state.health.detail = Some(detail.to_string());
2417 })
2418 }
2419
2420 pub fn state(&self) -> Result<ModuleState, SuperviseError> {
2421 Ok(lock_snapshot(&self.inner.snapshot)?.state)
2422 }
2423
2424 pub fn stderr_tail(
2431 &self,
2432 max_lines: Option<usize>,
2433 max_bytes: Option<usize>,
2434 ) -> StderrTailSnapshot {
2435 self.inner
2436 .stderr_ring
2437 .lock()
2438 .unwrap_or_else(|poisoned| poisoned.into_inner())
2439 .snapshot(max_lines, max_bytes)
2440 }
2441
2442 pub fn terminal_history(&self) -> TerminalHistorySnapshot {
2447 self.inner
2448 .terminal_ring
2449 .lock()
2450 .unwrap_or_else(|poisoned| poisoned.into_inner())
2451 .snapshot()
2452 }
2453
2454 pub fn durable_terminal_history(&self) -> subc_control::TerminalHistory {
2459 durable_terminal_history_of(&self.inner.terminal_ring, &self.inner.module_id)
2460 }
2461
2462 pub(crate) async fn read_durable_terminal_history(
2467 &self,
2468 ) -> Result<subc_control::TerminalHistory, tokio::task::JoinError> {
2469 let terminal_ring = Arc::clone(&self.inner.terminal_ring);
2470 let module_id = self.inner.module_id.clone();
2471 tokio::task::spawn_blocking(move || durable_terminal_history_of(&terminal_ring, &module_id))
2472 .await
2473 }
2474
2475 pub fn status(&self) -> Result<ModuleStatus, SuperviseError> {
2476 self.status_with_snapshot_lock(&self.inner.snapshot, None)
2477 }
2478
2479 pub(crate) fn record_deliberate_severance(
2480 &self,
2481 identity: ProcessIdentity,
2482 ) -> Result<bool, SuperviseError> {
2483 let mut snapshot = lock_snapshot(&self.inner.snapshot)?;
2484 if snapshot.pid != Some(identity.pid)
2485 || snapshot.process_start_time != Some(identity.start_time)
2486 {
2487 return Ok(false);
2488 }
2489 snapshot.deliberate_severance = Some(identity);
2490 Ok(true)
2491 }
2492
2493 pub(crate) fn status_for_control(
2498 &self,
2499 caller: &'static str,
2500 ) -> Result<ModuleStatus, SuperviseError> {
2501 self.status_with_snapshot_lock(&self.inner.snapshot, Some(caller))
2502 }
2503
2504 fn status_with_snapshot_lock(
2505 &self,
2506 snapshot: &SharedSnapshot,
2507 caller: Option<&'static str>,
2508 ) -> Result<ModuleStatus, SuperviseError> {
2509 let mut guard = match caller {
2510 Some(caller) => lock_snapshot_for_control(snapshot, &self.inner.module_id, caller)?,
2511 None => lock_snapshot(snapshot)?,
2512 };
2513 let restart_count =
2516 guard.crash_restarts_in_window(self.inner.restart_policy.window, Instant::now());
2517 let snapshot = guard.clone();
2518 drop(guard);
2519 let drain_timeout = *self.inner.effective_drain_timeout.lock().map_err(|_| {
2520 SuperviseError::StatePoisoned {
2521 module_id: Some(self.inner.module_id.clone()),
2522 }
2523 })?;
2524 let registration_active = self
2525 .inner
2526 .registry
2527 .get_module(&self.inner.module_id)
2528 .map_err(SuperviseError::Registry)?
2529 .is_some();
2530 let protocol = self.declared_protocol()?;
2531 let running_process =
2532 snapshot.enabled && snapshot.state == ModuleState::Running && snapshot.process_alive;
2533 let live = match protocol {
2539 ModuleProtocol::Subc => running_process && registration_active,
2540 ModuleProtocol::None => running_process,
2541 };
2542
2543 Ok(ModuleStatus {
2544 module_id: self.inner.module_id.clone(),
2545 state: snapshot.state,
2546 enabled: snapshot.enabled,
2547 process_alive: snapshot.process_alive,
2548 registration_active,
2549 protocol,
2550 live,
2551 restart_count,
2552 lifetime_restarts: snapshot.lifetime_restarts,
2553 spawn_generation: snapshot.spawn_generation,
2554 max_restarts: self.inner.restart_policy.max_restarts,
2555 restart_window: self.inner.restart_policy.window,
2556 drain_timeout,
2557 restart_backoff: self.inner.restart_policy.backoff,
2558 restart_max_backoff: self.inner.restart_policy.max_backoff,
2559 pid: snapshot.pid,
2560 spawned_at_ms: snapshot.spawned_at_ms,
2561 spawned_from: snapshot.spawned_from,
2562 process_start_time: snapshot.process_start_time,
2563 last_exit: snapshot.last_exit,
2564 health: snapshot.health,
2565 })
2566 }
2567
2568 #[cfg(test)]
2569 pub(crate) fn hold_snapshot_for_test(
2570 &self,
2571 acquired: std::sync::mpsc::Sender<()>,
2572 hold: Duration,
2573 ) -> std::thread::JoinHandle<()> {
2574 let snapshot = Arc::clone(&self.inner.snapshot);
2575 std::thread::spawn(move || {
2576 let _guard = snapshot.lock().expect("test snapshot lock is not poisoned");
2577 acquired
2578 .send(())
2579 .expect("test receiver waits for snapshot lock");
2580 std::thread::sleep(hold);
2581 })
2582 }
2583
2584 pub(crate) async fn running_image_agreement(&self) -> subc_control::RunningImageAgreement {
2585 let snapshot = match lock_snapshot(&self.inner.snapshot) {
2586 Ok(snapshot) => snapshot.clone(),
2587 Err(_) => {
2588 return subc_control::RunningImageAgreement::Unavailable {
2589 reason: subc_control::RunningImageUnavailableReason::NotRunning,
2590 };
2591 }
2592 };
2593 self.inner
2594 .provenance_probe
2595 .observe(
2596 snapshot.pid,
2597 snapshot.spawned_from.as_deref(),
2598 snapshot.spawned_file_identity,
2599 snapshot.process_start_time,
2600 )
2601 .await
2602 }
2603
2604 pub(crate) fn will_recover_after_connection_loss(&self) -> Result<bool, SuperviseError> {
2605 let mut snapshot = lock_snapshot(&self.inner.snapshot)?;
2606 Ok(match snapshot.state {
2607 ModuleState::Restarting => true,
2608 ModuleState::Failed | ModuleState::Disabled => false,
2609 _ => daemon_will_restart(&mut snapshot, &self.inner.restart_policy, Instant::now()),
2610 })
2611 }
2612
2613 #[cfg(test)]
2614 pub(crate) fn is_warming(&self) -> Result<bool, SuperviseError> {
2615 self.is_warming_with_snapshot_lock(None)
2616 }
2617
2618 pub(crate) fn is_warming_for_control(
2619 &self,
2620 caller: &'static str,
2621 ) -> Result<bool, SuperviseError> {
2622 self.is_warming_with_snapshot_lock(Some(caller))
2623 }
2624
2625 fn is_warming_with_snapshot_lock(
2626 &self,
2627 caller: Option<&'static str>,
2628 ) -> Result<bool, SuperviseError> {
2629 let snapshot = match caller {
2630 Some(caller) => {
2631 lock_snapshot_for_control(&self.inner.snapshot, &self.inner.module_id, caller)?
2632 }
2633 None => lock_snapshot(&self.inner.snapshot)?,
2634 }
2635 .clone();
2636 Ok(matches!(
2637 snapshot.state,
2638 ModuleState::Starting | ModuleState::Running | ModuleState::Restarting
2639 ))
2640 }
2641
2642 pub async fn drain(&self) -> Result<(), SuperviseError> {
2644 self.stop().await
2645 }
2646
2647 pub(crate) async fn retire(&self) -> Result<(), SuperviseError> {
2648 match self.state()? {
2649 ModuleState::Stopped | ModuleState::Failed => return Ok(()),
2650 ModuleState::Starting
2651 | ModuleState::Running
2652 | ModuleState::Unresponsive
2653 | ModuleState::Restarting
2654 | ModuleState::Draining
2655 | ModuleState::Disabled => {}
2656 }
2657
2658 let (reply_tx, reply_rx) = oneshot::channel();
2659 self.inner
2660 .commands
2661 .send(SupervisorCommand::Retire { reply: reply_tx })
2662 .await
2663 .map_err(|_| SuperviseError::CommandClosed {
2664 module_id: self.inner.module_id.clone(),
2665 })?;
2666 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
2667 module_id: self.inner.module_id.clone(),
2668 })?
2669 }
2670
2671 pub async fn stop(&self) -> Result<(), SuperviseError> {
2672 match self.state()? {
2673 ModuleState::Stopped | ModuleState::Failed => return Ok(()),
2674 ModuleState::Starting
2675 | ModuleState::Running
2676 | ModuleState::Unresponsive
2677 | ModuleState::Restarting
2678 | ModuleState::Draining
2679 | ModuleState::Disabled => {}
2680 }
2681
2682 let (reply_tx, reply_rx) = oneshot::channel();
2683 self.inner
2684 .commands
2685 .send(SupervisorCommand::Drain { reply: reply_tx })
2686 .await
2687 .map_err(|_| SuperviseError::CommandClosed {
2688 module_id: self.inner.module_id.clone(),
2689 })?;
2690 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
2691 module_id: self.inner.module_id.clone(),
2692 })?
2693 }
2694
2695 pub async fn restart(&self, drain_timeout_ms: Option<u64>) -> Result<(), SuperviseError> {
2696 let (reply_tx, reply_rx) = oneshot::channel();
2697 self.inner
2698 .commands
2699 .send(SupervisorCommand::Restart {
2700 drain_timeout_ms,
2701 reply: reply_tx,
2702 })
2703 .await
2704 .map_err(|_| SuperviseError::CommandClosed {
2705 module_id: self.inner.module_id.clone(),
2706 })?;
2707 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
2708 module_id: self.inner.module_id.clone(),
2709 })?
2710 }
2711
2712 pub async fn swap(&self, ready_timeout: Option<Duration>) -> Result<(), SuperviseError> {
2717 let (reply_tx, reply_rx) = oneshot::channel();
2718 self.inner
2719 .commands
2720 .send(SupervisorCommand::Swap {
2721 ready_timeout,
2722 reply: reply_tx,
2723 })
2724 .await
2725 .map_err(|_| SuperviseError::CommandClosed {
2726 module_id: self.inner.module_id.clone(),
2727 })?;
2728 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
2729 module_id: self.inner.module_id.clone(),
2730 })?
2731 }
2732
2733 pub async fn reload(&self) -> Result<(), SuperviseError> {
2734 let (reply_tx, reply_rx) = oneshot::channel();
2735 self.inner
2736 .commands
2737 .send(SupervisorCommand::Reload { reply: reply_tx })
2738 .await
2739 .map_err(|_| SuperviseError::CommandClosed {
2740 module_id: self.inner.module_id.clone(),
2741 })?;
2742 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
2743 module_id: self.inner.module_id.clone(),
2744 })?
2745 }
2746
2747 pub async fn set_enabled(&self, enabled: bool) -> Result<bool, SuperviseError> {
2748 let (reply_tx, reply_rx) = oneshot::channel();
2749 self.inner
2750 .commands
2751 .send(SupervisorCommand::SetEnabled {
2752 enabled,
2753 reply: reply_tx,
2754 })
2755 .await
2756 .map_err(|_| SuperviseError::CommandClosed {
2757 module_id: self.inner.module_id.clone(),
2758 })?;
2759 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
2760 module_id: self.inner.module_id.clone(),
2761 })?
2762 }
2763
2764 pub(crate) fn declared_protocol(&self) -> Result<ModuleProtocol, SuperviseError> {
2769 Ok(self
2770 .inner
2771 .configuration
2772 .lock()
2773 .map_err(|_| SuperviseError::StatePoisoned {
2774 module_id: Some(self.inner.module_id.clone()),
2775 })?
2776 .spec
2777 .protocol)
2778 }
2779
2780 pub(crate) fn configuration(&self) -> Result<(ModuleSpec, HealthConfig), SuperviseError> {
2781 let configuration =
2782 self.inner
2783 .configuration
2784 .lock()
2785 .map_err(|_| SuperviseError::StatePoisoned {
2786 module_id: Some(self.inner.module_id.clone()),
2787 })?;
2788 Ok((configuration.spec.clone(), configuration.health))
2789 }
2790
2791 #[cfg(any(test, feature = "test-support"))]
2795 pub async fn update_spec_for_test(&self, spec: ModuleSpec) -> Result<(), SuperviseError> {
2796 let (_, health) = self.configuration()?;
2797 let drain_timeout_ms = u64::try_from(
2798 self.inner
2799 .effective_drain_timeout
2800 .lock()
2801 .unwrap_or_else(|poisoned| poisoned.into_inner())
2802 .as_millis(),
2803 )
2804 .ok();
2805 self.update_configuration(spec, health, drain_timeout_ms)
2806 .await
2807 }
2808
2809 pub(crate) async fn update_configuration(
2810 &self,
2811 spec: ModuleSpec,
2812 health: HealthConfig,
2813 drain_timeout_ms: Option<u64>,
2814 ) -> Result<(), SuperviseError> {
2815 if spec.module_id != self.inner.module_id {
2816 return Err(SuperviseError::InvalidSpec {
2817 reason: "a supervised module's module_id cannot be changed".to_string(),
2818 });
2819 }
2820 validate_spec(&spec)?;
2821 let (reply_tx, reply_rx) = oneshot::channel();
2822 self.inner
2823 .commands
2824 .send(SupervisorCommand::UpdateConfiguration {
2825 spec: spec.clone(),
2826 health,
2827 drain_timeout_ms,
2828 reply: reply_tx,
2829 })
2830 .await
2831 .map_err(|_| SuperviseError::CommandClosed {
2832 module_id: self.inner.module_id.clone(),
2833 })?;
2834 reply_rx.await.map_err(|_| SuperviseError::CommandClosed {
2835 module_id: self.inner.module_id.clone(),
2836 })?;
2837 let mut configuration =
2838 self.inner
2839 .configuration
2840 .lock()
2841 .map_err(|_| SuperviseError::StatePoisoned {
2842 module_id: Some(self.inner.module_id.clone()),
2843 })?;
2844 configuration.spec = spec;
2845 configuration.health = health;
2846 Ok(())
2847 }
2848}
2849
2850impl Drop for SupervisedModuleInner {
2851 fn drop(&mut self) {
2852 let Ok(mut monitor) = self.monitor.lock() else {
2853 return;
2854 };
2855 if let Some(monitor) = monitor.as_ref().filter(|monitor| !monitor.is_finished()) {
2856 let _ = update_snapshot(&self.snapshot, Some(&self.module_id), |state| {
2857 state.state = ModuleState::Stopped;
2858 clear_current_process_facts(state);
2859 });
2860 monitor.abort();
2861 }
2862 let _ = monitor.take();
2863 }
2864}
2865
2866#[derive(Debug)]
2867enum SupervisorCommand {
2868 Drain {
2869 reply: oneshot::Sender<Result<(), SuperviseError>>,
2870 },
2871 Retire {
2872 reply: oneshot::Sender<Result<(), SuperviseError>>,
2873 },
2874 Restart {
2875 drain_timeout_ms: Option<u64>,
2880 reply: oneshot::Sender<Result<(), SuperviseError>>,
2881 },
2882 Reload {
2883 reply: oneshot::Sender<Result<(), SuperviseError>>,
2884 },
2885 SetEnabled {
2886 enabled: bool,
2887 reply: oneshot::Sender<Result<bool, SuperviseError>>,
2888 },
2889 UpdateConfiguration {
2890 spec: ModuleSpec,
2891 health: HealthConfig,
2892 drain_timeout_ms: Option<u64>,
2895 reply: oneshot::Sender<()>,
2896 },
2897 Swap {
2898 ready_timeout: Option<Duration>,
2901 reply: oneshot::Sender<Result<(), SuperviseError>>,
2903 },
2904}
2905
2906#[derive(Debug)]
2907pub enum SuperviseError {
2908 InvalidSpec {
2909 reason: String,
2910 },
2911 Spawn {
2912 program: PathBuf,
2913 source: io::Error,
2914 cgroup_path: Option<PathBuf>,
2915 },
2916 Cgroup {
2917 module_id: String,
2918 source: io::Error,
2919 },
2920 LaunchNonce {
2923 reason: String,
2924 },
2925 Wait {
2926 module_id: String,
2927 source: io::Error,
2928 },
2929 Kill {
2930 module_id: String,
2931 source: io::Error,
2932 },
2933 Forwarding(ForwardingError),
2934 Registry(RegistryError),
2935 ReloadUnavailable {
2936 module_id: String,
2937 reason: String,
2938 },
2939 Disabled {
2944 module_id: String,
2945 },
2946 ReloadFailed {
2947 module_id: String,
2948 reason: String,
2949 },
2950 RegistrationStillActive {
2951 module_id: String,
2952 waited: Duration,
2953 },
2954 StatePoisoned {
2955 module_id: Option<String>,
2956 },
2957 CommandClosed {
2958 module_id: String,
2959 },
2960 SwapInProgress {
2964 module_id: String,
2965 },
2966 SwapRefused {
2968 module_id: String,
2969 reason: SwapRefusal,
2970 },
2971 SwapFailed {
2975 module_id: String,
2976 arm: SwapFailureArm,
2977 detail: String,
2978 candidate_exit: Option<ExitReport>,
2981 },
2982}
2983
2984#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2986pub enum SwapRefusal {
2987 OverlapExclusive,
2989 NotRegistered,
2992 ProtocolNone,
2995 NotConfigured,
2998 AlreadySwapping,
3000}
3001
3002impl SwapRefusal {
3003 pub fn as_str(self) -> &'static str {
3004 match self {
3005 Self::OverlapExclusive => "overlap_exclusive",
3006 Self::NotRegistered => "not_registered",
3007 Self::ProtocolNone => "protocol_none",
3008 Self::NotConfigured => "not_configured",
3009 Self::AlreadySwapping => "already_swapping",
3010 }
3011 }
3012}
3013
3014#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3017pub enum SwapFailureArm {
3018 SpawnFailed,
3020 NeverRegistered,
3022 NeverReady,
3024 CandidateExited,
3026 CandidateUnhealthy,
3028 Interrupted,
3032 CutoverLost,
3037}
3038
3039impl SwapFailureArm {
3040 pub fn as_str(self) -> &'static str {
3041 match self {
3042 Self::SpawnFailed => "spawn_failed",
3043 Self::NeverRegistered => "never_registered",
3044 Self::NeverReady => "never_ready",
3045 Self::CandidateExited => "candidate_exited",
3046 Self::CandidateUnhealthy => "candidate_unhealthy",
3047 Self::Interrupted => "interrupted",
3048 Self::CutoverLost => "cutover_lost",
3049 }
3050 }
3051}
3052
3053impl fmt::Display for SuperviseError {
3054 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3055 match self {
3056 Self::InvalidSpec { reason } => write!(f, "invalid module spec: {reason}"),
3057 Self::Spawn {
3058 program,
3059 source,
3060 cgroup_path: Some(cgroup_path),
3061 } => write!(
3062 f,
3063 "failed to place module in cgroup '{}' while spawning '{}': {source}",
3064 cgroup_path.display(),
3065 program.display()
3066 ),
3067 Self::Spawn {
3068 program,
3069 source,
3070 cgroup_path: None,
3071 } => write!(
3072 f,
3073 "failed to spawn module '{}': {source}",
3074 program.display()
3075 ),
3076 Self::Cgroup { module_id, source } => {
3077 write!(
3078 f,
3079 "failed to prepare cgroup for module '{module_id}': {source}"
3080 )
3081 }
3082 Self::LaunchNonce { reason } => {
3083 write!(
3084 f,
3085 "failed to generate reserved-module launch nonce: {reason}"
3086 )
3087 }
3088 Self::Wait { module_id, source } => {
3089 write!(f, "failed to wait for module '{module_id}': {source}")
3090 }
3091 Self::Kill { module_id, source } => {
3092 write!(f, "failed to kill module '{module_id}': {source}")
3093 }
3094 Self::Forwarding(err) => write!(f, "forwarding error: {err}"),
3095 Self::Registry(err) => write!(f, "registry error: {err}"),
3096 Self::ReloadUnavailable { module_id, reason } => {
3097 write!(f, "reload unavailable for module '{module_id}': {reason}")
3098 }
3099 Self::Disabled { module_id } => {
3100 write!(
3101 f,
3102 "module '{module_id}' is disabled; enable it before restart or reload"
3103 )
3104 }
3105 Self::ReloadFailed { module_id, reason } => {
3106 write!(f, "reload failed for module '{module_id}': {reason}")
3107 }
3108 Self::RegistrationStillActive { module_id, waited } => write!(
3109 f,
3110 "module '{module_id}' registration remained active after waiting {waited:?}"
3111 ),
3112 Self::StatePoisoned { module_id } => match module_id {
3113 Some(module_id) => {
3114 write!(f, "supervisor state for module '{module_id}' was poisoned")
3115 }
3116 None => write!(f, "supervisor state was poisoned"),
3117 },
3118 Self::CommandClosed { module_id } => {
3119 write!(
3120 f,
3121 "supervisor command channel for module '{module_id}' is closed"
3122 )
3123 }
3124 Self::SwapInProgress { module_id } => write!(
3125 f,
3126 "module '{module_id}' is being swapped; retry once the swap has cut over or failed, or stop the module to abort the swap"
3127 ),
3128 Self::SwapRefused { module_id, reason } => match reason {
3129 SwapRefusal::OverlapExclusive => write!(
3130 f,
3131 "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"
3132 ),
3133 SwapRefusal::NotRegistered => write!(
3134 f,
3135 "module '{module_id}' is not registered, so there is no serving process to keep while a replacement warms; use a plain restart"
3136 ),
3137 SwapRefusal::ProtocolNone => write!(
3138 f,
3139 "module '{module_id}' is protocol: \"none\" and never registers, so a swap could never see its replacement become ready; use a plain restart"
3140 ),
3141 SwapRefusal::NotConfigured => write!(
3142 f,
3143 "module '{module_id}' cannot be swapped: the supervisor was built without the forwarding table or shared handle a swap needs"
3144 ),
3145 SwapRefusal::AlreadySwapping => {
3146 write!(f, "module '{module_id}' is already being swapped")
3147 }
3148 },
3149 Self::SwapFailed {
3150 module_id,
3151 arm,
3152 detail,
3153 ..
3154 } => write!(
3155 f,
3156 "swap of module '{module_id}' failed ({}): {detail}; the running process was left serving",
3157 arm.as_str()
3158 ),
3159 }
3160 }
3161}
3162
3163impl Error for SuperviseError {
3164 fn source(&self) -> Option<&(dyn Error + 'static)> {
3165 match self {
3166 Self::Spawn { source, .. }
3167 | Self::Cgroup { source, .. }
3168 | Self::Wait { source, .. }
3169 | Self::Kill { source, .. } => Some(source),
3170 Self::Forwarding(err) => Some(err),
3171 Self::Registry(err) => Some(err),
3172 Self::LaunchNonce { .. }
3173 | Self::InvalidSpec { .. }
3174 | Self::ReloadUnavailable { .. }
3175 | Self::Disabled { .. }
3176 | Self::ReloadFailed { .. }
3177 | Self::RegistrationStillActive { .. }
3178 | Self::StatePoisoned { .. }
3179 | Self::CommandClosed { .. }
3180 | Self::SwapInProgress { .. }
3181 | Self::SwapRefused { .. }
3182 | Self::SwapFailed { .. } => None,
3183 }
3184 }
3185}
3186
3187pub(crate) fn validate_spec(spec: &ModuleSpec) -> Result<(), SuperviseError> {
3188 if spec.module_id.trim().is_empty() {
3189 return Err(SuperviseError::InvalidSpec {
3190 reason: "module_id must not be empty".to_string(),
3191 });
3192 }
3193
3194 Ok(())
3195}
3196
3197#[derive(Debug, Default)]
3198struct HealthProbeRuntime {
3199 registered_connection: Option<crate::ConnectionId>,
3200 advertised: bool,
3201 next_probe_at: Option<Instant>,
3202 probe_index: u64,
3203}
3204
3205impl HealthProbeRuntime {
3206 fn refresh_registration(
3207 &mut self,
3208 spec: &ModuleSpec,
3209 runtime: &SupervisorRuntimeConfig,
3210 registry: &Registry,
3211 snapshot: &SharedSnapshot,
3212 ) {
3213 if spec.protocol == ModuleProtocol::None {
3225 self.registered_connection = None;
3226 self.advertised = false;
3227 self.next_probe_at = None;
3228 return;
3229 }
3230
3231 let registration = match registry.get_module(&spec.module_id) {
3232 Ok(registration) => registration,
3233 Err(err) => {
3234 warn!(module_id = %spec.module_id, error = %err, "health prober could not read registry");
3235 self.advertised = false;
3236 self.next_probe_at = None;
3237 return;
3238 }
3239 };
3240
3241 let Some(registration) = registration else {
3242 self.registered_connection = None;
3243 self.advertised = false;
3244 self.next_probe_at = None;
3245 return;
3246 };
3247
3248 let advertised = registration
3249 .control_ops
3250 .iter()
3251 .any(|op| op == MODULE_CONTROL_OP_HEALTH_CHECK);
3252 if !advertised {
3253 self.registered_connection = Some(registration.connection_id);
3254 self.advertised = false;
3255 self.next_probe_at = None;
3256 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
3257 state.health.status = SupervisorHealthStatus::Unknown;
3258 state.health.consecutive_failures = 0;
3259 state.health.last_probe_ms = None;
3260 state.health.detail = None;
3261 state.health.metrics = None;
3262 });
3263 return;
3264 }
3265
3266 let reregistered = self.registered_connection != Some(registration.connection_id);
3267 self.registered_connection = Some(registration.connection_id);
3268 self.advertised = true;
3269 if reregistered || self.next_probe_at.is_none() {
3270 self.probe_index = 0;
3271 self.next_probe_at = Some(
3272 Instant::now() + jittered_health_delay(&spec.module_id, 0, runtime.health.cadence),
3273 );
3274 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
3275 state.health.status = SupervisorHealthStatus::Unknown;
3276 state.health.consecutive_failures = 0;
3277 state.health.detail = None;
3278 state.health.metrics = None;
3279 });
3280 }
3281 }
3282
3283 fn wake_after(&self) -> Duration {
3284 if !self.advertised {
3285 return REGISTRY_RELEASE_POLL;
3286 }
3287 self.next_probe_at
3288 .map(|next| next.saturating_duration_since(Instant::now()))
3289 .unwrap_or(REGISTRY_RELEASE_POLL)
3290 }
3291
3292 fn due(&self) -> bool {
3293 self.advertised
3294 && self
3295 .next_probe_at
3296 .is_some_and(|next| Instant::now() >= next)
3297 }
3298
3299 fn schedule_next(&mut self, spec: &ModuleSpec, cadence: Duration) {
3300 self.probe_index = self.probe_index.wrapping_add(1);
3301 self.next_probe_at = Some(
3302 Instant::now() + jittered_health_delay(&spec.module_id, self.probe_index, cadence),
3303 );
3304 }
3305}
3306
3307#[derive(Debug)]
3342enum HealthProbeEvidence {
3343 LaneDead,
3345 NoAnswer,
3347 BadAnswer,
3349 Misconfigured,
3351}
3352
3353#[derive(Debug)]
3354struct HealthProbeError {
3355 evidence: HealthProbeEvidence,
3356 message: String,
3357}
3358
3359impl HealthProbeError {
3360 fn lane_dead(message: impl Into<String>) -> Self {
3361 Self::with(HealthProbeEvidence::LaneDead, message)
3362 }
3363
3364 fn no_answer(message: impl Into<String>) -> Self {
3365 Self::with(HealthProbeEvidence::NoAnswer, message)
3366 }
3367
3368 fn bad_answer(message: impl Into<String>) -> Self {
3369 Self::with(HealthProbeEvidence::BadAnswer, message)
3370 }
3371
3372 fn misconfigured(message: impl Into<String>) -> Self {
3373 Self::with(HealthProbeEvidence::Misconfigured, message)
3374 }
3375
3376 fn with(evidence: HealthProbeEvidence, message: impl Into<String>) -> Self {
3377 Self {
3378 evidence,
3379 message: message.into(),
3380 }
3381 }
3382
3383 #[allow(dead_code)]
3397 fn is_proof_of_death(&self) -> bool {
3398 matches!(self.evidence, HealthProbeEvidence::LaneDead)
3399 }
3400
3401 fn label(&self) -> &'static str {
3409 match self.evidence {
3410 HealthProbeEvidence::LaneDead => "lane-dead",
3411 HealthProbeEvidence::NoAnswer => "no-answer",
3412 HealthProbeEvidence::BadAnswer => "bad-answer",
3413 HealthProbeEvidence::Misconfigured => "daemon-misconfigured",
3414 }
3415 }
3416}
3417
3418impl fmt::Display for HealthProbeError {
3419 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3420 f.write_str(&self.message)
3421 }
3422}
3423
3424async fn run_health_probe_cycle(
3425 spec: &ModuleSpec,
3426 runtime: &SupervisorRuntimeConfig,
3427 registry: &Registry,
3428 process_liveness: &SupervisorProcessLiveness,
3429 snapshot: &SharedSnapshot,
3430 child: &mut Option<SupervisedChild>,
3431) {
3432 let now_ms = unix_ms_now();
3433 match probe_module_health(&spec.module_id, runtime, None).await {
3434 Ok(report) => {
3435 handle_health_report(
3436 spec,
3437 runtime,
3438 registry,
3439 process_liveness,
3440 snapshot,
3441 child,
3442 report,
3443 now_ms,
3444 )
3445 .await;
3446 }
3447 Err(err) => {
3448 handle_health_probe_failure(
3449 spec,
3450 runtime,
3451 registry,
3452 process_liveness,
3453 snapshot,
3454 child,
3455 err,
3456 now_ms,
3457 )
3458 .await;
3459 }
3460 }
3461}
3462
3463async fn probe_module_health(
3464 module_id: &str,
3465 runtime: &SupervisorRuntimeConfig,
3466 drain_deadline: Option<Instant>,
3467) -> Result<HealthReport, HealthProbeError> {
3468 let Some(forwarding) = runtime.forwarding.as_ref() else {
3469 return Err(HealthProbeError::misconfigured(
3470 "supervisor was not configured with a forwarding table",
3471 ));
3472 };
3473 let probe_started_at = Instant::now();
3474 let mut deadline = probe_started_at + runtime.health.deadline;
3475 if let Some(drain_deadline) = drain_deadline {
3476 deadline = deadline.min(drain_deadline);
3477 }
3478 let pending = if drain_deadline.is_some() {
3479 forwarding.begin_drain_health_probe_rpc_for(
3480 module_id,
3481 MODULE_CONTROL_OP_HEALTH_CHECK,
3482 probe_started_at,
3483 deadline,
3484 )
3485 } else {
3486 forwarding.begin_health_probe_rpc_for(
3487 module_id,
3488 MODULE_CONTROL_OP_HEALTH_CHECK,
3489 probe_started_at,
3490 deadline,
3491 )
3492 }
3493 .map_err(|err| {
3494 HealthProbeError::lane_dead(format!("failed to begin health.check RPC: {err}"))
3497 })?;
3498 await_health_probe(forwarding, pending, deadline, runtime.health.deadline).await
3499}
3500
3501async fn probe_endpoint_health(
3508 endpoint: crate::ModuleEndpointId,
3509 runtime: &SupervisorRuntimeConfig,
3510 deadline_cap: Option<Instant>,
3511) -> Result<HealthReport, HealthProbeError> {
3512 let Some(forwarding) = runtime.forwarding.as_ref() else {
3513 return Err(HealthProbeError::misconfigured(
3514 "supervisor was not configured with a forwarding table",
3515 ));
3516 };
3517 let probe_started_at = Instant::now();
3518 let mut deadline = probe_started_at + runtime.health.deadline;
3519 if let Some(cap) = deadline_cap {
3520 deadline = deadline.min(cap);
3521 }
3522 let pending = forwarding
3523 .begin_endpoint_health_probe_rpc_for(
3524 endpoint,
3525 MODULE_CONTROL_OP_HEALTH_CHECK,
3526 probe_started_at,
3527 deadline,
3528 )
3529 .map_err(|err| {
3530 HealthProbeError::lane_dead(format!("failed to begin health.check RPC: {err}"))
3531 })?;
3532 await_health_probe(forwarding, pending, deadline, runtime.health.deadline).await
3533}
3534
3535async fn await_health_probe(
3537 forwarding: &ForwardingTable,
3538 pending: PendingModuleControlRpc,
3539 deadline: Instant,
3540 probe_budget: Duration,
3541) -> Result<HealthReport, HealthProbeError> {
3542 let PendingModuleControlRpc {
3543 endpoint,
3544 module_sink,
3545 negotiated_ver,
3546 corr,
3547 receiver,
3548 } = pending;
3549 let body = serde_json::to_vec(&ModuleControlRequest::HealthCheck {}).map_err(|err| {
3550 HealthProbeError::misconfigured(format!("failed to encode health.check: {err}"))
3551 })?;
3552 let frame = Frame::build_with_version(
3553 negotiated_ver,
3554 FrameType::Request,
3555 control_flags(),
3556 0,
3557 0,
3558 corr,
3559 body,
3560 )
3561 .map_err(|err| {
3562 HealthProbeError::misconfigured(format!("failed to build health.check frame: {err}"))
3563 })?;
3564
3565 match timeout_at(deadline, module_sink.send(frame)).await {
3571 Ok(Ok(())) => {}
3572 Ok(Err(err)) => {
3573 let _ = forwarding.cancel_module_control_rpc(endpoint, corr);
3574 return Err(HealthProbeError::lane_dead(format!(
3577 "failed to send health.check: {err}"
3578 )));
3579 }
3580 Err(_elapsed) => {
3581 let _ = forwarding.cancel_module_control_rpc(endpoint, corr);
3582 return Err(HealthProbeError::no_answer(
3586 "health.check send timed out before enqueue (module egress full)",
3587 ));
3588 }
3589 }
3590
3591 match timeout_at(deadline, receiver).await {
3592 Ok(Ok(ModuleControlRpcOutcome::Response(response))) => {
3596 response.health_report().ok_or_else(|| {
3597 HealthProbeError::bad_answer("health.check RPC returned a non-health response")
3598 })
3599 }
3600 Ok(Ok(ModuleControlRpcOutcome::Rejected(body))) => Err(HealthProbeError::bad_answer(
3601 format!("health.check rejected: {}", body.message),
3602 )),
3603 Ok(Ok(ModuleControlRpcOutcome::ModuleGone(message))) => {
3604 Err(HealthProbeError::lane_dead(message))
3605 }
3606 Ok(Ok(ModuleControlRpcOutcome::MalformedResponse(message))) => {
3607 Err(HealthProbeError::bad_answer(message))
3608 }
3609 Ok(Ok(ModuleControlRpcOutcome::UnexpectedOp { expected, actual })) => {
3610 Err(HealthProbeError::bad_answer(format!(
3611 "expected module-control op '{expected}', got '{actual}'"
3612 )))
3613 }
3614 Ok(Ok(ModuleControlRpcOutcome::DeadlineElapsed)) => Err(HealthProbeError::bad_answer(
3618 "module answered health.check after its daemon deadline",
3619 )),
3620 Ok(Err(_)) => Err(HealthProbeError::misconfigured(
3621 "health.check waiter was canceled before the module responded",
3622 )),
3623 Err(_) => {
3624 let _ = forwarding.tombstone_health_probe_rpc(endpoint, corr);
3625 Err(HealthProbeError::no_answer(format!(
3626 "module did not answer health.check within {probe_budget:?}"
3627 )))
3628 }
3629 }
3630}
3631
3632#[allow(clippy::too_many_arguments)]
3633async fn handle_health_report(
3634 spec: &ModuleSpec,
3635 runtime: &SupervisorRuntimeConfig,
3636 registry: &Registry,
3637 process_liveness: &SupervisorProcessLiveness,
3638 snapshot: &SharedSnapshot,
3639 child: &mut Option<SupervisedChild>,
3640 report: HealthReport,
3641 now_ms: u64,
3642) {
3643 let status = supervisor_health_status(report.status);
3644 let detail = report.detail.clone();
3645 let metrics = truncate_health_metrics(report.metrics);
3646 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
3647 state.health.status = status;
3648 state.health.last_probe_ms = Some(now_ms);
3649 state.health.detail = detail.clone();
3650 state.health.metrics = metrics.clone();
3651 state.health.consecutive_failures = 0;
3652 });
3653
3654 let action = match report.status {
3655 HealthStatus::Ok => return,
3656 HealthStatus::Degraded => runtime.health.on_degraded,
3657 HealthStatus::Failing => runtime.health.on_failing,
3658 };
3659 apply_l3_health_action(
3660 spec,
3661 runtime,
3662 registry,
3663 process_liveness,
3664 snapshot,
3665 child,
3666 status,
3667 detail.as_deref(),
3668 action,
3669 now_ms,
3670 )
3671 .await;
3672}
3673
3674#[allow(clippy::too_many_arguments)]
3675async fn handle_health_probe_failure(
3676 spec: &ModuleSpec,
3677 runtime: &SupervisorRuntimeConfig,
3678 registry: &Registry,
3679 process_liveness: &SupervisorProcessLiveness,
3680 snapshot: &SharedSnapshot,
3681 child: &mut Option<SupervisedChild>,
3682 err: HealthProbeError,
3683 now_ms: u64,
3684) {
3685 let threshold = runtime.health.failure_threshold.max(1);
3686 let mut failures = 0;
3687 let detail = format!("[{}] {err}", err.label());
3692 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
3693 state.health.last_probe_ms = Some(now_ms);
3694 state.health.consecutive_failures = state.health.consecutive_failures.saturating_add(1);
3695 state.health.detail = Some(detail.clone());
3696 state.health.metrics = None;
3697 failures = state.health.consecutive_failures;
3698 });
3699
3700 if failures < threshold {
3701 warn!(
3702 module_id = %spec.module_id,
3703 consecutive_failures = failures,
3704 threshold,
3705 evidence = err.label(),
3706 detail = %detail,
3707 "health.check probe failed"
3708 );
3709 return;
3710 }
3711
3712 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
3713 state.state = ModuleState::Unresponsive;
3714 state.health.status = SupervisorHealthStatus::Unresponsive;
3715 });
3716 if runtime.health.critical {
3720 error!(
3721 module_id = %spec.module_id,
3722 status = "unresponsive",
3723 evidence = err.label(),
3724 detail = %detail,
3725 "critical module health alert"
3726 );
3727 } else {
3728 warn!(
3729 module_id = %spec.module_id,
3730 status = "unresponsive",
3731 evidence = err.label(),
3732 detail = %detail,
3733 "module health threshold breached"
3734 );
3735 }
3736 if let Err(err) = health_restart_child(
3737 spec,
3738 runtime,
3739 registry,
3740 process_liveness,
3741 snapshot,
3742 child,
3743 SupervisorHealthStatus::Unresponsive,
3744 Some(&detail),
3745 now_ms,
3746 )
3747 .await
3748 {
3749 error!(module_id = %spec.module_id, error = %err, "health-triggered restart failed");
3750 }
3751}
3752
3753#[allow(clippy::too_many_arguments)]
3754async fn apply_l3_health_action(
3755 spec: &ModuleSpec,
3756 runtime: &SupervisorRuntimeConfig,
3757 registry: &Registry,
3758 process_liveness: &SupervisorProcessLiveness,
3759 snapshot: &SharedSnapshot,
3760 child: &mut Option<SupervisedChild>,
3761 status: SupervisorHealthStatus,
3762 detail: Option<&str>,
3763 action: HealthAction,
3764 now_ms: u64,
3765) {
3766 record_health_action(snapshot, &spec.module_id, action.to_string(), now_ms);
3767 match action {
3768 HealthAction::Report => {
3769 info!(
3770 module_id = %spec.module_id,
3771 status = ?status,
3772 detail,
3773 "module reported non-ok health"
3774 );
3775 }
3776 HealthAction::Alert => {
3777 error!(
3778 module_id = %spec.module_id,
3779 status = ?status,
3780 detail,
3781 "module health alert"
3782 );
3783 }
3784 HealthAction::Restart => {
3785 if let Err(err) = health_restart_child(
3786 spec,
3787 runtime,
3788 registry,
3789 process_liveness,
3790 snapshot,
3791 child,
3792 status,
3793 detail,
3794 now_ms,
3795 )
3796 .await
3797 {
3798 error!(module_id = %spec.module_id, error = %err, "health-triggered restart failed");
3799 }
3800 }
3801 }
3802}
3803
3804#[allow(clippy::too_many_arguments)]
3805async fn health_restart_child(
3806 spec: &ModuleSpec,
3807 runtime: &SupervisorRuntimeConfig,
3808 registry: &Registry,
3809 process_liveness: &SupervisorProcessLiveness,
3810 snapshot: &SharedSnapshot,
3811 child: &mut Option<SupervisedChild>,
3812 status: SupervisorHealthStatus,
3813 detail: Option<&str>,
3814 now_ms: u64,
3815) -> Result<(), SuperviseError> {
3816 let (enabled, schedule) = {
3817 let mut state = lock_snapshot(snapshot)?;
3818 let enabled = state.enabled;
3819 let schedule = if enabled {
3820 state.next_crash_restart(&runtime.restart_policy, Instant::now())
3821 } else {
3822 None
3823 };
3824 (enabled, schedule)
3825 };
3826
3827 if !enabled {
3828 return Err(SuperviseError::Disabled {
3829 module_id: spec.module_id.clone(),
3830 });
3831 }
3832
3833 if schedule.is_none() {
3834 record_health_action(snapshot, &spec.module_id, "disabled".to_string(), now_ms);
3835 error!(
3836 module_id = %spec.module_id,
3837 status = ?status,
3838 detail,
3839 max_restarts = runtime.restart_policy.max_restarts,
3840 window_secs = runtime.restart_policy.window.as_secs(),
3841 "health restart budget exhausted; disabling module"
3842 );
3843 begin_forwarding_drain_if_configured(
3844 spec,
3845 runtime,
3846 registry,
3847 snapshot,
3848 Some(false),
3849 RouteCloseReason::Disable,
3850 )
3851 .await?;
3852 drain_optional_child(
3853 &spec.module_id,
3854 spec.protocol,
3855 registry,
3856 snapshot,
3857 &runtime.terminal_ring,
3858 &runtime.spawn_events,
3859 child,
3860 runtime.drain_timeout,
3861 ModuleState::Disabled,
3862 Some(false),
3863 )
3864 .await?;
3865 process_liveness.untrack_if_current(&spec.module_id, snapshot);
3866 return Ok(());
3867 }
3868
3869 let schedule = schedule.expect("a health restart must have a crash-restart schedule");
3870 let mut restart_count = 0;
3871 update_snapshot(snapshot, Some(&spec.module_id), |state| {
3872 restart_count = state.crash_restarts.len();
3873 state.state = ModuleState::Unresponsive;
3874 state.health.status = status;
3875 state.health.last_action = Some(HealthAction::Restart.to_string());
3876 state.health.last_action_ms = Some(now_ms);
3877 })?;
3878 warn!(
3879 module_id = %spec.module_id,
3880 status = ?status,
3881 detail,
3882 restart_count,
3883 restart_in_window = schedule.restart_in_window,
3884 delay_ms = schedule.delay.as_millis() as u64,
3885 "health-triggered module restart"
3886 );
3887
3888 begin_forwarding_drain_if_configured(
3889 spec,
3890 runtime,
3891 registry,
3892 snapshot,
3893 Some(true),
3894 RouteCloseReason::Restart,
3895 )
3896 .await?;
3897 drain_optional_child(
3898 &spec.module_id,
3899 spec.protocol,
3900 registry,
3901 snapshot,
3902 &runtime.terminal_ring,
3903 &runtime.spawn_events,
3904 child,
3905 runtime.drain_timeout,
3906 ModuleState::Restarting,
3907 Some(true),
3908 )
3909 .await?;
3910 sleep(schedule.delay).await;
3911 if !respawn_still_pending(snapshot) {
3915 process_liveness.untrack_if_current(&spec.module_id, snapshot);
3916 return Ok(());
3917 }
3918 process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
3919 match spawn_and_mark_running(spec, runtime, snapshot) {
3920 Ok(next_child) => {
3921 *child = Some(next_child);
3922 Ok(())
3923 }
3924 Err(err) => {
3925 fail_snapshot(snapshot, Some(&spec.module_id), None);
3926 process_liveness.untrack_if_current(&spec.module_id, snapshot);
3927 *child = None;
3928 Err(err)
3929 }
3930 }
3931}
3932
3933fn record_health_action(snapshot: &SharedSnapshot, module_id: &str, action: String, now_ms: u64) {
3934 let _ = update_snapshot(snapshot, Some(module_id), |state| {
3935 state.health.last_action = Some(action);
3936 state.health.last_action_ms = Some(now_ms);
3937 });
3938}
3939
3940fn supervisor_health_status(status: HealthStatus) -> SupervisorHealthStatus {
3941 match status {
3942 HealthStatus::Ok => SupervisorHealthStatus::Ok,
3943 HealthStatus::Degraded => SupervisorHealthStatus::Degraded,
3944 HealthStatus::Failing => SupervisorHealthStatus::Failing,
3945 }
3946}
3947
3948fn truncate_health_metrics(metrics: Option<Value>) -> Option<Value> {
3960 let metrics = metrics?;
3961 match serde_json::to_vec(&metrics) {
3962 Ok(encoded) if encoded.len() > MAX_HEALTH_METRICS_BYTES => Some(serde_json::json!({
3963 "truncated": true,
3964 "original_bytes": encoded.len(),
3965 })),
3966 Ok(_) | Err(_) => Some(metrics),
3967 }
3968}
3969
3970fn jittered_health_delay(module_id: &str, probe_index: u64, cadence: Duration) -> Duration {
3976 if cadence.is_zero() {
3977 return Duration::ZERO;
3978 }
3979 let cadence_ms = cadence.as_millis() as u64;
3980 if cadence_ms == 0 {
3996 return cadence;
3997 }
3998 let jitter_span = (cadence_ms / 10).max(1);
4013 let hash = module_id.as_bytes().iter().fold(
4014 probe_index.wrapping_mul(0x9E37_79B9_7F4A_7C15),
4015 |acc, byte| {
4016 acc.wrapping_mul(1099511628211)
4017 .wrapping_add(u64::from(*byte))
4018 },
4019 );
4020 cadence + Duration::from_millis(hash % jitter_span)
4021}
4022
4023#[cfg(test)]
4024mod tests {
4025 use super::*;
4026
4027 #[test]
4028 fn readding_a_module_clears_its_rescan_removal_tombstone() {
4029 let handle = SupervisorHandle::new();
4030 let module_id = "readded-tombstone";
4031 handle.record_rescan_removal(module_id);
4032 assert!(handle.removal_tombstone_age_ms(module_id).is_some());
4033
4034 handle.apply_identity_configuration(&ModuleSpec {
4035 module_id: module_id.to_string(),
4036 program: PathBuf::from("/test/module"),
4037 args: Vec::new(),
4038 env: Vec::new(),
4039 reserved: false,
4040 reserved_prefixes: Vec::new(),
4041 protocol: ModuleProtocol::Subc,
4042 overlap: Default::default(),
4043 });
4044
4045 assert!(
4046 handle.removal_tombstone_age_ms(module_id).is_none(),
4047 "a re-added module must not retain a stale removal tombstone"
4048 );
4049 }
4050
4051 fn stale_process_snapshot(state: ModuleState, enabled: bool) -> SharedSnapshot {
4052 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::new(state, enabled)));
4053 update_snapshot(&snapshot, Some("stale-process-facts"), |snapshot| {
4054 snapshot.process_alive = true;
4055 snapshot.pid = Some(41);
4056 snapshot.spawned_at_ms = Some(42);
4057 snapshot.spawned_from = Some(PathBuf::from("/spawned/module"));
4058 snapshot.spawned_file_identity = Some(SpawnedFileIdentity {
4059 device: 43,
4060 inode: 44,
4061 });
4062 })
4063 .unwrap();
4064 snapshot
4065 }
4066
4067 fn assert_snapshot_process_facts_cleared(snapshot: &SharedSnapshot) {
4068 let snapshot = lock_snapshot(snapshot).unwrap();
4069 assert!(!snapshot.process_alive);
4070 assert_eq!(snapshot.pid, None);
4071 assert_eq!(snapshot.spawned_at_ms, None);
4072 assert_eq!(snapshot.spawned_from, None);
4073 assert_eq!(snapshot.spawned_file_identity, None);
4074 }
4075
4076 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4077 async fn failed_enable_spawn_clears_preexisting_current_process_facts() {
4078 let supervisor = Supervisor::default();
4079 let mut runtime = supervisor.runtime_config();
4080 runtime.test_seed_stale_facts_before_enable_spawn = true;
4081 let snapshot = stale_process_snapshot(ModuleState::Disabled, false);
4082 let mut child = None;
4083 let spec = ModuleSpec {
4084 module_id: "failed-enable-clears-facts".to_string(),
4085 program: PathBuf::from("/definitely/missing/failed-enable-module"),
4086 args: Vec::new(),
4087 env: Vec::new(),
4088 reserved: false,
4089 reserved_prefixes: Vec::new(),
4090 protocol: ModuleProtocol::Subc,
4091 overlap: Default::default(),
4092 };
4093
4094 let result = set_child_enabled(
4095 &spec,
4096 &runtime,
4097 &supervisor.registry,
4098 &supervisor.process_liveness,
4099 &snapshot,
4100 &mut child,
4101 true,
4102 )
4103 .await;
4104
4105 assert!(matches!(result, Err(SuperviseError::Spawn { .. })));
4106 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
4107 assert_snapshot_process_facts_cleared(&snapshot);
4108 }
4109
4110 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4111 async fn failed_reload_spawn_clears_current_process_facts() {
4112 let supervisor = Supervisor::default();
4113 let mut runtime = supervisor.runtime_config();
4114 runtime.restart_policy = RestartPolicy::new(0, Duration::ZERO);
4115 let snapshot = stale_process_snapshot(ModuleState::Running, true);
4116 let mut child = None;
4117 let spec = ModuleSpec {
4118 module_id: "failed-reload-clears-facts".to_string(),
4119 program: PathBuf::from("/unused/failed-reload-module"),
4120 args: Vec::new(),
4121 env: Vec::new(),
4122 reserved: false,
4123 reserved_prefixes: Vec::new(),
4124 protocol: ModuleProtocol::Subc,
4125 overlap: Default::default(),
4126 };
4127
4128 let result = handle_reload_spawn_failure(
4129 &spec,
4130 &runtime,
4131 &supervisor.process_liveness,
4132 &snapshot,
4133 &mut child,
4134 "forced reload spawn failure".to_string(),
4135 )
4136 .await;
4137
4138 assert!(matches!(result, Err(SuperviseError::ReloadFailed { .. })));
4139 assert_eq!(lock_snapshot(&snapshot).unwrap().state, ModuleState::Failed);
4140 assert_snapshot_process_facts_cleared(&snapshot);
4141 }
4142
4143 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4144 async fn dropping_a_module_with_an_active_monitor_clears_current_process_facts() {
4145 let supervisor = Supervisor::default();
4146 let snapshot = stale_process_snapshot(ModuleState::Running, true);
4147 let module = supervisor.supervised_module(
4148 ModuleSpec {
4149 module_id: "drop-clears-facts".to_string(),
4150 program: PathBuf::from("/unused/drop-module"),
4151 args: Vec::new(),
4152 env: Vec::new(),
4153 reserved: false,
4154 reserved_prefixes: Vec::new(),
4155 protocol: ModuleProtocol::Subc,
4156 overlap: Default::default(),
4157 },
4158 supervisor.runtime_config(),
4159 Arc::clone(&snapshot),
4160 None,
4161 );
4162 assert!(!module
4163 .inner
4164 .monitor
4165 .lock()
4166 .unwrap()
4167 .as_ref()
4168 .unwrap()
4169 .is_finished());
4170
4171 drop(module);
4172
4173 assert_eq!(
4174 lock_snapshot(&snapshot).unwrap().state,
4175 ModuleState::Stopped
4176 );
4177 assert_snapshot_process_facts_cleared(&snapshot);
4178 }
4179
4180 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4181 async fn configuration_update_does_not_replace_captured_running_process_facts() {
4182 let supervisor = Supervisor::default();
4183 let snapshot = stale_process_snapshot(ModuleState::Running, true);
4184 let initial = ModuleSpec {
4185 module_id: "rescan-preserves-spawn-facts".to_string(),
4186 program: PathBuf::from("/spawned/module"),
4187 args: Vec::new(),
4188 env: Vec::new(),
4189 reserved: false,
4190 reserved_prefixes: Vec::new(),
4191 protocol: ModuleProtocol::Subc,
4192 overlap: Default::default(),
4193 };
4194 let module = supervisor.supervised_module(
4195 initial.clone(),
4196 supervisor.runtime_config(),
4197 snapshot,
4198 None,
4199 );
4200 let before = module.status().unwrap();
4201 let mut replacement = initial;
4202 replacement.program = PathBuf::from("/rescanned/replacement-module");
4203
4204 module
4205 .update_configuration(replacement, HealthConfig::default(), None)
4206 .await
4207 .unwrap();
4208
4209 let after = module.status().unwrap();
4210 assert_eq!(after.pid, before.pid);
4211 assert_eq!(after.spawned_at_ms, before.spawned_at_ms);
4212 assert_eq!(after.spawned_from, before.spawned_from);
4213 drop(module);
4214 }
4215}
4216
4217fn unix_ms_now() -> u64 {
4218 SystemTime::now()
4219 .duration_since(UNIX_EPOCH)
4220 .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64)
4221 .unwrap_or(0)
4222}
4223
4224async fn supervise_loop(
4225 mut spec: ModuleSpec,
4226 mut runtime: SupervisorRuntimeConfig,
4227 registry: Arc<Registry>,
4228 process_liveness: Arc<SupervisorProcessLiveness>,
4229 snapshot: SharedSnapshot,
4230 mut child: Option<SupervisedChild>,
4231 mut commands: mpsc::Receiver<SupervisorCommand>,
4232) {
4233 let mut health_probe = HealthProbeRuntime::default();
4234 let mut pending_respawn: Option<Instant> = None;
4238 let mut requeued: VecDeque<SupervisorCommand> = VecDeque::new();
4241 loop {
4242 if let Some(command) = requeued.pop_front() {
4243 if !handle_supervisor_command(
4244 command,
4245 &mut spec,
4246 &mut runtime,
4247 ®istry,
4248 &process_liveness,
4249 &snapshot,
4250 &mut child,
4251 &mut commands,
4252 &mut requeued,
4253 )
4254 .await
4255 {
4256 return;
4257 }
4258 if child.is_some() || !respawn_still_pending(&snapshot) {
4259 pending_respawn = None;
4260 }
4261 continue;
4262 }
4263 if child.is_some() {
4264 health_probe.refresh_registration(&spec, &runtime, ®istry, &snapshot);
4265 let probe_sleep = sleep(health_probe.wake_after());
4266 tokio::pin!(probe_sleep);
4267 let active_child = child.as_mut().expect("child checked above");
4268 tokio::select! {
4269 wait_result = active_child.wait() => {
4270 let exit_report = match wait_result {
4279 Ok(status) => classify_reaped_child_exit(&snapshot, active_child, &status),
4280 Err(err) => {
4281 active_child.drain_stderr(&spec.module_id).await;
4282 fail_snapshot(&snapshot, Some(&spec.module_id), None);
4283 record_wait_error_terminal(
4289 &spec.module_id,
4290 &runtime.terminal_ring,
4291 &runtime.spawn_events,
4292 );
4293 untrack_if_registration_released(
4294 &process_liveness,
4295 ®istry,
4296 &spec.module_id,
4297 &snapshot,
4298 );
4299 error!(module_id = %spec.module_id, error = %err, "failed to wait for supervised module");
4300 child = None;
4301 continue;
4302 }
4303 };
4304 active_child.drain_stderr(&spec.module_id).await;
4305
4306 match on_child_exit(
4307 &spec,
4308 runtime.restart_policy,
4309 ®istry,
4310 &snapshot,
4311 &runtime.terminal_ring,
4312 &runtime.spawn_events,
4313 &runtime.child_roster,
4314 exit_report,
4315 ).await {
4316 NextAction::Stop { registration_released } => {
4317 if registration_released {
4318 process_liveness.untrack_if_current(&spec.module_id, &snapshot);
4319 }
4320 child = None;
4321 }
4322 NextAction::Restart { schedule } => {
4323 let delay = schedule.map_or(
4324 runtime.restart_policy.delay_for_restart(0),
4325 |schedule| schedule.delay,
4326 );
4327 if let Some(schedule) = schedule {
4328 log_crash_respawn(&spec.module_id, schedule);
4329 }
4330 child = None;
4338 pending_respawn = Some(Instant::now() + delay);
4339 }
4340 }
4341 }
4342 command = commands.recv() => {
4343 let Some(command) = command else {
4344 return;
4345 };
4346 if !handle_supervisor_command(
4347 command,
4348 &mut spec,
4349 &mut runtime,
4350 ®istry,
4351 &process_liveness,
4352 &snapshot,
4353 &mut child,
4354 &mut commands,
4355 &mut requeued,
4356 ).await {
4357 return;
4358 }
4359 }
4360 _ = &mut probe_sleep => {
4361 if health_probe.due() {
4362 run_health_probe_cycle(
4363 &spec,
4364 &runtime,
4365 ®istry,
4366 &process_liveness,
4367 &snapshot,
4368 &mut child,
4369 ).await;
4370 if child.is_some() {
4371 health_probe.schedule_next(&spec, runtime.health.cadence);
4372 }
4373 }
4374 }
4375 }
4376 } else if let Some(deadline) = pending_respawn {
4377 tokio::select! {
4378 _ = sleep_until(deadline) => {
4379 pending_respawn = None;
4380 if !respawn_still_pending(&snapshot) {
4384 continue;
4385 }
4386 if runtime.child_roster.is_closed() {
4391 let _ = update_snapshot(&snapshot, Some(&spec.module_id), |state| {
4392 state.state = ModuleState::Stopped;
4393 });
4394 debug!(module_id = %spec.module_id, "crash respawn cancelled by daemon shutdown");
4395 continue;
4396 }
4397 if let Err(err) = wait_for_registration_release(
4398 ®istry,
4399 &spec.module_id,
4400 REGISTRY_RELEASE_TIMEOUT,
4401 ).await {
4402 fail_snapshot(&snapshot, Some(&spec.module_id), None);
4403 error!(module_id = %spec.module_id, error = %err, "registration did not release before restart");
4404 continue;
4405 }
4406
4407 match spawn_and_mark_running(&spec, &runtime, &snapshot) {
4408 Ok(next_child) => {
4409 child = Some(next_child);
4410 debug!(module_id = %spec.module_id, "supervised module restarted after crash");
4411 }
4412 Err(err) => {
4413 fail_snapshot(&snapshot, Some(&spec.module_id), None);
4414 process_liveness.untrack_if_current(&spec.module_id, &snapshot);
4415 error!(module_id = %spec.module_id, error = %err, "failed to restart supervised module");
4416 }
4417 }
4418 }
4419 command = commands.recv() => {
4420 let Some(command) = command else {
4421 return;
4422 };
4423 if !handle_supervisor_command(
4424 command,
4425 &mut spec,
4426 &mut runtime,
4427 ®istry,
4428 &process_liveness,
4429 &snapshot,
4430 &mut child,
4431 &mut commands,
4432 &mut requeued,
4433 ).await {
4434 return;
4435 }
4436 if child.is_some() || !respawn_still_pending(&snapshot) {
4441 pending_respawn = None;
4442 }
4443 }
4444 }
4445 } else {
4446 let Some(command) = commands.recv().await else {
4447 return;
4448 };
4449 if !handle_supervisor_command(
4450 command,
4451 &mut spec,
4452 &mut runtime,
4453 ®istry,
4454 &process_liveness,
4455 &snapshot,
4456 &mut child,
4457 &mut commands,
4458 &mut requeued,
4459 )
4460 .await
4461 {
4462 return;
4463 }
4464 }
4465 }
4466}
4467
4468fn log_crash_respawn(module_id: &str, schedule: CrashRestartSchedule) {
4469 info!(
4470 module_id,
4471 restart_in_window = schedule.restart_in_window,
4472 delay_ms = schedule.delay.as_millis() as u64,
4473 "respawning after crash"
4474 );
4475}
4476
4477fn respawn_still_pending(snapshot: &SharedSnapshot) -> bool {
4483 matches!(
4484 lock_snapshot(snapshot),
4485 Ok(state) if state.enabled && state.state == ModuleState::Restarting
4486 )
4487}
4488
4489enum NextAction {
4490 Stop {
4491 registration_released: bool,
4492 },
4493 Restart {
4494 schedule: Option<CrashRestartSchedule>,
4495 },
4496}
4497
4498#[allow(clippy::too_many_arguments)]
4499async fn handle_supervisor_command(
4500 command: SupervisorCommand,
4501 spec: &mut ModuleSpec,
4502 runtime: &mut SupervisorRuntimeConfig,
4503 registry: &Registry,
4504 process_liveness: &SupervisorProcessLiveness,
4505 snapshot: &SharedSnapshot,
4506 child: &mut Option<SupervisedChild>,
4507 commands: &mut mpsc::Receiver<SupervisorCommand>,
4508 requeued: &mut VecDeque<SupervisorCommand>,
4509) -> bool {
4510 match command {
4511 SupervisorCommand::Drain { reply } => {
4512 let result = drain_optional_child(
4513 &spec.module_id,
4514 spec.protocol,
4515 registry,
4516 snapshot,
4517 &runtime.terminal_ring,
4518 &runtime.spawn_events,
4519 child,
4520 runtime.drain_timeout,
4521 ModuleState::Stopped,
4522 None,
4523 )
4524 .await;
4525 let registration_released = result.is_ok();
4526 let _ = reply.send(result);
4527 if registration_released {
4528 process_liveness.untrack_if_current(&spec.module_id, snapshot);
4529 }
4530 false
4531 }
4532 SupervisorCommand::Retire { reply } => {
4533 let result = async {
4534 begin_forwarding_drain_if_configured(
4535 spec,
4536 runtime,
4537 registry,
4538 snapshot,
4539 None,
4540 RouteCloseReason::Disable,
4541 )
4542 .await?;
4543 drain_optional_child(
4544 &spec.module_id,
4545 spec.protocol,
4546 registry,
4547 snapshot,
4548 &runtime.terminal_ring,
4549 &runtime.spawn_events,
4550 child,
4551 runtime.drain_timeout,
4552 ModuleState::Stopped,
4553 None,
4554 )
4555 .await
4556 }
4557 .await;
4558 let registration_released = result.is_ok();
4559 let _ = reply.send(result);
4560 if registration_released {
4561 process_liveness.untrack_if_current(&spec.module_id, snapshot);
4562 }
4563 false
4564 }
4565 SupervisorCommand::Restart {
4566 drain_timeout_ms,
4567 reply,
4568 } => {
4569 let validation = match lock_snapshot(snapshot) {
4581 Ok(state) if !state.enabled => Err(SuperviseError::Disabled {
4582 module_id: spec.module_id.clone(),
4583 }),
4584 Ok(_) => Ok(()),
4585 Err(err) => Err(err),
4586 };
4587 let initiated = validation.is_ok();
4588 let _ = reply.send(validation);
4589 if initiated {
4590 let drain_timeout = drain_timeout_ms
4593 .map(Duration::from_millis)
4594 .unwrap_or(runtime.drain_timeout);
4595 if let Err(err) = restart_child(
4596 spec,
4597 runtime,
4598 registry,
4599 process_liveness,
4600 snapshot,
4601 child,
4602 drain_timeout,
4603 )
4604 .await
4605 {
4606 warn!(
4607 module_id = %spec.module_id,
4608 error = %err,
4609 "operator restart failed after initiation ack; module state carries the outcome"
4610 );
4611 let _ = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4612 state.state = ModuleState::Failed;
4613 clear_current_process_facts(state);
4614 });
4615 }
4616 }
4617 true
4618 }
4619 SupervisorCommand::Reload { reply } => {
4620 let result =
4621 reload_child(spec, runtime, registry, process_liveness, snapshot, child).await;
4622 let _ = reply.send(result);
4623 true
4624 }
4625 SupervisorCommand::SetEnabled { enabled, reply } => {
4626 let result = set_child_enabled(
4627 spec,
4628 runtime,
4629 registry,
4630 process_liveness,
4631 snapshot,
4632 child,
4633 enabled,
4634 )
4635 .await;
4636 let _ = reply.send(result);
4637 true
4638 }
4639 SupervisorCommand::UpdateConfiguration {
4640 spec: next_spec,
4641 health,
4642 drain_timeout_ms,
4643 reply,
4644 } => {
4645 if let Some(handle) = &runtime.supervisor_handle {
4646 handle.apply_identity_configuration(&next_spec);
4647 }
4648 *spec = next_spec;
4649 runtime.health = health;
4650 runtime.drain_timeout = drain_timeout_ms
4651 .map(Duration::from_millis)
4652 .unwrap_or(runtime.default_drain_timeout);
4653 *runtime
4654 .effective_drain_timeout
4655 .lock()
4656 .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.drain_timeout;
4657 let _ = reply.send(());
4658 true
4659 }
4660 SupervisorCommand::Swap {
4661 ready_timeout,
4662 reply,
4663 } => {
4664 let end = swap::run_swap(
4665 spec,
4666 runtime,
4667 registry,
4668 process_liveness,
4669 snapshot,
4670 child,
4671 commands,
4672 ready_timeout.unwrap_or(DEFAULT_SWAP_READY_TIMEOUT),
4673 reply,
4674 )
4675 .await;
4676 requeued.extend(end.requeue);
4677 true
4678 }
4679 }
4680}
4681
4682async fn restart_child(
4683 spec: &ModuleSpec,
4684 runtime: &SupervisorRuntimeConfig,
4685 registry: &Registry,
4686 process_liveness: &SupervisorProcessLiveness,
4687 snapshot: &SharedSnapshot,
4688 child: &mut Option<SupervisedChild>,
4689 drain_timeout: Duration,
4690) -> Result<(), SuperviseError> {
4691 if !lock_snapshot(snapshot)?.enabled {
4693 return Err(SuperviseError::Disabled {
4694 module_id: spec.module_id.clone(),
4695 });
4696 }
4697 begin_forwarding_drain_with_timeout(
4698 spec,
4699 runtime,
4700 registry,
4701 snapshot,
4702 None,
4703 RouteCloseReason::Restart,
4704 drain_timeout,
4705 )
4706 .await?;
4707
4708 if child.is_some() {
4709 drain_optional_child(
4710 &spec.module_id,
4711 spec.protocol,
4712 registry,
4713 snapshot,
4714 &runtime.terminal_ring,
4715 &runtime.spawn_events,
4716 child,
4717 drain_timeout,
4718 ModuleState::Restarting,
4719 Some(true),
4720 )
4721 .await?;
4722 } else {
4723 update_snapshot(snapshot, Some(&spec.module_id), |state| {
4724 state.enabled = true;
4725 state.state = ModuleState::Restarting;
4726 clear_current_process_facts(state);
4727 })?;
4728 wait_for_registration_release(registry, &spec.module_id, REGISTRY_RELEASE_TIMEOUT).await?;
4729 }
4730
4731 reset_restart_count(snapshot, &spec.module_id)?;
4732 sleep(runtime.restart_policy.backoff).await;
4733 if !respawn_still_pending(snapshot) {
4736 process_liveness.untrack_if_current(&spec.module_id, snapshot);
4737 return Ok(());
4738 }
4739 process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
4740 match spawn_and_mark_running(spec, runtime, snapshot) {
4746 Ok(next_child) => {
4747 *child = Some(next_child);
4748 debug!(module_id = %spec.module_id, "supervised module restarted by operator request");
4749 Ok(())
4750 }
4751 Err(err) => {
4752 fail_snapshot(snapshot, Some(&spec.module_id), None);
4753 process_liveness.untrack_if_current(&spec.module_id, snapshot);
4754 *child = None;
4755 Err(err)
4756 }
4757 }
4758}
4759
4760async fn reload_child(
4761 spec: &ModuleSpec,
4762 runtime: &SupervisorRuntimeConfig,
4763 registry: &Registry,
4764 process_liveness: &SupervisorProcessLiveness,
4765 snapshot: &SharedSnapshot,
4766 child: &mut Option<SupervisedChild>,
4767) -> Result<(), SuperviseError> {
4768 if !lock_snapshot(snapshot)?.enabled {
4770 return Err(SuperviseError::Disabled {
4771 module_id: spec.module_id.clone(),
4772 });
4773 }
4774 begin_forwarding_drain(
4775 spec,
4776 runtime,
4777 registry,
4778 snapshot,
4779 Some(true),
4780 RouteCloseReason::Reload,
4781 )
4782 .await?;
4783
4784 if child.is_some() {
4785 drain_optional_child(
4786 &spec.module_id,
4787 spec.protocol,
4788 registry,
4789 snapshot,
4790 &runtime.terminal_ring,
4791 &runtime.spawn_events,
4792 child,
4793 runtime.drain_timeout,
4794 ModuleState::Restarting,
4795 Some(true),
4796 )
4797 .await?;
4798 } else {
4799 update_snapshot(snapshot, Some(&spec.module_id), |state| {
4800 state.enabled = true;
4801 state.state = ModuleState::Restarting;
4802 clear_current_process_facts(state);
4803 })?;
4804 wait_for_registration_release(registry, &spec.module_id, REGISTRY_RELEASE_TIMEOUT).await?;
4805 }
4806
4807 reset_restart_count(snapshot, &spec.module_id)?;
4808 sleep(runtime.restart_policy.backoff).await;
4809 if !respawn_still_pending(snapshot) {
4812 process_liveness.untrack_if_current(&spec.module_id, snapshot);
4813 return Ok(());
4814 }
4815 process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
4816 let next_child = match spawn_and_mark_running(spec, runtime, snapshot) {
4817 Ok(next_child) => next_child,
4818 Err(err) => {
4819 return handle_reload_spawn_failure(
4820 spec,
4821 runtime,
4822 process_liveness,
4823 snapshot,
4824 child,
4825 format!("new child failed to spawn: {err}"),
4826 )
4827 .await;
4828 }
4829 };
4830 *child = Some(next_child);
4831
4832 let wait_outcome = {
4833 let active_child = child.as_mut().expect("new reload child was just stored");
4834 wait_for_registration_after_reload(
4835 registry,
4836 &spec.module_id,
4837 snapshot,
4838 active_child,
4839 REGISTRY_RELEASE_TIMEOUT,
4840 )
4841 .await?
4842 };
4843
4844 match wait_outcome {
4845 RegistrationWaitOutcome::Registered => {
4846 debug!(module_id = %spec.module_id, "supervised module reloaded and registered");
4847 Ok(())
4848 }
4849 RegistrationWaitOutcome::Exited(exit_report) => {
4850 if let Some(active_child) = child.as_mut() {
4851 active_child.drain_stderr(&spec.module_id).await;
4852 }
4853 *child = None;
4854 handle_reload_child_registration_failure(
4855 spec,
4856 runtime,
4857 registry,
4858 process_liveness,
4859 snapshot,
4860 child,
4861 ReloadRegistrationFailure {
4862 exit_report: registration_failure_exit_report(exit_report),
4863 reason: "new child exited before registering".to_string(),
4864 },
4865 )
4866 .await
4867 }
4868 RegistrationWaitOutcome::TimedOut => {
4869 let mut timed_out_child = child
4870 .take()
4871 .expect("timed-out reload child is still running");
4872 timed_out_child
4873 .start_kill()
4874 .map_err(|source| SuperviseError::Kill {
4875 module_id: spec.module_id.clone(),
4876 source,
4877 })?;
4878 let status = timed_out_child
4879 .wait()
4880 .await
4881 .map_err(|source| SuperviseError::Wait {
4882 module_id: spec.module_id.clone(),
4883 source,
4884 })?;
4885 timed_out_child.drain_stderr(&spec.module_id).await;
4886 handle_reload_child_registration_failure(
4887 spec,
4888 runtime,
4889 registry,
4890 process_liveness,
4891 snapshot,
4892 child,
4893 ReloadRegistrationFailure {
4894 exit_report: registration_failure_exit_report(classify_reaped_child_exit(
4895 snapshot,
4896 &timed_out_child,
4897 &status,
4898 )),
4899 reason: format!(
4900 "new child did not register within {:?}",
4901 REGISTRY_RELEASE_TIMEOUT
4902 ),
4903 },
4904 )
4905 .await
4906 }
4907 }
4908}
4909
4910async fn set_child_enabled(
4911 spec: &ModuleSpec,
4912 runtime: &SupervisorRuntimeConfig,
4913 registry: &Registry,
4914 process_liveness: &SupervisorProcessLiveness,
4915 snapshot: &SharedSnapshot,
4916 child: &mut Option<SupervisedChild>,
4917 enabled: bool,
4918) -> Result<bool, SuperviseError> {
4919 let (current_enabled, current_state) = {
4920 let state = lock_snapshot(snapshot)?;
4921 (state.enabled, state.state)
4922 };
4923 let revive_terminal = enabled
4931 && current_enabled
4932 && child.is_none()
4933 && matches!(current_state, ModuleState::Failed | ModuleState::Stopped);
4934 if current_enabled == enabled && !revive_terminal {
4935 return Ok(false);
4936 }
4937
4938 if enabled {
4939 update_snapshot(snapshot, Some(&spec.module_id), |state| {
4940 state.enabled = true;
4941 state.state = ModuleState::Starting;
4942 clear_current_process_facts(state);
4943 })?;
4944 #[cfg(test)]
4945 if runtime.test_seed_stale_facts_before_enable_spawn {
4946 update_snapshot(snapshot, Some(&spec.module_id), |state| {
4947 state.process_alive = true;
4948 state.pid = Some(41);
4949 state.spawned_at_ms = Some(42);
4950 state.spawned_from = Some(PathBuf::from("/spawned/module"));
4951 state.spawned_file_identity = Some(SpawnedFileIdentity {
4952 device: 43,
4953 inode: 44,
4954 });
4955 })?;
4956 }
4957 wait_for_registration_release(registry, &spec.module_id, REGISTRY_RELEASE_TIMEOUT).await?;
4958 reset_restart_count(snapshot, &spec.module_id)?;
4959 process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
4960 let next_child = match spawn_and_mark_running(spec, runtime, snapshot) {
4961 Ok(next_child) => next_child,
4962 Err(err) => {
4963 if let Err(state_err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
4964 state.state = ModuleState::Failed;
4965 clear_current_process_facts(state);
4966 }) {
4967 error!(module_id = %spec.module_id, error = %state_err, "failed to record enable spawn failure");
4968 }
4969 process_liveness.untrack_if_current(&spec.module_id, snapshot);
4970 return Err(err);
4971 }
4972 };
4973 *child = Some(next_child);
4974 debug!(module_id = %spec.module_id, "supervised module enabled");
4975 Ok(true)
4976 } else {
4977 begin_forwarding_drain_if_configured(
4978 spec,
4979 runtime,
4980 registry,
4981 snapshot,
4982 Some(false),
4983 RouteCloseReason::Disable,
4984 )
4985 .await?;
4986 drain_optional_child(
4987 &spec.module_id,
4988 spec.protocol,
4989 registry,
4990 snapshot,
4991 &runtime.terminal_ring,
4992 &runtime.spawn_events,
4993 child,
4994 runtime.drain_timeout,
4995 ModuleState::Disabled,
4996 Some(false),
4997 )
4998 .await?;
4999 debug!(module_id = %spec.module_id, "supervised module disabled");
5000 Ok(true)
5001 }
5002}
5003
5004#[allow(clippy::too_many_arguments)]
5005async fn on_child_exit(
5006 spec: &ModuleSpec,
5007 policy: RestartPolicy,
5008 registry: &Registry,
5009 snapshot: &SharedSnapshot,
5010 terminal_ring: &Arc<Mutex<TerminalRing>>,
5011 spawn_events: &SpawnEventFeed,
5012 roster: &ChildRoster,
5013 exit_report: ExitReport,
5014) -> NextAction {
5015 if roster.is_closed() {
5021 return on_child_exit_during_daemon_shutdown(
5022 spec,
5023 registry,
5024 snapshot,
5025 terminal_ring,
5026 spawn_events,
5027 exit_report,
5028 )
5029 .await;
5030 }
5031 match exit_report.kind {
5032 ExitKind::Clean => {
5033 info!(
5034 module_id = %spec.module_id,
5035 exit_code = ?exit_report.code,
5036 exit_signal = ?exit_report.signal,
5037 "supervised module exited cleanly"
5038 );
5039 if let Err(err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
5040 state.state = ModuleState::Stopped;
5041 clear_current_process_facts(state);
5042 state.last_exit = Some(exit_report.clone());
5043 }) {
5044 error!(module_id = %spec.module_id, error = %err, "failed to record clean module exit");
5045 }
5046 record_terminal(
5047 &spec.module_id,
5048 terminal_ring,
5049 spawn_events,
5050 &exit_report,
5051 TerminalDisposition::Stopped,
5052 );
5053 let registration_released = match wait_for_registration_release(
5054 registry,
5055 &spec.module_id,
5056 REGISTRY_RELEASE_TIMEOUT,
5057 )
5058 .await
5059 {
5060 Ok(()) => true,
5061 Err(err) => {
5062 warn!(module_id = %spec.module_id, error = %err, "registration still active after clean exit");
5063 false
5064 }
5065 };
5066 NextAction::Stop {
5067 registration_released,
5068 }
5069 }
5070 ExitKind::Crash => {
5071 warn!(
5072 module_id = %spec.module_id,
5073 exit_code = ?exit_report.code,
5074 exit_signal = ?exit_report.signal,
5075 "supervised module exited abnormally (crash)"
5076 );
5077 let mut restart_schedule = None;
5078 let mut disposition = TerminalDisposition::Disabled;
5079 let mut disposition_detail = None;
5083 let now = Instant::now();
5084 if let Err(err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
5085 clear_current_process_facts(state);
5086 state.last_exit = Some(exit_report.clone());
5087 if state.enabled {
5088 if let Some(schedule) = state.next_crash_restart(&policy, now) {
5089 state.state = ModuleState::Restarting;
5090 restart_schedule = Some(schedule);
5091 disposition = TerminalDisposition::Restarting;
5092 } else {
5093 state.state = ModuleState::Failed;
5094 disposition = TerminalDisposition::Failed;
5095 disposition_detail = Some(policy.budget_exhausted_detail());
5096 }
5097 } else {
5098 state.state = ModuleState::Disabled;
5099 disposition = TerminalDisposition::Disabled;
5100 }
5101 }) {
5102 error!(module_id = %spec.module_id, error = %err, "failed to record crashed module exit");
5103 return NextAction::Stop {
5104 registration_released: false,
5105 };
5106 }
5107 if disposition_detail.is_some() {
5108 error!(
5113 module_id = %spec.module_id,
5114 max_restarts = policy.max_restarts,
5115 window_secs = policy.window.as_secs(),
5116 "module stopped: {}",
5117 policy.budget_exhausted_detail()
5118 );
5119 }
5120 record_terminal_with_detail(
5121 &spec.module_id,
5122 terminal_ring,
5123 spawn_events,
5124 &exit_report,
5125 disposition,
5126 disposition_detail,
5127 );
5128
5129 if let Some(schedule) = restart_schedule {
5130 NextAction::Restart {
5131 schedule: Some(schedule),
5132 }
5133 } else {
5134 let registration_released = match wait_for_registration_release(
5135 registry,
5136 &spec.module_id,
5137 REGISTRY_RELEASE_TIMEOUT,
5138 )
5139 .await
5140 {
5141 Ok(()) => true,
5142 Err(err) => {
5143 warn!(module_id = %spec.module_id, error = %err, "registration still active after failed module");
5144 false
5145 }
5146 };
5147 NextAction::Stop {
5148 registration_released,
5149 }
5150 }
5151 }
5152 ExitKind::DeliberateSeverance => {
5153 warn!(
5154 module_id = %spec.module_id,
5155 exit_code = ?exit_report.code,
5156 exit_signal = ?exit_report.signal,
5157 "supervised module exited after deliberate connection severance"
5158 );
5159 let mut should_restart = false;
5160 let mut disposition = TerminalDisposition::Disabled;
5161 if let Err(err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
5162 clear_current_process_facts(state);
5163 state.last_exit = Some(exit_report.clone());
5164 state.lifetime_restarts += 1;
5165 if state.enabled {
5166 state.state = ModuleState::Restarting;
5167 should_restart = true;
5168 disposition = TerminalDisposition::Restarting;
5169 } else {
5170 state.state = ModuleState::Disabled;
5171 }
5172 }) {
5173 error!(module_id = %spec.module_id, error = %err, "failed to record deliberately severed module exit");
5174 return NextAction::Stop {
5175 registration_released: false,
5176 };
5177 }
5178 record_terminal(
5179 &spec.module_id,
5180 terminal_ring,
5181 spawn_events,
5182 &exit_report,
5183 disposition,
5184 );
5185
5186 if should_restart {
5187 NextAction::Restart { schedule: None }
5188 } else {
5189 let registration_released = match wait_for_registration_release(
5190 registry,
5191 &spec.module_id,
5192 REGISTRY_RELEASE_TIMEOUT,
5193 )
5194 .await
5195 {
5196 Ok(()) => true,
5197 Err(err) => {
5198 warn!(module_id = %spec.module_id, error = %err, "registration still active after deliberately severed module exit");
5199 false
5200 }
5201 };
5202 NextAction::Stop {
5203 registration_released,
5204 }
5205 }
5206 }
5207 }
5208}
5209
5210async fn on_child_exit_during_daemon_shutdown(
5211 spec: &ModuleSpec,
5212 registry: &Registry,
5213 snapshot: &SharedSnapshot,
5214 terminal_ring: &Arc<Mutex<TerminalRing>>,
5215 spawn_events: &SpawnEventFeed,
5216 exit_report: ExitReport,
5217) -> NextAction {
5218 info!(
5219 module_id = %spec.module_id,
5220 exit_code = ?exit_report.code,
5221 exit_signal = ?exit_report.signal,
5222 exit_kind = ?exit_report.kind,
5223 "supervised module exited during daemon shutdown; not restarting it"
5224 );
5225 if let Err(err) = update_snapshot(snapshot, Some(&spec.module_id), |state| {
5226 state.state = ModuleState::Stopped;
5227 clear_current_process_facts(state);
5228 state.last_exit = Some(exit_report.clone());
5229 }) {
5230 error!(module_id = %spec.module_id, error = %err, "failed to record module exit during daemon shutdown");
5231 }
5232 record_terminal(
5233 &spec.module_id,
5234 terminal_ring,
5235 spawn_events,
5236 &exit_report,
5237 TerminalDisposition::DaemonShutdown,
5238 );
5239 let registration_released =
5240 wait_for_registration_release(registry, &spec.module_id, REGISTRY_RELEASE_TIMEOUT)
5241 .await
5242 .is_ok();
5243 NextAction::Stop {
5244 registration_released,
5245 }
5246}
5247
5248fn record_wait_error_terminal(
5249 module_id: &str,
5250 terminal_ring: &Arc<Mutex<TerminalRing>>,
5251 spawn_events: &SpawnEventFeed,
5252) {
5253 record_terminal(
5254 module_id,
5255 terminal_ring,
5256 spawn_events,
5257 &wait_error_exit_report(),
5258 TerminalDisposition::Failed,
5259 );
5260}
5261
5262fn record_terminal(
5263 module_id: &str,
5264 terminal_ring: &Arc<Mutex<TerminalRing>>,
5265 spawn_events: &SpawnEventFeed,
5266 exit_report: &ExitReport,
5267 disposition: TerminalDisposition,
5268) {
5269 record_terminal_with_detail(
5270 module_id,
5271 terminal_ring,
5272 spawn_events,
5273 exit_report,
5274 disposition,
5275 None,
5276 );
5277}
5278
5279fn durable_terminal_history_of(
5283 terminal_ring: &Mutex<TerminalRing>,
5284 module_id: &str,
5285) -> subc_control::TerminalHistory {
5286 let read = terminal_ring
5287 .lock()
5288 .unwrap_or_else(|p| p.into_inner())
5289 .capture_durable_history();
5290 read.read(module_id)
5291}
5292
5293fn record_terminal_with_detail(
5294 module_id: &str,
5295 terminal_ring: &Arc<Mutex<TerminalRing>>,
5296 spawn_events: &SpawnEventFeed,
5297 exit_report: &ExitReport,
5298 disposition: TerminalDisposition,
5299 disposition_detail: Option<String>,
5300) {
5301 spawn_events.emit_exited(module_id, exit_report.code, exit_report.signal);
5302 let record = TerminalRecord {
5303 exit_code: exit_report.code,
5304 exit_signal: exit_report.signal,
5305 at_ms: exit_report.at_ms,
5306 disposition,
5307 exit_kind: exit_report.kind.into(),
5308 disposition_detail,
5309 };
5310 terminal_ring
5311 .lock()
5312 .unwrap_or_else(|poisoned| poisoned.into_inner())
5313 .record_exit(module_id, record);
5314}
5315
5316fn untrack_if_registration_released(
5317 process_liveness: &SupervisorProcessLiveness,
5318 registry: &Registry,
5319 module_id: &str,
5320 snapshot: &SharedSnapshot,
5321) {
5322 match registry.get_module(module_id) {
5323 Ok(None) => process_liveness.untrack_if_current(module_id, snapshot),
5324 Ok(Some(_)) => {}
5325 Err(err) => {
5326 warn!(module_id, error = %err, "could not determine whether supervisor liveness can be untracked");
5327 }
5328 }
5329}
5330
5331#[cfg(test)]
5345fn apply_wire_spawn_args(
5346 command: &mut Command,
5347 spec: &ModuleSpec,
5348 connection_file_path: Option<&std::path::Path>,
5349 handle: Option<&SupervisorHandle>,
5350) -> Result<(), SuperviseError> {
5351 apply_wire_spawn_args_for_role(
5352 command,
5353 spec,
5354 connection_file_path,
5355 handle,
5356 SpawnRole::Plain,
5357 )
5358}
5359
5360fn apply_wire_spawn_args_for_role(
5369 command: &mut Command,
5370 spec: &ModuleSpec,
5371 connection_file_path: Option<&std::path::Path>,
5372 handle: Option<&SupervisorHandle>,
5373 role: SpawnRole,
5374) -> Result<(), SuperviseError> {
5375 command.env(SUBC_MODULE_ID_ENV, &spec.module_id);
5376 if spec.protocol == ModuleProtocol::None {
5377 return Ok(());
5378 }
5379 if let Some(connection_file_path) = connection_file_path {
5380 command.arg(SUBC_ARG).arg(connection_file_path);
5381 }
5382
5383 let nonce = generate_launch_nonce()?;
5387 if let Some(handle) = handle {
5388 match role {
5389 SpawnRole::Plain => {
5390 handle.set_spawn_nonce(&spec.module_id, nonce.clone());
5391 if spec.reserved {
5392 handle.set_reserved_nonce(&spec.module_id, nonce.clone());
5393 }
5394 }
5395 SpawnRole::SwapCandidate => handle.open_swap(&spec.module_id, nonce.clone()),
5396 }
5397 }
5398 command.env(SUBC_LAUNCH_NONCE_ENV, nonce);
5399 Ok(())
5400}
5401
5402fn apply_child_env(command: &mut Command, spec: &ModuleSpec) {
5403 command.env_remove(CK_LOG_ENV);
5404 command.env_remove(SUBC_SPAWN_ROLE_ENV);
5411 for (key, value) in &spec.env {
5412 if matches!(
5416 key.as_str(),
5417 CAPTURE_MAX_FILE_MB_ENV | CAPTURE_KEEP_ENV | CAPTURE_MAX_AGE_DAYS_ENV
5418 ) || key == SUBC_SPAWN_ROLE_ENV
5419 {
5420 continue;
5421 }
5422 command.env(key, value);
5423 }
5424}
5425
5426#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5429enum SpawnRole {
5430 Plain,
5431 SwapCandidate,
5432}
5433
5434fn apply_spawn_role(command: &mut Command, role: SpawnRole) {
5437 if role == SpawnRole::SwapCandidate {
5438 command.env(SUBC_SPAWN_ROLE_ENV, SPAWN_ROLE_SWAP_CANDIDATE);
5439 }
5440}
5441
5442fn spawn_child(
5443 spec: &ModuleSpec,
5444 connection_file_path: Option<&std::path::Path>,
5445 handle: Option<&SupervisorHandle>,
5446 ring: &Arc<Mutex<StderrRing>>,
5447 capture_logs_dir: Option<&std::path::Path>,
5448 roster: &ChildRoster,
5449 #[cfg(target_os = "linux")] cgroup_placement: Option<&subc_cgroup::Placement>,
5450) -> Result<SupervisedChild, SuperviseError> {
5451 spawn_child_in_slot(
5452 spec,
5453 connection_file_path,
5454 handle,
5455 ring,
5456 capture_logs_dir,
5457 roster,
5458 #[cfg(target_os = "linux")]
5459 cgroup_placement,
5460 SpawnRole::Plain,
5461 false,
5462 )
5463}
5464
5465#[allow(clippy::too_many_arguments)]
5478fn spawn_child_in_slot(
5479 spec: &ModuleSpec,
5480 connection_file_path: Option<&std::path::Path>,
5481 handle: Option<&SupervisorHandle>,
5482 ring: &Arc<Mutex<StderrRing>>,
5483 capture_logs_dir: Option<&std::path::Path>,
5484 roster: &ChildRoster,
5485 #[cfg(target_os = "linux")] cgroup_placement: Option<&subc_cgroup::Placement>,
5486 role: SpawnRole,
5487 alternate_slot: bool,
5488) -> Result<SupervisedChild, SuperviseError> {
5489 if roster.is_closed() {
5490 return Err(SuperviseError::Spawn {
5491 program: spec.program.clone(),
5492 source: io::Error::other("the daemon is shutting down; not starting a new process"),
5493 cgroup_path: None,
5494 });
5495 }
5496 #[cfg(target_os = "linux")]
5497 let cgroup_name = swap::cgroup_name(&spec.module_id, alternate_slot);
5498 #[cfg(not(target_os = "linux"))]
5499 let _ = alternate_slot;
5500 let mut command = Command::new(&spec.program);
5501 command.args(&spec.args);
5502 apply_child_env(&mut command, spec);
5532 apply_spawn_role(&mut command, role);
5533 apply_wire_spawn_args_for_role(&mut command, spec, connection_file_path, handle, role)?;
5534
5535 #[cfg(target_os = "linux")]
5536 let cgroup_path = cgroup_placement
5537 .map(|placement| placement.module_path(&cgroup_name))
5538 .transpose()
5539 .map_err(|source| SuperviseError::Cgroup {
5540 module_id: spec.module_id.clone(),
5541 source,
5542 })?;
5543 #[cfg(not(target_os = "linux"))]
5544 let cgroup_path: Option<PathBuf> = None;
5545 #[cfg(target_os = "linux")]
5546 if let Some(path) = &cgroup_path {
5547 if let Err(error) = apply_cgroup_placement(&mut command, spec, path) {
5548 if let Some(placement) = cgroup_placement {
5549 remove_module_cgroup(placement, &cgroup_name);
5550 }
5551 return Err(error);
5552 }
5553 }
5554
5555 let output_sink = if let Some(logs_dir) = capture_logs_dir {
5556 let path = logs_dir.join(format!("{}.stderr.log", spec.module_id));
5557 match ChildOutputSink::open(&path, capture_retention(spec)) {
5558 Ok(sink) => sink,
5559 Err(error) => {
5560 warn!(
5561 module_id = %spec.module_id,
5562 path = %path.display(),
5563 error = %error,
5564 "could not open child output capture file; forwarding to stderr"
5565 );
5566 ChildOutputSink::Stderr
5567 }
5568 }
5569 } else {
5570 ChildOutputSink::Stderr
5571 };
5572
5573 command.stdout(Stdio::piped());
5574 command.stderr(Stdio::piped());
5575 command.kill_on_drop(true);
5576 #[cfg(unix)]
5593 command.process_group(0);
5594 command.stdin(Stdio::null());
5595 let mut child = match command.spawn() {
5596 Ok(child) => child,
5597 Err(source) => {
5598 #[cfg(target_os = "linux")]
5599 if let Some(placement) = cgroup_placement {
5600 remove_module_cgroup(placement, &cgroup_name);
5601 }
5602 return Err(SuperviseError::Spawn {
5603 program: spec.program.clone(),
5604 source,
5605 cgroup_path,
5606 });
5607 }
5608 };
5609 let spawned_at_ms = unix_ms_now();
5610 let spawned_from = spec.program.clone();
5611 let spawned_file_identity = spawned_file_identity(&spawned_from);
5612 let pid = child.id().ok_or_else(|| SuperviseError::Spawn {
5613 program: spec.program.clone(),
5614 source: io::Error::other("spawned child exposed no live pid"),
5615 cgroup_path: cgroup_path.clone(),
5616 })?;
5617 let process_start_time = crate::provenance::process_start_time(pid);
5618 let process_identity = process_start_time.map(|start_time| ProcessIdentity { pid, start_time });
5619 let roster_guard = roster.admit(
5620 spec.module_id.clone(),
5621 pid,
5622 spec.protocol,
5623 process_start_time,
5624 );
5625 if roster.is_closed() {
5634 if let Err(error) = child.start_kill() {
5635 debug!(module_id = %spec.module_id, pid, %error, "kill of a process spawned during daemon shutdown failed; it may already have exited");
5636 }
5637 drop(roster_guard);
5638 return Err(SuperviseError::Spawn {
5639 program: spec.program.clone(),
5640 source: io::Error::other(
5641 "the daemon began shutting down while this process was starting; ended it",
5642 ),
5643 cgroup_path,
5644 });
5645 }
5646
5647 let stdout_pump = match child.stdout.take() {
5648 Some(stdout) => Some(tokio::spawn(pump_stdout_to(stdout, output_sink.clone()))),
5649 None => {
5650 warn!(
5651 module_id = %spec.module_id,
5652 "spawned child exposed no stdout pipe; file capture will be incomplete"
5653 );
5654 None
5655 }
5656 };
5657 let stderr_pump = match child.stderr.take() {
5658 Some(stderr) => {
5659 ring.lock()
5660 .unwrap_or_else(|poisoned| poisoned.into_inner())
5661 .push_process_start();
5662 Some(tokio::spawn(pump_stderr_to(
5663 stderr,
5664 Arc::clone(ring),
5665 output_sink,
5666 )))
5667 }
5668 None => {
5669 ring.lock()
5673 .unwrap_or_else(|poisoned| poisoned.into_inner())
5674 .mark_not_captured("stderr pipe was not available on spawn");
5675 warn!(
5676 module_id = %spec.module_id,
5677 "spawned child exposed no stderr pipe; tail will be unavailable"
5678 );
5679 None
5680 }
5681 };
5682
5683 Ok(SupervisedChild {
5684 child,
5685 #[cfg(target_os = "linux")]
5686 module_id: cgroup_name,
5687 #[cfg(target_os = "linux")]
5688 cgroup_placement: cgroup_placement.cloned(),
5689 stdout_pump,
5690 stderr_pump,
5691 stderr_ring: Arc::clone(ring),
5692 spawned_at_ms,
5693 spawned_from,
5694 spawned_file_identity,
5695 process_start_time,
5696 process_identity,
5697 pid,
5698 roster_guard: Some(roster_guard),
5699 })
5700}
5701
5702#[cfg(target_os = "linux")]
5703fn remove_module_cgroup(placement: &subc_cgroup::Placement, module_id: &str) {
5704 match placement.remove_module(module_id) {
5705 Ok(()) => debug!(module_id, "removed module cgroup after process exit"),
5706 Err(error) => warn!(
5707 module_id,
5708 error = %error,
5709 "could not remove module cgroup after process exit; continuing teardown"
5710 ),
5711 }
5712}
5713
5714#[cfg(target_os = "linux")]
5715fn apply_cgroup_placement(
5716 command: &mut Command,
5717 spec: &ModuleSpec,
5718 path: &std::path::Path,
5719) -> Result<(), SuperviseError> {
5720 subc_cgroup::apply(command, path).map_err(|source| SuperviseError::Cgroup {
5721 module_id: spec.module_id.clone(),
5722 source,
5723 })
5724}
5725
5726fn capture_retention(spec: &ModuleSpec) -> Retention {
5727 let defaults = Retention::default();
5728 let value = |name: &str| {
5729 spec.env
5730 .iter()
5731 .rev()
5732 .find_map(|(key, value)| (key == name).then_some(value.as_str()))
5733 };
5734 Retention {
5735 max_file_mb: value(CAPTURE_MAX_FILE_MB_ENV)
5736 .and_then(|value| value.parse().ok())
5737 .unwrap_or(defaults.max_file_mb),
5738 keep: value(CAPTURE_KEEP_ENV)
5739 .and_then(|value| value.parse().ok())
5740 .unwrap_or(defaults.keep),
5741 max_age_days: value(CAPTURE_MAX_AGE_DAYS_ENV)
5742 .and_then(|value| value.parse().ok())
5743 .unwrap_or(defaults.max_age_days),
5744 }
5745}
5746
5747fn generate_launch_nonce() -> Result<String, SuperviseError> {
5750 let mut bytes = [0u8; 32];
5751 getrandom::getrandom(&mut bytes).map_err(|source| SuperviseError::LaunchNonce {
5752 reason: source.to_string(),
5753 })?;
5754 let mut hex = String::with_capacity(64);
5755 for b in bytes {
5756 use std::fmt::Write;
5757 let _ = write!(hex, "{b:02x}");
5758 }
5759 Ok(hex)
5760}
5761
5762fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
5765 if a.len() != b.len() {
5766 return false;
5767 }
5768 let mut diff = 0u8;
5769 for (x, y) in a.iter().zip(b.iter()) {
5770 diff |= x ^ y;
5771 }
5772 diff == 0
5773}
5774
5775fn spawn_and_mark_running(
5776 spec: &ModuleSpec,
5777 runtime: &SupervisorRuntimeConfig,
5778 snapshot: &SharedSnapshot,
5779) -> Result<SupervisedChild, SuperviseError> {
5780 let child = spawn_child(
5781 spec,
5782 runtime.connection_file_path.as_deref(),
5783 runtime.supervisor_handle.as_ref(),
5784 &runtime.stderr_ring,
5785 runtime.capture_logs_dir.as_deref(),
5786 &runtime.child_roster,
5787 #[cfg(target_os = "linux")]
5788 runtime.cgroup_placement.as_ref(),
5789 )?;
5790 set_running(snapshot, &child, &spec.module_id, &runtime.spawn_events)?;
5791 Ok(child)
5792}
5793
5794enum RegistrationWaitOutcome {
5795 Registered,
5796 Exited(ExitReport),
5797 TimedOut,
5798}
5799
5800struct ReloadRegistrationFailure {
5801 exit_report: ExitReport,
5802 reason: String,
5803}
5804
5805#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5806enum BusyGaugeObservation {
5807 Quiescent,
5808 Busy,
5809 Omitted,
5810}
5811
5812fn busy_gauge_observation(metrics: Option<&Value>, gauges: &[String]) -> BusyGaugeObservation {
5813 let Some(metrics) = metrics.and_then(Value::as_object) else {
5814 return BusyGaugeObservation::Omitted;
5815 };
5816 let mut sum = 0u128;
5817 for gauge in gauges {
5818 let Some(value) = metrics.get(gauge) else {
5819 return BusyGaugeObservation::Omitted;
5820 };
5821 let Some(value) = value.as_u64() else {
5822 return BusyGaugeObservation::Busy;
5823 };
5824 sum = sum.saturating_add(u128::from(value));
5825 }
5826 if sum == 0 {
5827 BusyGaugeObservation::Quiescent
5828 } else {
5829 BusyGaugeObservation::Busy
5830 }
5831}
5832
5833fn declared_busy_gauges(
5834 registry: &Registry,
5835 module_id: &str,
5836) -> Result<Vec<String>, SuperviseError> {
5837 busy_gauges_of(
5838 registry
5839 .get_module(module_id)
5840 .map_err(SuperviseError::Registry)?,
5841 )
5842}
5843
5844fn declared_busy_gauges_for_connection(
5848 registry: &Registry,
5849 connection_id: ConnectionId,
5850) -> Result<Vec<String>, SuperviseError> {
5851 busy_gauges_of(
5852 registry
5853 .get_module_by_connection(connection_id)
5854 .map_err(SuperviseError::Registry)?,
5855 )
5856}
5857
5858fn busy_gauges_of(
5859 registration: Option<crate::registry::ModuleRegistration>,
5860) -> Result<Vec<String>, SuperviseError> {
5861 let Some(registration) = registration else {
5862 return Ok(Vec::new());
5863 };
5864 let Some(self_signals) = registration.manifest.self_signals else {
5865 return Ok(Vec::new());
5866 };
5867
5868 let mut gauges = Vec::new();
5869 for declaration in self_signals {
5870 if declaration.kind != SelfSignalKind::Busy {
5871 continue;
5872 }
5873 match declaration.anchored_to {
5874 SignalAnchor::HealthGauges { gauges: declared } if !declared.is_empty() => {
5875 gauges.extend(declared)
5876 }
5877 _ => {
5878 gauges.push(String::new());
5881 }
5882 }
5883 }
5884 Ok(gauges)
5885}
5886
5887async fn wait_for_forwarding_quiescence(
5892 forwarding: &ForwardingTable,
5893 module_id: &str,
5894 runtime: &SupervisorRuntimeConfig,
5895 endpoint: crate::ModuleEndpointId,
5896 deadline: Instant,
5897 busy_gauges: &[String],
5898 scope: DrainScope,
5899) -> Result<bool, SuperviseError> {
5900 let mut gauges_quiescent = busy_gauges.is_empty();
5901 let mut next_probe_at = Instant::now();
5902 let mut omission_counted = false;
5903
5904 loop {
5905 let now = Instant::now();
5906 if !busy_gauges.is_empty() && now >= next_probe_at && now < deadline {
5907 let report = match scope {
5908 DrainScope::Active => probe_module_health(module_id, runtime, Some(deadline)).await,
5909 DrainScope::Endpoint(endpoint) => {
5910 probe_endpoint_health(endpoint, runtime, Some(deadline)).await
5911 }
5912 };
5913 gauges_quiescent = match report {
5914 Ok(report) => match busy_gauge_observation(report.metrics.as_ref(), busy_gauges) {
5915 BusyGaugeObservation::Quiescent => true,
5916 BusyGaugeObservation::Busy => false,
5917 BusyGaugeObservation::Omitted => {
5918 if !omission_counted {
5919 forwarding
5920 .counters()
5921 .increment_drains_with_undeclared_gauge();
5922 omission_counted = true;
5923 }
5924 false
5925 }
5926 },
5927 Err(err) => {
5928 warn!(
5929 module_id,
5930 error = %err,
5931 "drain health.check did not produce declared busy gauges; treating module as busy"
5932 );
5933 false
5934 }
5935 };
5936 next_probe_at = Instant::now() + runtime.health.cadence.max(REGISTRY_RELEASE_POLL);
5937 }
5938
5939 let in_flight = forwarding
5940 .endpoint_in_flight_count(endpoint)
5941 .map_err(SuperviseError::Forwarding)?;
5942 if in_flight == 0 && gauges_quiescent {
5943 return Ok(true);
5944 }
5945
5946 let now = Instant::now();
5947 if now >= deadline {
5948 return Ok(false);
5949 }
5950 let mut wait = deadline
5951 .saturating_duration_since(now)
5952 .min(REGISTRY_RELEASE_POLL);
5953 if !busy_gauges.is_empty() {
5954 wait = wait.min(next_probe_at.saturating_duration_since(now));
5955 }
5956 sleep(wait).await;
5957 }
5958}
5959
5960fn drained_after_quiescence_wait(wait_result: &Result<bool, SuperviseError>) -> bool {
5968 match wait_result {
5969 Ok(drained) => *drained,
5970 Err(_) => false,
5971 }
5972}
5973
5974fn send_route_goodbyes(forwarding: &ForwardingTable, released_routes: Vec<GoodbyeTarget>) {
5975 for released in released_routes {
5976 let frame = match Frame::build_with_version(
5977 released.negotiated_ver,
5978 FrameType::Goodbye,
5979 control_flags(),
5980 released.channel,
5981 released.epoch,
5982 0,
5983 Vec::new(),
5984 ) {
5985 Ok(frame) => frame,
5986 Err(err) => {
5987 warn!(
5988 route_channel = released.channel,
5989 error = %err,
5990 "failed to build supervisor drain route GOODBYE frame"
5991 );
5992 continue;
5993 }
5994 };
5995 if !released.close_on_delivery_failure() {
5996 crate::forwarding::send_module_route_goodbye(
5997 &forwarding.counters(),
5998 &released.sink,
5999 frame,
6000 released.module_id.as_deref(),
6001 "supervisor drain",
6002 );
6003 continue;
6004 }
6005 if let Err(err) = released.sink.try_send(frame) {
6006 warn!(
6007 target_connection_id = released.connection_id.get(),
6008 route_channel = released.channel,
6009 error = %err,
6010 "supervisor drain route GOODBYE was not delivered to client; closing target connection"
6011 );
6012 let _ = forwarding.escalate_client_delivery_failure(
6013 released.connection_id,
6014 released.channel,
6015 released.epoch,
6016 CloseReason::new(
6017 "route_goodbye_delivery_failed",
6018 format!(
6019 "failed to enqueue supervisor drain route GOODBYE for channel {}: {err}",
6020 released.channel
6021 ),
6022 ),
6023 crate::forwarding::UndeliveredFrame {
6024 module_id: released.module_id.as_deref(),
6025 sink: &released.sink,
6026 },
6027 );
6028 }
6029 }
6030}
6031
6032fn send_module_draining(
6033 module_id: &str,
6034 reason: RouteCloseReason,
6035 deadline_ms: u64,
6036 target: &ModuleDrainTarget,
6037) {
6038 let body = match serde_json::to_vec(&ModuleControlCommand::Draining {
6039 reason,
6040 deadline_ms,
6041 }) {
6042 Ok(body) => body,
6043 Err(err) => {
6044 warn!(
6045 module_id,
6046 error = %err,
6047 "failed to encode module draining command"
6048 );
6049 return;
6050 }
6051 };
6052 let frame = match Frame::build_with_version(
6053 target.negotiated_ver,
6054 FrameType::Push,
6055 control_flags(),
6056 0,
6057 0,
6058 0,
6059 body,
6060 ) {
6061 Ok(frame) => frame,
6062 Err(err) => {
6063 warn!(
6064 module_id,
6065 error = %err,
6066 "failed to build module draining command frame"
6067 );
6068 return;
6069 }
6070 };
6071 if let Err(err) = target.sink.try_send(frame) {
6072 warn!(
6073 module_id,
6074 target_connection_id = target.endpoint.connection_id.get(),
6075 error = %err,
6076 "module draining command was not delivered to peer"
6077 );
6078 }
6079}
6080
6081fn send_module_goodbye(module_id: &str, forwarding: &ForwardingTable, target: &ModuleDrainTarget) {
6082 let frame = match Frame::build_with_version(
6083 target.negotiated_ver,
6084 FrameType::Goodbye,
6085 control_flags(),
6086 0,
6087 0,
6088 0,
6089 Vec::new(),
6090 ) {
6091 Ok(frame) => frame,
6092 Err(err) => {
6093 warn!(
6094 module_id,
6095 error = %err,
6096 "failed to build supervisor drain module GOODBYE frame"
6097 );
6098 return;
6099 }
6100 };
6101 if let Err(err) = target.sink.try_send(frame) {
6102 warn!(
6103 module_id,
6104 target_connection_id = target.endpoint.connection_id.get(),
6105 error = %err,
6106 "supervisor drain module GOODBYE was not delivered to peer; closing module connection"
6107 );
6108 forwarding.request_connection_close(
6109 target.endpoint.connection_id,
6110 CloseReason::new(
6111 "module_goodbye_delivery_failed",
6112 format!("failed to enqueue supervisor drain module GOODBYE for module '{module_id}': {err}"),
6113 ),
6114 );
6115 }
6116}
6117
6118#[derive(Clone, Copy)]
6119struct ForwardingDrainContext<'a> {
6120 spec: &'a ModuleSpec,
6121 runtime: &'a SupervisorRuntimeConfig,
6122 registry: &'a Registry,
6123 scope: DrainScope,
6124}
6125
6126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6128enum DrainScope {
6129 Active,
6132 Endpoint(crate::ModuleEndpointId),
6137}
6138
6139async fn begin_forwarding_drain(
6140 spec: &ModuleSpec,
6141 runtime: &SupervisorRuntimeConfig,
6142 registry: &Registry,
6143 snapshot: &SharedSnapshot,
6144 enabled: Option<bool>,
6145 reason: RouteCloseReason,
6146) -> Result<(), SuperviseError> {
6147 let Some(forwarding) = runtime.forwarding.as_ref() else {
6148 return Err(SuperviseError::ReloadUnavailable {
6149 module_id: spec.module_id.clone(),
6150 reason: "supervisor was not configured with a forwarding table".to_string(),
6151 });
6152 };
6153
6154 begin_forwarding_drain_with(
6155 forwarding,
6156 ForwardingDrainContext {
6157 spec,
6158 runtime,
6159 registry,
6160 scope: DrainScope::Active,
6161 },
6162 snapshot,
6163 enabled,
6164 reason,
6165 runtime.drain_timeout,
6166 )
6167 .await
6168}
6169
6170async fn begin_forwarding_drain_if_configured(
6171 spec: &ModuleSpec,
6172 runtime: &SupervisorRuntimeConfig,
6173 registry: &Registry,
6174 snapshot: &SharedSnapshot,
6175 enabled: Option<bool>,
6176 reason: RouteCloseReason,
6177) -> Result<(), SuperviseError> {
6178 begin_forwarding_drain_with_timeout(
6179 spec,
6180 runtime,
6181 registry,
6182 snapshot,
6183 enabled,
6184 reason,
6185 runtime.drain_timeout,
6186 )
6187 .await
6188}
6189
6190async fn begin_forwarding_drain_with_timeout(
6194 spec: &ModuleSpec,
6195 runtime: &SupervisorRuntimeConfig,
6196 registry: &Registry,
6197 snapshot: &SharedSnapshot,
6198 enabled: Option<bool>,
6199 reason: RouteCloseReason,
6200 drain_timeout: Duration,
6201) -> Result<(), SuperviseError> {
6202 let Some(forwarding) = runtime.forwarding.as_ref() else {
6203 return Ok(());
6204 };
6205
6206 begin_forwarding_drain_with(
6207 forwarding,
6208 ForwardingDrainContext {
6209 spec,
6210 runtime,
6211 registry,
6212 scope: DrainScope::Active,
6213 },
6214 snapshot,
6215 enabled,
6216 reason,
6217 drain_timeout,
6218 )
6219 .await
6220}
6221
6222async fn begin_forwarding_drain_with(
6223 forwarding: &ForwardingTable,
6224 context: ForwardingDrainContext<'_>,
6225 snapshot: &SharedSnapshot,
6226 enabled: Option<bool>,
6227 reason: RouteCloseReason,
6228 drain_timeout: Duration,
6229) -> Result<(), SuperviseError> {
6230 let ForwardingDrainContext {
6231 spec,
6232 runtime,
6233 registry,
6234 scope,
6235 } = context;
6236 debug_assert_ne!(reason, RouteCloseReason::Crash);
6237 let terminal = matches!(reason, RouteCloseReason::Disable);
6238 let drain_started_at = Instant::now();
6239 let drain_deadline = drain_started_at + drain_timeout;
6240 let deadline_ms =
6241 unix_ms_now().saturating_add(u64::try_from(drain_timeout.as_millis()).unwrap_or(u64::MAX));
6242 let busy_gauges = match scope {
6243 DrainScope::Active => declared_busy_gauges(registry, &spec.module_id)?,
6244 DrainScope::Endpoint(endpoint) => {
6245 declared_busy_gauges_for_connection(registry, endpoint.connection_id)?
6246 }
6247 };
6248
6249 let drain_target = match scope {
6252 DrainScope::Active => forwarding.begin_module_drain(&spec.module_id, reason),
6253 DrainScope::Endpoint(endpoint) => forwarding.begin_endpoint_drain(endpoint, reason),
6254 }
6255 .map_err(SuperviseError::Forwarding)?;
6256 if scope == DrainScope::Active {
6257 update_snapshot(snapshot, Some(&spec.module_id), |state| {
6258 state.state = ModuleState::Draining;
6259 if let Some(enabled) = enabled {
6260 state.enabled = enabled;
6261 }
6262 })?;
6263 }
6264
6265 if let Some(target) = drain_target.as_ref() {
6266 send_module_draining(&spec.module_id, reason, deadline_ms, target);
6267 let routes = forwarding
6268 .endpoint_routes(target.endpoint)
6269 .map_err(SuperviseError::Forwarding)?;
6270 let routes_notified = routes.len();
6271 crate::control::send_route_control_pushes(
6272 forwarding,
6273 routes.clone(),
6274 ClientControlPush::RouteClosing {
6275 module_id: spec.module_id.clone(),
6276 reason,
6277 },
6278 );
6279 send_route_goodbyes(forwarding, target.abandoned_bindings.clone());
6280
6281 let wait_result = wait_for_forwarding_quiescence(
6287 forwarding,
6288 &spec.module_id,
6289 runtime,
6290 target.endpoint,
6291 drain_deadline,
6292 &busy_gauges,
6293 scope,
6294 )
6295 .await;
6296 let drained = drained_after_quiescence_wait(&wait_result);
6297 if let Err(err) = &wait_result {
6298 error!(
6299 module_id = %spec.module_id,
6300 ?reason,
6301 error = %err,
6302 "forwarding quiescence wait failed after route.closing; forcing route.closed(drained: false) so the client is not left waiting on an unfulfilled promise"
6303 );
6304 } else if !drained {
6305 let holdouts = forwarding
6311 .endpoint_drain_holdouts(target.endpoint)
6312 .unwrap_or_default();
6313 warn!(
6314 module_id = %spec.module_id,
6315 waited = ?drain_timeout,
6316 ?reason,
6317 held_requests = holdouts.requests,
6318 held_routes = holdouts.routes,
6319 total_routes = holdouts.total_routes,
6320 top_connections = ?holdouts.top_connections,
6321 held = %holdouts
6324 .held
6325 .iter()
6326 .map(|(channel, corr)| format!("{channel}:{corr}"))
6327 .collect::<Vec<_>>()
6328 .join(","),
6329 "route drain timed out before request quiescence; forcing teardown"
6330 );
6331 }
6332 crate::control::send_route_control_pushes(
6333 forwarding,
6334 routes,
6335 ClientControlPush::RouteClosed {
6336 module_id: spec.module_id.clone(),
6337 reason,
6338 drained,
6339 abandoned: target.abandoned_bindings.len() as u32,
6340 excluded_subscriptions: target.excluded_subscriptions,
6341 terminal: Some(terminal),
6342 },
6343 );
6344 wait_result?;
6345
6346 let released_routes = match forwarding.release_module_endpoint_routes(target.endpoint) {
6352 Ok(routes) => routes,
6353 Err(err) => {
6354 warn!(
6355 module_id = %spec.module_id,
6356 ?reason,
6357 error = %err,
6358 "failed to release module endpoint routes after route.closed; module GOODBYE will still be sent"
6359 );
6360 send_module_goodbye(&spec.module_id, forwarding, target);
6361 return Err(SuperviseError::Forwarding(err));
6362 }
6363 };
6364 let route_goodbye_count = released_routes.len();
6365 send_route_goodbyes(forwarding, released_routes);
6366 send_module_goodbye(&spec.module_id, forwarding, target);
6367
6368 info!(
6374 module_id = %spec.module_id,
6375 ?reason,
6376 routes_notified,
6377 route_goodbyes = route_goodbye_count,
6378 abandoned_reservations = target.abandoned_bindings.len(),
6379 excluded_subscriptions = target.excluded_subscriptions,
6380 drained,
6381 "module drain complete; consumers notified via route.closing/route.closed pushes and per-route GOODBYE frames"
6382 );
6383 }
6384
6385 Ok(())
6386}
6387
6388async fn wait_for_registration_after_reload(
6391 registry: &Registry,
6392 module_id: &str,
6393 snapshot: &SharedSnapshot,
6394 child: &mut SupervisedChild,
6395 wait: Duration,
6396) -> Result<RegistrationWaitOutcome, SuperviseError> {
6397 wait_for_slot_registration(
6398 registry,
6399 crate::registry::RegistrationSlot::Active(module_id),
6400 module_id,
6401 snapshot,
6402 child,
6403 wait,
6404 )
6405 .await
6406}
6407
6408async fn wait_for_slot_registration(
6416 registry: &Registry,
6417 slot: crate::registry::RegistrationSlot<'_>,
6418 module_id: &str,
6419 snapshot: &SharedSnapshot,
6420 child: &mut SupervisedChild,
6421 wait: Duration,
6422) -> Result<RegistrationWaitOutcome, SuperviseError> {
6423 let deadline = Instant::now() + wait;
6424 loop {
6425 if registry
6426 .registration(slot)
6427 .map_err(SuperviseError::Registry)?
6428 .is_some()
6429 {
6430 return Ok(RegistrationWaitOutcome::Registered);
6431 }
6432
6433 let now = Instant::now();
6434 if now >= deadline {
6435 return Ok(RegistrationWaitOutcome::TimedOut);
6436 }
6437 let remaining = deadline.saturating_duration_since(now);
6438 let poll = remaining.min(REGISTRY_RELEASE_POLL);
6439
6440 tokio::select! {
6441 wait_result = child.wait() => {
6442 let status = wait_result.map_err(|source| SuperviseError::Wait {
6443 module_id: module_id.to_string(),
6444 source,
6445 })?;
6446 return Ok(RegistrationWaitOutcome::Exited(classify_reaped_child_exit(
6447 snapshot,
6448 child,
6449 &status,
6450 )));
6451 }
6452 _ = sleep(poll) => {}
6453 }
6454 }
6455}
6456
6457fn registration_failure_exit_report(mut exit_report: ExitReport) -> ExitReport {
6458 if exit_report.kind != ExitKind::DeliberateSeverance {
6461 exit_report.kind = ExitKind::Crash;
6462 }
6463 exit_report
6464}
6465
6466async fn handle_reload_child_registration_failure(
6467 spec: &ModuleSpec,
6468 runtime: &SupervisorRuntimeConfig,
6469 registry: &Registry,
6470 process_liveness: &SupervisorProcessLiveness,
6471 snapshot: &SharedSnapshot,
6472 child: &mut Option<SupervisedChild>,
6473 failure: ReloadRegistrationFailure,
6474) -> Result<(), SuperviseError> {
6475 let ReloadRegistrationFailure {
6476 exit_report,
6477 reason,
6478 } = failure;
6479 match on_child_exit(
6480 spec,
6481 runtime.restart_policy,
6482 registry,
6483 snapshot,
6484 &runtime.terminal_ring,
6485 &runtime.spawn_events,
6486 &runtime.child_roster,
6487 exit_report,
6488 )
6489 .await
6490 {
6491 NextAction::Stop {
6492 registration_released,
6493 } => {
6494 if registration_released {
6495 process_liveness.untrack_if_current(&spec.module_id, snapshot);
6496 }
6497 }
6498 NextAction::Restart { schedule } => {
6499 let delay = schedule.map_or(runtime.restart_policy.delay_for_restart(0), |schedule| {
6500 schedule.delay
6501 });
6502 if let Some(schedule) = schedule {
6503 log_crash_respawn(&spec.module_id, schedule);
6504 }
6505 sleep(delay).await;
6506 if respawn_still_pending(snapshot) {
6510 if let Err(err) = wait_for_registration_release(
6511 registry,
6512 &spec.module_id,
6513 REGISTRY_RELEASE_TIMEOUT,
6514 )
6515 .await
6516 {
6517 fail_snapshot(snapshot, Some(&spec.module_id), None);
6518 process_liveness.untrack_if_current(&spec.module_id, snapshot);
6519 return Err(SuperviseError::ReloadFailed {
6520 module_id: spec.module_id.clone(),
6521 reason: format!(
6522 "{reason}; registration did not release before policy retry: {err}"
6523 ),
6524 });
6525 }
6526 process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
6527 match spawn_and_mark_running(spec, runtime, snapshot) {
6528 Ok(next_child) => {
6529 *child = Some(next_child);
6530 }
6531 Err(err) => {
6532 fail_snapshot(snapshot, Some(&spec.module_id), None);
6533 process_liveness.untrack_if_current(&spec.module_id, snapshot);
6534 return Err(SuperviseError::ReloadFailed {
6535 module_id: spec.module_id.clone(),
6536 reason: format!("{reason}; policy retry spawn failed: {err}"),
6537 });
6538 }
6539 }
6540 }
6541 }
6542 }
6543
6544 Err(SuperviseError::ReloadFailed {
6545 module_id: spec.module_id.clone(),
6546 reason,
6547 })
6548}
6549
6550async fn handle_reload_spawn_failure(
6551 spec: &ModuleSpec,
6552 runtime: &SupervisorRuntimeConfig,
6553 process_liveness: &SupervisorProcessLiveness,
6554 snapshot: &SharedSnapshot,
6555 child: &mut Option<SupervisedChild>,
6556 reason: String,
6557) -> Result<(), SuperviseError> {
6558 let mut should_retry = false;
6559 let now = Instant::now();
6560 update_snapshot(snapshot, Some(&spec.module_id), |state| {
6561 clear_current_process_facts(state);
6562 if daemon_will_restart(state, &runtime.restart_policy, now) {
6563 state.record_crash_restart(&runtime.restart_policy, now);
6564 state.state = ModuleState::Restarting;
6565 should_retry = true;
6566 } else if state.enabled {
6567 state.state = ModuleState::Failed;
6568 } else {
6569 state.state = ModuleState::Disabled;
6570 }
6571 })?;
6572
6573 if should_retry {
6574 sleep(runtime.restart_policy.backoff).await;
6575 if respawn_still_pending(snapshot) {
6579 process_liveness.track(spec.module_id.clone(), Arc::clone(snapshot));
6580 match spawn_and_mark_running(spec, runtime, snapshot) {
6581 Ok(next_child) => {
6582 *child = Some(next_child);
6583 }
6584 Err(err) => {
6585 fail_snapshot(snapshot, Some(&spec.module_id), None);
6586 process_liveness.untrack_if_current(&spec.module_id, snapshot);
6587 return Err(SuperviseError::ReloadFailed {
6588 module_id: spec.module_id.clone(),
6589 reason: format!("{reason}; policy retry spawn failed: {err}"),
6590 });
6591 }
6592 }
6593 }
6594 } else {
6595 process_liveness.untrack_if_current(&spec.module_id, snapshot);
6596 }
6597
6598 Err(SuperviseError::ReloadFailed {
6599 module_id: spec.module_id.clone(),
6600 reason,
6601 })
6602}
6603
6604fn control_flags() -> Flags {
6605 Flags::new(false, Priority::Passive, false)
6606}
6607
6608#[allow(clippy::too_many_arguments)]
6609async fn drain_optional_child(
6610 module_id: &str,
6611 protocol: ModuleProtocol,
6612 registry: &Registry,
6613 snapshot: &SharedSnapshot,
6614 terminal_ring: &Arc<Mutex<TerminalRing>>,
6615 spawn_events: &SpawnEventFeed,
6616 child: &mut Option<SupervisedChild>,
6617 drain_timeout: Duration,
6618 final_state: ModuleState,
6619 enabled: Option<bool>,
6620) -> Result<(), SuperviseError> {
6621 if let Some(child) = child.take() {
6622 drain_child_to_state(
6623 module_id,
6624 protocol,
6625 registry,
6626 snapshot,
6627 terminal_ring,
6628 spawn_events,
6629 child,
6630 drain_timeout,
6631 final_state,
6632 enabled,
6633 )
6634 .await
6635 } else {
6636 update_snapshot(snapshot, Some(module_id), |state| {
6637 state.state = final_state;
6638 if let Some(enabled) = enabled {
6639 state.enabled = enabled;
6640 }
6641 clear_current_process_facts(state);
6642 })?;
6643 wait_for_registration_release(registry, module_id, REGISTRY_RELEASE_TIMEOUT).await
6644 }
6645}
6646
6647#[allow(clippy::too_many_arguments)]
6648async fn drain_child_to_state(
6649 module_id: &str,
6650 protocol: ModuleProtocol,
6651 registry: &Registry,
6652 snapshot: &SharedSnapshot,
6653 terminal_ring: &Arc<Mutex<TerminalRing>>,
6654 spawn_events: &SpawnEventFeed,
6655 mut child: SupervisedChild,
6656 drain_timeout: Duration,
6657 final_state: ModuleState,
6658 enabled: Option<bool>,
6659) -> Result<(), SuperviseError> {
6660 update_snapshot(snapshot, Some(module_id), |state| {
6661 state.state = ModuleState::Draining;
6662 if let Some(enabled) = enabled {
6663 state.enabled = enabled;
6664 }
6665 })?;
6666
6667 if protocol == ModuleProtocol::None {
6673 request_graceful_stop(module_id, &child);
6674 }
6675
6676 let exit_report = match timeout(drain_timeout, child.wait()).await {
6677 Ok(Ok(status)) => classify_reaped_child_exit(snapshot, &child, &status),
6678 Ok(Err(source)) => {
6679 fail_snapshot(snapshot, Some(module_id), None);
6680 return Err(SuperviseError::Wait {
6681 module_id: module_id.to_string(),
6682 source,
6683 });
6684 }
6685 Err(_) => {
6686 child.start_kill().map_err(|source| {
6695 fail_snapshot(snapshot, Some(module_id), None);
6696 SuperviseError::Kill {
6697 module_id: module_id.to_string(),
6698 source,
6699 }
6700 })?;
6701 let status = child.wait().await.map_err(|source| {
6702 fail_snapshot(snapshot, Some(module_id), None);
6703 SuperviseError::Wait {
6704 module_id: module_id.to_string(),
6705 source,
6706 }
6707 })?;
6708 classify_reaped_child_exit(snapshot, &child, &status)
6709 }
6710 };
6711
6712 update_snapshot(snapshot, Some(module_id), |state| {
6713 state.state = final_state;
6714 if let Some(enabled) = enabled {
6715 state.enabled = enabled;
6716 }
6717 clear_current_process_facts(state);
6718 state.last_exit = Some(exit_report.clone());
6719 if exit_report.kind == ExitKind::DeliberateSeverance {
6720 state.lifetime_restarts += 1;
6721 }
6722 })?;
6723 record_terminal(
6724 module_id,
6725 terminal_ring,
6726 spawn_events,
6727 &exit_report,
6728 terminal_disposition(final_state),
6729 );
6730 child.drain_stderr(module_id).await;
6731
6732 wait_for_registration_release(registry, module_id, REGISTRY_RELEASE_TIMEOUT).await
6733}
6734
6735#[cfg(unix)]
6755fn request_graceful_stop(module_id: &str, child: &SupervisedChild) {
6756 let Some(pid) = child
6757 .id()
6758 .and_then(|pid| i32::try_from(pid).ok())
6759 .and_then(rustix::process::Pid::from_raw)
6760 else {
6761 debug!(
6762 module_id,
6763 "no pid to signal for protocol: none teardown; falling through to the drain wait"
6764 );
6765 return;
6766 };
6767 match rustix::process::kill_process(pid, rustix::process::Signal::TERM) {
6768 Ok(()) => debug!(module_id, "sent SIGTERM to protocol: none module"),
6769 Err(err) => debug!(
6770 module_id,
6771 error = %err,
6772 "SIGTERM to protocol: none module failed; the drain wait and kill still apply"
6773 ),
6774 }
6775}
6776
6777#[cfg(not(unix))]
6785fn request_graceful_stop(module_id: &str, _child: &SupervisedChild) {
6786 debug!(
6787 module_id,
6788 "no graceful stop signal exists on this platform; protocol: none teardown waits, then kills"
6789 );
6790}
6791
6792fn terminal_disposition(final_state: ModuleState) -> TerminalDisposition {
6793 match final_state {
6794 ModuleState::Stopped => TerminalDisposition::Stopped,
6795 ModuleState::Disabled => TerminalDisposition::Disabled,
6796 ModuleState::Restarting => TerminalDisposition::Restarting,
6797 ModuleState::Failed => TerminalDisposition::Failed,
6798 ModuleState::Starting
6799 | ModuleState::Running
6800 | ModuleState::Unresponsive
6801 | ModuleState::Draining => {
6802 unreachable!("terminal exits only finish in terminal or restarting states")
6803 }
6804 }
6805}
6806
6807async fn wait_for_registration_release(
6810 registry: &Registry,
6811 module_id: &str,
6812 wait: Duration,
6813) -> Result<(), SuperviseError> {
6814 wait_for_slot_registration_release(
6815 registry,
6816 crate::registry::RegistrationSlot::Active(module_id),
6817 wait,
6818 )
6819 .await
6820}
6821
6822async fn wait_for_slot_registration_release(
6830 registry: &Registry,
6831 slot: crate::registry::RegistrationSlot<'_>,
6832 wait: Duration,
6833) -> Result<(), SuperviseError> {
6834 let deadline = Instant::now() + wait;
6835 let mut release_events = registration_release_events().subscribe();
6836 let still_active = |registration: &crate::registry::ModuleRegistration| {
6837 SuperviseError::RegistrationStillActive {
6838 module_id: registration.manifest.module_id.clone(),
6839 waited: wait,
6840 }
6841 };
6842 loop {
6843 let _observed_generation = *release_events.borrow_and_update();
6844 let Some(registration) = registry
6845 .registration(slot)
6846 .map_err(SuperviseError::Registry)?
6847 else {
6848 return Ok(());
6849 };
6850
6851 let now = Instant::now();
6852 if now >= deadline {
6853 return Err(still_active(®istration));
6854 }
6855
6856 let remaining = deadline.saturating_duration_since(now);
6857 match timeout(remaining, release_events.changed()).await {
6858 Ok(Ok(())) | Ok(Err(_)) => {}
6859 Err(_) => return Err(still_active(®istration)),
6860 }
6861 }
6862}
6863
6864#[cfg(test)]
6865mod slot_registration_wait_tests {
6866 use super::*;
6867 use crate::registry::{ConnectionId, RegistrationSlot};
6868 use subc_protocol::manifest::ModuleManifest;
6869
6870 const INCUMBENT: u64 = 1;
6871 const CANDIDATE: u64 = 2;
6872
6873 fn swapped_registry() -> Arc<Registry> {
6874 let registry = Arc::new(Registry::default());
6875 let manifest = ModuleManifest::builder("m", "0.1.0").build();
6876 registry
6877 .register_with_control_ops(
6878 manifest.clone(),
6879 1,
6880 ConnectionId::new(INCUMBENT),
6881 Vec::new(),
6882 )
6883 .unwrap();
6884 registry
6885 .register_candidate_with_control_ops(
6886 manifest,
6887 1,
6888 ConnectionId::new(CANDIDATE),
6889 Vec::new(),
6890 )
6891 .unwrap();
6892 registry
6893 }
6894
6895 #[tokio::test]
6899 async fn incumbent_release_is_awaited_by_connection_not_by_module_id() {
6900 let registry = swapped_registry();
6901 registry.promote_candidate("m").unwrap().unwrap();
6902
6903 assert!(matches!(
6904 wait_for_registration_release(®istry, "m", Duration::from_millis(50)).await,
6905 Err(SuperviseError::RegistrationStillActive { .. })
6906 ));
6907
6908 assert!(matches!(
6910 wait_for_slot_registration_release(
6911 ®istry,
6912 RegistrationSlot::Connection(ConnectionId::new(INCUMBENT)),
6913 Duration::from_millis(50),
6914 )
6915 .await,
6916 Err(SuperviseError::RegistrationStillActive { .. })
6917 ));
6918
6919 let releaser = Arc::clone(®istry);
6920 let release = tokio::spawn(async move {
6921 sleep(Duration::from_millis(20)).await;
6922 releaser
6923 .deregister_connection(ConnectionId::new(INCUMBENT))
6924 .unwrap();
6925 notify_registration_release();
6926 });
6927 wait_for_slot_registration_release(
6928 ®istry,
6929 RegistrationSlot::Connection(ConnectionId::new(INCUMBENT)),
6930 Duration::from_secs(5),
6931 )
6932 .await
6933 .expect("the incumbent's own registration is released");
6934 release.await.unwrap();
6935 assert!(registry.get_module("m").unwrap().is_some());
6936 }
6937
6938 #[tokio::test]
6941 async fn candidate_slot_wait_ignores_the_incumbents_registration() {
6942 let registry = swapped_registry();
6943 assert!(matches!(
6944 wait_for_slot_registration_release(
6945 ®istry,
6946 RegistrationSlot::Candidate("m"),
6947 Duration::from_millis(50),
6948 )
6949 .await,
6950 Err(SuperviseError::RegistrationStillActive { .. })
6951 ));
6952 registry
6953 .deregister_connection(ConnectionId::new(CANDIDATE))
6954 .unwrap();
6955 wait_for_slot_registration_release(
6956 ®istry,
6957 RegistrationSlot::Candidate("m"),
6958 Duration::from_millis(50),
6959 )
6960 .await
6961 .expect("a candidate slot with no candidate is released");
6962 assert!(registry
6963 .registration(RegistrationSlot::Active("m"))
6964 .unwrap()
6965 .is_some());
6966 }
6967}
6968
6969fn classify_exit(status: &ExitStatus) -> ExitReport {
6970 ExitReport {
6971 kind: if status.success() {
6972 ExitKind::Clean
6973 } else {
6974 ExitKind::Crash
6975 },
6976 code: status.code(),
6977 signal: exit_signal(status),
6978 at_ms: unix_ms_now(),
6979 }
6980}
6981
6982fn wait_error_exit_report() -> ExitReport {
6988 ExitReport {
6989 kind: ExitKind::Crash,
6990 code: None,
6991 signal: None,
6992 at_ms: unix_ms_now(),
6993 }
6994}
6995
6996#[cfg(unix)]
6997fn exit_signal(status: &ExitStatus) -> Option<i32> {
6998 use std::os::unix::process::ExitStatusExt;
6999
7000 status.signal()
7001}
7002
7003#[cfg(not(unix))]
7004fn exit_signal(_status: &ExitStatus) -> Option<i32> {
7005 None
7006}
7007
7008fn reset_restart_count(snapshot: &SharedSnapshot, module_id: &str) -> Result<(), SuperviseError> {
7014 update_snapshot(snapshot, Some(module_id), |state| {
7015 state.clear_crash_restarts();
7016 })
7017}
7018
7019fn set_running(
7020 snapshot: &SharedSnapshot,
7021 child: &SupervisedChild,
7022 module_id: &str,
7023 spawn_events: &SpawnEventFeed,
7024) -> Result<(), SuperviseError> {
7025 let mut state = snapshot.lock().map_err(|_| SuperviseError::StatePoisoned {
7026 module_id: Some(module_id.to_string()),
7027 })?;
7028 state.spawn_generation = spawn_events.emit_spawned(module_id, child.pid, child.spawned_at_ms);
7029 state.in_alternate_slot = false;
7032 state.state = ModuleState::Running;
7033 state.enabled = true;
7034 state.process_alive = true;
7035 state.pid = child.id();
7036 state.spawned_at_ms = Some(child.spawned_at_ms);
7037 state.spawned_from = Some(child.spawned_from.clone());
7038 state.spawned_file_identity = child.spawned_file_identity;
7039 state.process_start_time = child.process_start_time;
7040 Ok(())
7041}
7042
7043fn clear_current_process_facts(state: &mut SupervisorSnapshot) {
7044 state.process_alive = false;
7045 state.pid = None;
7046 state.spawned_at_ms = None;
7047 state.spawned_from = None;
7048 state.spawned_file_identity = None;
7049 state.process_start_time = None;
7050 state.deliberate_severance = None;
7051}
7052
7053#[cfg(test)]
7054fn record_deliberate_severance(
7055 snapshot: &SharedSnapshot,
7056 identity: ProcessIdentity,
7057) -> Result<(), SuperviseError> {
7058 update_snapshot(snapshot, None, |state| {
7059 state.deliberate_severance = Some(identity);
7060 })
7061}
7062
7063fn apply_deliberate_severance_marker(
7064 snapshot: &SharedSnapshot,
7065 exited_identity: Option<ProcessIdentity>,
7066 mut exit_report: ExitReport,
7067) -> ExitReport {
7068 let marker = lock_snapshot(snapshot)
7069 .ok()
7070 .and_then(|mut state| state.deliberate_severance.take());
7071 if marker.is_some() && marker == exited_identity {
7072 exit_report.kind = ExitKind::DeliberateSeverance;
7073 }
7074 exit_report
7075}
7076
7077fn classify_reaped_child_exit(
7078 snapshot: &SharedSnapshot,
7079 child: &SupervisedChild,
7080 status: &ExitStatus,
7081) -> ExitReport {
7082 apply_deliberate_severance_marker(snapshot, child.process_identity(), classify_exit(status))
7083}
7084
7085fn fail_snapshot(
7086 snapshot: &SharedSnapshot,
7087 module_id: Option<&str>,
7088 last_exit: Option<ExitReport>,
7089) {
7090 if let Err(err) = update_snapshot(snapshot, module_id, |state| {
7091 state.state = ModuleState::Failed;
7092 clear_current_process_facts(state);
7093 if let Some(last_exit) = last_exit {
7094 state.last_exit = Some(last_exit);
7095 }
7096 }) {
7097 error!(error = %err, "failed to mark supervisor state failed");
7098 }
7099}
7100
7101fn update_snapshot(
7102 snapshot: &SharedSnapshot,
7103 module_id: Option<&str>,
7104 update: impl FnOnce(&mut SupervisorSnapshot),
7105) -> Result<(), SuperviseError> {
7106 let mut state = snapshot.lock().map_err(|_| SuperviseError::StatePoisoned {
7107 module_id: module_id.map(ToOwned::to_owned),
7108 })?;
7109 update(&mut state);
7110 Ok(())
7111}
7112
7113const SLOW_SNAPSHOT_LOCK_THRESHOLD: Duration = Duration::from_millis(250);
7114
7115fn lock_snapshot_for_control<'a>(
7116 snapshot: &'a SharedSnapshot,
7117 module_id: &str,
7118 caller: &'static str,
7119) -> Result<std::sync::MutexGuard<'a, SupervisorSnapshot>, SuperviseError> {
7120 let started_at = Instant::now();
7121 let guard = lock_snapshot(snapshot)?;
7122 let waited = started_at.elapsed();
7123 if waited >= SLOW_SNAPSHOT_LOCK_THRESHOLD {
7124 warn!(
7125 module_id = %module_id,
7126 waited_ms = waited.as_millis() as u64,
7127 caller = %caller,
7128 "slow snapshot lock"
7129 );
7130 }
7131 Ok(guard)
7132}
7133
7134fn lock_snapshot(
7135 snapshot: &SharedSnapshot,
7136) -> Result<std::sync::MutexGuard<'_, SupervisorSnapshot>, SuperviseError> {
7137 snapshot
7138 .lock()
7139 .map_err(|_| SuperviseError::StatePoisoned { module_id: None })
7140}
7141
7142#[cfg(test)]
7143mod terminal_history_tests {
7144 use std::{
7145 path::PathBuf,
7146 sync::Arc,
7147 time::{Duration, Instant},
7148 };
7149
7150 use tokio::time::sleep;
7151
7152 use super::{
7153 apply_deliberate_severance_marker, daemon_will_restart, drain_child_to_state,
7154 drained_after_quiescence_wait, handle_reload_spawn_failure, health_restart_child,
7155 lock_snapshot, on_child_exit, record_deliberate_severance, record_wait_error_terminal,
7156 reset_restart_count, spawn_and_mark_running, update_snapshot, wait_error_exit_report,
7157 ExitKind, ExitReport, ModuleProtocol, ModuleSpec, ModuleState, NextAction, ProcessIdentity,
7158 RestartPolicy, SpawnEventKind, SuperviseError, SupervisedModule, Supervisor,
7159 SupervisorHandle, SupervisorHealthStatus, SupervisorSnapshot,
7160 };
7161 use super::Instant as ClockInstant;
7166 use crate::{
7167 registry::Registry,
7168 terminal_ring::{TerminalRing, TerminalRingConfig},
7169 };
7170 use std::sync::Mutex;
7171 use subc_control::TerminalDisposition;
7172
7173 fn fake_aft_stub_path() -> PathBuf {
7178 let mut path = std::env::current_exe().expect("current_exe available in tests");
7179 path.pop();
7180 path.pop();
7181 path.push(if cfg!(windows) {
7182 "fake-aft-stub.exe"
7183 } else {
7184 "fake-aft-stub"
7185 });
7186 assert!(
7187 path.exists(),
7188 "fake-aft-stub not built at {}: run `cargo test -p subc-core` (which builds \
7189 [[bin]] targets) rather than `cargo test -p subc-core --lib` (which does not)",
7190 path.display()
7191 );
7192 path
7193 }
7194
7195 #[test]
7196 fn reserved_never_spawned_refuses_every_hello() {
7197 let supervisor = SupervisorHandle::default();
7202 supervisor.apply_identity_configuration(&ModuleSpec {
7203 module_id: "never-spawned".to_string(),
7204 program: PathBuf::from("/usr/bin/false"),
7205 args: Vec::new(),
7206 env: Vec::new(),
7207 reserved: true,
7208 reserved_prefixes: Vec::new(),
7209 protocol: ModuleProtocol::Subc,
7210 overlap: Default::default(),
7211 });
7212 assert!(
7213 supervisor
7214 .reserved_hello_rejection("never-spawned", Some("any-forged-nonce"))
7215 .is_some(),
7216 "forged nonce must refuse on a reserved never-spawned id"
7217 );
7218 assert!(
7219 supervisor
7220 .reserved_hello_rejection("never-spawned", None)
7221 .is_some(),
7222 "absent nonce must refuse on a reserved never-spawned id"
7223 );
7224 supervisor.set_spawn_nonce("never-spawned", "minted".to_string());
7226 supervisor.apply_identity_configuration(&ModuleSpec {
7227 module_id: "never-spawned".to_string(),
7228 program: PathBuf::from("/usr/bin/false"),
7229 args: Vec::new(),
7230 env: Vec::new(),
7231 reserved: true,
7232 reserved_prefixes: Vec::new(),
7233 protocol: ModuleProtocol::Subc,
7234 overlap: Default::default(),
7235 });
7236 assert!(supervisor
7237 .reserved_hello_rejection("never-spawned", Some("minted"))
7238 .is_none());
7239 assert!(supervisor
7240 .reserved_hello_rejection("never-spawned", Some("forged"))
7241 .is_some());
7242 }
7243
7244 fn seed_crash_restarts(state: &mut SupervisorSnapshot, count: u32) {
7247 let now = ClockInstant::now();
7248 for _ in 0..count {
7249 state.crash_restarts.push_back(now);
7250 }
7251 }
7252
7253 fn age_oldest_crash_restart_out_of_window(state: &mut SupervisorSnapshot, window: Duration) {
7257 let aged = state
7258 .crash_restarts
7259 .front()
7260 .expect("a crash restart must be recorded before it can be aged")
7261 .checked_sub(window + Duration::from_secs(1))
7262 .expect("the test clock is far enough from its origin to age an instant");
7263 state.crash_restarts[0] = aged;
7264 }
7265
7266 fn snapshot_with_restarts(enabled: bool, count: u32) -> SupervisorSnapshot {
7267 let mut state = SupervisorSnapshot::new(ModuleState::Running, enabled);
7268 seed_crash_restarts(&mut state, count);
7269 state
7270 }
7271
7272 #[test]
7273 fn daemon_owned_recovery_predicate_uses_the_pre_increment_budget() {
7274 let policy = RestartPolicy::new(3, Duration::ZERO);
7275 let now = ClockInstant::now();
7276 assert!(daemon_will_restart(
7277 &mut snapshot_with_restarts(true, 2),
7278 &policy,
7279 now
7280 ));
7281 assert!(!daemon_will_restart(
7282 &mut snapshot_with_restarts(true, 3),
7283 &policy,
7284 now
7285 ));
7286 assert!(!daemon_will_restart(
7287 &mut snapshot_with_restarts(false, 0),
7288 &policy,
7289 now
7290 ));
7291 }
7292
7293 #[test]
7294 fn crash_restart_backoff_escalates_with_in_window_count() {
7295 let policy = RestartPolicy::new(4, Duration::from_millis(100))
7296 .with_max_backoff(Duration::from_secs(30));
7297 let now = ClockInstant::now();
7298 let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
7299 let schedules = (0..4)
7300 .map(|_| {
7301 state
7302 .next_crash_restart(&policy, now)
7303 .expect("the test policy allows four crash restarts")
7304 })
7305 .collect::<Vec<_>>();
7306
7307 assert_eq!(
7308 schedules
7309 .iter()
7310 .map(|schedule| schedule.restart_in_window)
7311 .collect::<Vec<_>>(),
7312 vec![0, 1, 2, 3]
7313 );
7314 assert_eq!(
7315 schedules
7316 .iter()
7317 .map(|schedule| schedule.delay)
7318 .collect::<Vec<_>>(),
7319 vec![
7320 Duration::from_millis(100),
7321 Duration::from_secs(1),
7322 Duration::from_secs(10),
7323 Duration::from_secs(30),
7324 ]
7325 );
7326 }
7327
7328 #[test]
7329 fn crash_restart_backoff_resets_after_ring_clear() {
7330 let policy = RestartPolicy::new(3, Duration::from_millis(100));
7331 let now = ClockInstant::now();
7332 let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
7333 assert_eq!(
7334 state.next_crash_restart(&policy, now).unwrap().delay,
7335 Duration::from_millis(100)
7336 );
7337 assert_eq!(
7338 state.next_crash_restart(&policy, now).unwrap().delay,
7339 Duration::from_secs(1)
7340 );
7341
7342 state.clear_crash_restarts();
7343 let schedule = state
7344 .next_crash_restart(&policy, now)
7345 .expect("a cleared ring must allow another restart");
7346 assert_eq!(schedule.restart_in_window, 0);
7347 assert_eq!(schedule.delay, Duration::from_millis(100));
7348 }
7349
7350 #[test]
7351 fn crash_restart_backoff_ignores_aged_restarts() {
7352 let policy = RestartPolicy::new(3, Duration::from_millis(100));
7353 let now = ClockInstant::now();
7354 let mut state = SupervisorSnapshot::new(ModuleState::Running, true);
7355 state
7356 .next_crash_restart(&policy, now)
7357 .expect("the first restart is allowed");
7358 state
7359 .next_crash_restart(&policy, now)
7360 .expect("the second restart is allowed");
7361 state.crash_restarts[0] = now
7362 .checked_sub(policy.window + Duration::from_secs(1))
7363 .expect("the fake clock can age a restart past the window");
7364
7365 let schedule = state
7366 .next_crash_restart(&policy, now)
7367 .expect("an aged restart must release its slot");
7368 assert_eq!(schedule.restart_in_window, 1);
7369 assert_eq!(schedule.delay, Duration::from_secs(1));
7370 assert_eq!(state.crash_restarts.len(), 2);
7371 }
7372
7373 #[test]
7377 fn a_budget_spent_before_the_window_no_longer_refuses() {
7378 let policy = RestartPolicy::new(3, Duration::ZERO);
7379 let mut state = snapshot_with_restarts(true, 3);
7380 let now = ClockInstant::now();
7381 assert!(!daemon_will_restart(&mut state, &policy, now));
7382
7383 assert!(daemon_will_restart(
7384 &mut state,
7385 &policy,
7386 now + policy.window + Duration::from_secs(1)
7387 ));
7388 assert!(
7389 state.crash_restarts.is_empty(),
7390 "reading the budget must drop the instants that left the window"
7391 );
7392 }
7393
7394 fn module_with_recovery_snapshot(
7395 state: ModuleState,
7396 enabled: bool,
7397 restart_count: u32,
7398 ) -> SupervisedModule {
7399 let registry = Arc::new(Registry::default());
7400 let supervisor =
7401 Supervisor::new(Arc::clone(®istry), RestartPolicy::new(3, Duration::ZERO));
7402 let module = supervisor
7403 .spawn(ModuleSpec {
7404 module_id: "recovery-snapshot".to_string(),
7405 program: fake_aft_stub_path(),
7406 args: Vec::new(),
7407 env: Vec::new(),
7408 reserved: false,
7409 reserved_prefixes: Vec::new(),
7410 protocol: ModuleProtocol::Subc,
7411 overlap: Default::default(),
7412 })
7413 .unwrap();
7414 update_snapshot(
7415 &module.inner.snapshot,
7416 Some("recovery-snapshot"),
7417 |snapshot| {
7418 snapshot.state = state;
7419 snapshot.enabled = enabled;
7420 seed_crash_restarts(snapshot, restart_count);
7421 },
7422 )
7423 .unwrap();
7424 module
7425 }
7426
7427 #[cfg(target_os = "linux")]
7428 #[tokio::test]
7429 async fn no_cgroup_placement_does_not_block_fake_aft_stub_spawn() {
7430 let supervisor = Supervisor::new(Arc::new(Registry::default()), RestartPolicy::default())
7431 .with_cgroup_placement(None);
7432 let result = supervisor.spawn(ModuleSpec {
7433 module_id: "no-cgroup-placement".to_string(),
7434 program: fake_aft_stub_path(),
7435 args: Vec::new(),
7436 env: Vec::new(),
7437 reserved: false,
7438 reserved_prefixes: Vec::new(),
7439 protocol: ModuleProtocol::Subc,
7440 overlap: Default::default(),
7441 });
7442
7443 assert!(
7444 result.is_ok(),
7445 "no delegation must not turn an otherwise valid spawn into a failure: {result:?}"
7446 );
7447 }
7448
7449 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
7450 async fn undecided_snapshot_uses_shared_restart_predicate() {
7451 assert!(module_with_recovery_snapshot(ModuleState::Running, true, 2)
7452 .will_recover_after_connection_loss()
7453 .unwrap());
7454 assert!(
7455 !module_with_recovery_snapshot(ModuleState::Running, true, 3)
7456 .will_recover_after_connection_loss()
7457 .unwrap()
7458 );
7459 }
7460
7461 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
7462 async fn restarting_snapshot_at_exhausted_budget_is_non_terminal() {
7463 assert!(
7464 module_with_recovery_snapshot(ModuleState::Restarting, true, 3)
7465 .will_recover_after_connection_loss()
7466 .unwrap()
7467 );
7468 }
7469
7470 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
7471 async fn terminal_phase_snapshots_are_terminal_before_budget_exhaustion() {
7472 assert!(!module_with_recovery_snapshot(ModuleState::Failed, true, 0)
7473 .will_recover_after_connection_loss()
7474 .unwrap());
7475 assert!(
7476 !module_with_recovery_snapshot(ModuleState::Disabled, true, 0)
7477 .will_recover_after_connection_loss()
7478 .unwrap()
7479 );
7480 }
7481
7482 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
7483 async fn warming_snapshot_is_limited_to_startup_phases() {
7484 for state in [
7485 ModuleState::Starting,
7486 ModuleState::Running,
7487 ModuleState::Restarting,
7488 ] {
7489 assert!(
7490 module_with_recovery_snapshot(state, true, 0)
7491 .is_warming()
7492 .unwrap(),
7493 "{state:?} should be warming"
7494 );
7495 }
7496 for state in [
7497 ModuleState::Unresponsive,
7498 ModuleState::Draining,
7499 ModuleState::Stopped,
7500 ModuleState::Failed,
7501 ModuleState::Disabled,
7502 ] {
7503 assert!(
7504 !module_with_recovery_snapshot(state, true, 0)
7505 .is_warming()
7506 .unwrap(),
7507 "{state:?} should not be warming"
7508 );
7509 }
7510 }
7511
7512 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
7513 async fn terminal_history_survives_respawn_and_keeps_both_crashes_in_order() {
7514 let registry = Arc::new(Registry::default());
7515 let supervisor =
7516 Supervisor::new(Arc::clone(®istry), RestartPolicy::new(1, Duration::ZERO));
7517 let module = supervisor
7518 .spawn(ModuleSpec {
7519 module_id: "terminal-history".to_string(),
7520 program: fake_aft_stub_path(),
7521 args: Vec::new(),
7522 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
7523 reserved: false,
7524 reserved_prefixes: Vec::new(),
7525 protocol: ModuleProtocol::Subc,
7526 overlap: Default::default(),
7527 })
7528 .unwrap();
7529
7530 let deadline = Instant::now() + Duration::from_secs(5);
7531 loop {
7532 let history = module.terminal_history();
7533 if history.entries.len() == 2 {
7534 assert_eq!(module.status().unwrap().state, ModuleState::Failed);
7535 assert_eq!(history.dropped, 0);
7536 assert_eq!(
7537 history
7538 .entries
7539 .iter()
7540 .map(|entry| entry.exit_code)
7541 .collect::<Vec<_>>(),
7542 vec![Some(23), Some(23)]
7543 );
7544 assert!(history.entries[0].at_ms <= history.entries[1].at_ms);
7545 return;
7546 }
7547 assert!(
7548 Instant::now() < deadline,
7549 "module did not retain two terminal exits: {history:?}"
7550 );
7551 sleep(Duration::from_millis(10)).await;
7552 }
7553 }
7554
7555 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
7559 async fn disable_during_crash_backoff_cancels_pending_respawn() {
7560 let backoff = Duration::from_secs(2);
7561 let supervisor = Supervisor::new(
7562 Arc::new(Registry::default()),
7563 RestartPolicy::new(10, backoff),
7564 );
7565 let module = supervisor
7566 .spawn(ModuleSpec {
7567 module_id: "disable-during-backoff".to_string(),
7568 program: fake_aft_stub_path(),
7569 args: Vec::new(),
7570 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
7571 reserved: false,
7572 reserved_prefixes: Vec::new(),
7573 protocol: ModuleProtocol::Subc,
7574 overlap: Default::default(),
7575 })
7576 .unwrap();
7577
7578 let deadline = Instant::now() + Duration::from_secs(5);
7580 loop {
7581 if module.status().unwrap().state == ModuleState::Restarting {
7582 break;
7583 }
7584 assert!(
7585 Instant::now() < deadline,
7586 "module never entered the crash backoff"
7587 );
7588 sleep(Duration::from_millis(10)).await;
7589 }
7590
7591 let started = Instant::now();
7592 module.set_enabled(false).await.unwrap();
7593 let waited = started.elapsed();
7594
7595 assert!(
7596 waited < backoff / 2,
7597 "disable waited {waited:?} behind the {backoff:?} crash backoff; the operator command must preempt the pending respawn"
7598 );
7599 assert_eq!(module.status().unwrap().state, ModuleState::Disabled);
7600
7601 sleep(backoff + Duration::from_millis(500)).await;
7603 let status = module.status().unwrap();
7604 assert_eq!(status.state, ModuleState::Disabled);
7605 assert_eq!(
7606 status.spawn_generation, 1,
7607 "module respawned after the operator disabled it"
7608 );
7609 }
7610
7611 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
7615 async fn every_restart_increment_path_advances_lifetime_count() {
7616 let supervisor = Supervisor::new(
7617 Arc::new(Registry::default()),
7618 RestartPolicy::new(1, Duration::ZERO),
7619 );
7620 let runtime = supervisor.runtime_config();
7621 let spec = ModuleSpec {
7622 module_id: "lifetime-increment-path".to_string(),
7623 program: PathBuf::from("/unused/lifetime-increment-path"),
7624 args: Vec::new(),
7625 env: Vec::new(),
7626 reserved: false,
7627 reserved_prefixes: Vec::new(),
7628 protocol: ModuleProtocol::Subc,
7629 overlap: Default::default(),
7630 };
7631
7632 let crash_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
7633 assert!(matches!(
7634 on_child_exit(
7635 &spec,
7636 runtime.restart_policy,
7637 &supervisor.registry,
7638 &crash_snapshot,
7639 &runtime.terminal_ring,
7640 &runtime.spawn_events,
7641 &runtime.child_roster,
7642 ExitReport {
7643 kind: ExitKind::Crash,
7644 code: Some(1),
7645 signal: None,
7646 at_ms: 1,
7647 },
7648 )
7649 .await,
7650 NextAction::Restart { schedule: _ }
7651 ));
7652 let (crash_restarts, crash_lifetime) = {
7653 let state = lock_snapshot(&crash_snapshot).unwrap();
7654 (state.crash_restarts.len(), state.lifetime_restarts)
7655 };
7656 assert_eq!(crash_restarts, 1);
7657 assert_eq!(crash_lifetime, 1);
7658
7659 let health_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
7660 let mut health_child = None;
7661 assert!(matches!(
7662 health_restart_child(
7663 &spec,
7664 &runtime,
7665 &supervisor.registry,
7666 &supervisor.process_liveness,
7667 &health_snapshot,
7668 &mut health_child,
7669 SupervisorHealthStatus::Failing,
7670 None,
7671 2,
7672 )
7673 .await,
7674 Err(SuperviseError::Spawn { .. })
7675 ));
7676 let (health_restarts, health_lifetime) = {
7677 let state = lock_snapshot(&health_snapshot).unwrap();
7678 (state.crash_restarts.len(), state.lifetime_restarts)
7679 };
7680 assert_eq!(health_restarts, 1);
7681 assert_eq!(health_lifetime, 1);
7682
7683 let reload_snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
7684 let mut reload_child = None;
7685 assert!(matches!(
7686 handle_reload_spawn_failure(
7687 &spec,
7688 &runtime,
7689 &supervisor.process_liveness,
7690 &reload_snapshot,
7691 &mut reload_child,
7692 "forced reload spawn failure".to_string(),
7693 )
7694 .await,
7695 Err(SuperviseError::ReloadFailed { .. })
7696 ));
7697 let (reload_restarts, reload_lifetime) = {
7698 let state = lock_snapshot(&reload_snapshot).unwrap();
7699 (state.crash_restarts.len(), state.lifetime_restarts)
7700 };
7701 assert_eq!(reload_restarts, 1);
7702 assert_eq!(reload_lifetime, 1);
7703 }
7704
7705 #[tokio::test]
7706 async fn deliberately_severed_live_child_records_lifetime_without_spending_restart_budget() {
7707 let supervisor = Supervisor::new(
7708 Arc::new(Registry::default()),
7709 RestartPolicy::new(3, Duration::ZERO),
7710 );
7711 let runtime = supervisor.runtime_config();
7712 let spec = ModuleSpec {
7713 module_id: "deliberately-severed".to_string(),
7714 program: PathBuf::from("/unused/deliberately-severed"),
7715 args: Vec::new(),
7716 env: Vec::new(),
7717 reserved: false,
7718 reserved_prefixes: Vec::new(),
7719 protocol: ModuleProtocol::Subc,
7720 overlap: Default::default(),
7721 };
7722 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
7723 let process = ProcessIdentity {
7724 pid: 41,
7725 start_time: 101,
7726 };
7727 record_deliberate_severance(&snapshot, process).unwrap();
7728 let exit_report = apply_deliberate_severance_marker(
7729 &snapshot,
7730 Some(process),
7731 ExitReport {
7732 kind: ExitKind::Crash,
7733 code: Some(1),
7734 signal: None,
7735 at_ms: 1,
7736 },
7737 );
7738 assert_eq!(exit_report.kind, ExitKind::DeliberateSeverance);
7739
7740 assert!(matches!(
7741 on_child_exit(
7742 &spec,
7743 runtime.restart_policy,
7744 &supervisor.registry,
7745 &snapshot,
7746 &runtime.terminal_ring,
7747 &runtime.spawn_events,
7748 &runtime.child_roster,
7749 exit_report,
7750 )
7751 .await,
7752 NextAction::Restart { schedule: _ }
7753 ));
7754 let state = lock_snapshot(&snapshot).unwrap();
7755 assert_eq!(state.lifetime_restarts, 1);
7756 assert_eq!(state.crash_restarts.len(), 0);
7757 }
7758
7759 #[tokio::test]
7760 async fn genuine_crash_spends_restart_budget_and_records_lifetime() {
7761 let supervisor = Supervisor::new(
7762 Arc::new(Registry::default()),
7763 RestartPolicy::new(3, Duration::ZERO),
7764 );
7765 let runtime = supervisor.runtime_config();
7766 let spec = ModuleSpec {
7767 module_id: "genuine-crash".to_string(),
7768 program: PathBuf::from("/unused/genuine-crash"),
7769 args: Vec::new(),
7770 env: Vec::new(),
7771 reserved: false,
7772 reserved_prefixes: Vec::new(),
7773 protocol: ModuleProtocol::Subc,
7774 overlap: Default::default(),
7775 };
7776 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
7777
7778 assert!(matches!(
7779 on_child_exit(
7780 &spec,
7781 runtime.restart_policy,
7782 &supervisor.registry,
7783 &snapshot,
7784 &runtime.terminal_ring,
7785 &runtime.spawn_events,
7786 &runtime.child_roster,
7787 ExitReport {
7788 kind: ExitKind::Crash,
7789 code: Some(1),
7790 signal: None,
7791 at_ms: 1,
7792 },
7793 )
7794 .await,
7795 NextAction::Restart { schedule: _ }
7796 ));
7797 let state = lock_snapshot(&snapshot).unwrap();
7798 assert_eq!(state.lifetime_restarts, 1);
7799 assert_eq!(state.crash_restarts.len(), 1);
7800 }
7801
7802 fn crash_exit_report(at_ms: u64) -> ExitReport {
7803 ExitReport {
7804 kind: ExitKind::Crash,
7805 code: Some(1),
7806 signal: None,
7807 at_ms,
7808 }
7809 }
7810
7811 fn windowed_crash_spec(module_id: &str) -> ModuleSpec {
7812 ModuleSpec {
7813 module_id: module_id.to_string(),
7814 program: PathBuf::from("/unused").join(module_id),
7815 args: Vec::new(),
7816 env: Vec::new(),
7817 reserved: false,
7818 reserved_prefixes: Vec::new(),
7819 protocol: ModuleProtocol::Subc,
7820 overlap: Default::default(),
7821 }
7822 }
7823
7824 #[tokio::test]
7830 async fn three_crashes_inside_the_window_stop_the_module_and_name_the_window() {
7831 let (logs, _guard) = crate::router::test_log::log_capture(tracing::Level::ERROR);
7832 let supervisor = Supervisor::new(
7833 Arc::new(Registry::default()),
7834 RestartPolicy::new(2, Duration::ZERO),
7835 );
7836 let runtime = supervisor.runtime_config();
7837 let spec = windowed_crash_spec("crash-loop-in-window");
7838 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
7839
7840 for attempt in 1..=2 {
7841 assert!(
7842 matches!(
7843 on_child_exit(
7844 &spec,
7845 runtime.restart_policy,
7846 &supervisor.registry,
7847 &snapshot,
7848 &runtime.terminal_ring,
7849 &runtime.spawn_events,
7850 &runtime.child_roster,
7851 crash_exit_report(attempt),
7852 )
7853 .await,
7854 NextAction::Restart { schedule: _ }
7855 ),
7856 "crash {attempt} is inside the budget and must respawn"
7857 );
7858 }
7859
7860 assert!(matches!(
7861 on_child_exit(
7862 &spec,
7863 runtime.restart_policy,
7864 &supervisor.registry,
7865 &snapshot,
7866 &runtime.terminal_ring,
7867 &runtime.spawn_events,
7868 &runtime.child_roster,
7869 crash_exit_report(3),
7870 )
7871 .await,
7872 NextAction::Stop { .. }
7873 ));
7874
7875 {
7876 let state = lock_snapshot(&snapshot).unwrap();
7877 assert_eq!(state.state, ModuleState::Failed);
7878 assert_eq!(state.crash_restarts.len(), 2);
7879 assert_eq!(state.lifetime_restarts, 2);
7880 }
7881
7882 let history = runtime
7883 .terminal_ring
7884 .lock()
7885 .expect("terminal ring is not poisoned")
7886 .snapshot();
7887 let last = history
7888 .entries
7889 .last()
7890 .expect("the refused crash is retained");
7891 assert_eq!(last.disposition, TerminalDisposition::Failed);
7892 assert_eq!(
7893 last.disposition_detail.as_deref(),
7894 Some("crash budget exhausted: max_restarts=2 within window_secs=600")
7895 );
7896
7897 let captured = crate::router::test_log::captured_logs(&logs);
7898 assert!(
7899 captured.contains("crash budget exhausted: max_restarts=2 within window_secs=600"),
7900 "the stop must be logged with its window: {captured}"
7901 );
7902 }
7903
7904 #[tokio::test]
7912 async fn a_crash_older_than_the_window_frees_its_slot_for_a_later_crash() {
7913 let supervisor = Supervisor::new(
7914 Arc::new(Registry::default()),
7915 RestartPolicy::new(2, Duration::ZERO),
7916 );
7917 let runtime = supervisor.runtime_config();
7918 let spec = windowed_crash_spec("crash-across-windows");
7919 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
7920
7921 for attempt in 1..=2 {
7922 assert!(matches!(
7923 on_child_exit(
7924 &spec,
7925 runtime.restart_policy,
7926 &supervisor.registry,
7927 &snapshot,
7928 &runtime.terminal_ring,
7929 &runtime.spawn_events,
7930 &runtime.child_roster,
7931 crash_exit_report(attempt),
7932 )
7933 .await,
7934 NextAction::Restart { schedule: _ }
7935 ));
7936 }
7937
7938 update_snapshot(&snapshot, Some(&spec.module_id), |state| {
7941 age_oldest_crash_restart_out_of_window(state, runtime.restart_policy.window);
7942 })
7943 .unwrap();
7944
7945 assert!(
7946 matches!(
7947 on_child_exit(
7948 &spec,
7949 runtime.restart_policy,
7950 &supervisor.registry,
7951 &snapshot,
7952 &runtime.terminal_ring,
7953 &runtime.spawn_events,
7954 &runtime.child_roster,
7955 crash_exit_report(3),
7956 )
7957 .await,
7958 NextAction::Restart { schedule: _ }
7959 ),
7960 "a crash older than the window must not hold a budget slot"
7961 );
7962
7963 let state = lock_snapshot(&snapshot).unwrap();
7964 assert_eq!(state.state, ModuleState::Restarting);
7965 assert_eq!(
7966 state.crash_restarts.len(),
7967 2,
7968 "the aged instant is dropped and the new one takes its place"
7969 );
7970 assert_eq!(
7971 state.lifetime_restarts, 3,
7972 "the ledger counts every restart, including the ones the window forgot"
7973 );
7974 }
7975
7976 #[tokio::test]
7981 async fn an_operator_restart_clears_the_ring_and_leaves_the_ledger_alone() {
7982 let supervisor = Supervisor::new(
7983 Arc::new(Registry::default()),
7984 RestartPolicy::new(2, Duration::ZERO),
7985 );
7986 let runtime = supervisor.runtime_config();
7987 let spec = windowed_crash_spec("operator-cleared-budget");
7988 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
7989
7990 for attempt in 1..=2 {
7991 assert!(matches!(
7992 on_child_exit(
7993 &spec,
7994 runtime.restart_policy,
7995 &supervisor.registry,
7996 &snapshot,
7997 &runtime.terminal_ring,
7998 &runtime.spawn_events,
7999 &runtime.child_roster,
8000 crash_exit_report(attempt),
8001 )
8002 .await,
8003 NextAction::Restart { schedule: _ }
8004 ));
8005 }
8006
8007 reset_restart_count(&snapshot, &spec.module_id).unwrap();
8008 {
8009 let state = lock_snapshot(&snapshot).unwrap();
8010 assert!(
8011 state.crash_restarts.is_empty(),
8012 "an operator restart returns the full budget"
8013 );
8014 assert_eq!(
8015 state.lifetime_restarts, 2,
8016 "clearing the budget must not unmake the crashes"
8017 );
8018 }
8019
8020 assert!(
8021 matches!(
8022 on_child_exit(
8023 &spec,
8024 runtime.restart_policy,
8025 &supervisor.registry,
8026 &snapshot,
8027 &runtime.terminal_ring,
8028 &runtime.spawn_events,
8029 &runtime.child_roster,
8030 crash_exit_report(3),
8031 )
8032 .await,
8033 NextAction::Restart { schedule: _ }
8034 ),
8035 "the cleared budget must be spendable again"
8036 );
8037 let state = lock_snapshot(&snapshot).unwrap();
8038 assert_eq!(state.crash_restarts.len(), 1);
8039 assert_eq!(state.lifetime_restarts, 3);
8040 }
8041
8042 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
8043 async fn severance_marker_for_a_dead_child_does_not_label_its_successor() {
8044 let severed = ProcessIdentity {
8045 pid: 41,
8046 start_time: 101,
8047 };
8048 let successor = ProcessIdentity {
8049 pid: 41,
8050 start_time: 202,
8051 };
8052 let module = module_with_recovery_snapshot(ModuleState::Running, true, 0);
8053 update_snapshot(&module.inner.snapshot, Some("recovery-snapshot"), |state| {
8054 state.pid = Some(successor.pid);
8055 state.process_start_time = Some(successor.start_time);
8056 })
8057 .unwrap();
8058 assert!(!module.record_deliberate_severance(severed).unwrap());
8059
8060 let exit_report = apply_deliberate_severance_marker(
8061 &module.inner.snapshot,
8062 Some(successor),
8063 ExitReport {
8064 kind: ExitKind::Crash,
8065 code: Some(1),
8066 signal: None,
8067 at_ms: 1,
8068 },
8069 );
8070
8071 assert_eq!(exit_report.kind, ExitKind::Crash);
8072 }
8073
8074 #[tokio::test]
8075 async fn drain_reap_marks_deliberate_severance_and_records_lifetime_without_budget() {
8076 let registry = Registry::default();
8077 let supervisor = Supervisor::new(
8078 Arc::new(Registry::default()),
8079 RestartPolicy::new(3, Duration::ZERO),
8080 );
8081 let runtime = supervisor.runtime_config();
8082 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
8083 let spec = ModuleSpec {
8084 module_id: "drain-deliberate-severance".to_string(),
8085 program: fake_aft_stub_path(),
8086 args: Vec::new(),
8087 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
8088 reserved: false,
8089 reserved_prefixes: Vec::new(),
8090 protocol: ModuleProtocol::Subc,
8091 overlap: Default::default(),
8092 };
8093 let mut child = spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap();
8094 let process = ProcessIdentity {
8095 pid: 41,
8096 start_time: 101,
8097 };
8098 child.process_identity = Some(process);
8099 update_snapshot(&snapshot, Some(&spec.module_id), |state| {
8100 state.pid = Some(process.pid);
8101 state.process_start_time = Some(process.start_time);
8102 })
8103 .unwrap();
8104 record_deliberate_severance(&snapshot, process).unwrap();
8105
8106 drain_child_to_state(
8107 &spec.module_id,
8108 spec.protocol,
8109 ®istry,
8110 &snapshot,
8111 &runtime.terminal_ring,
8112 &runtime.spawn_events,
8113 child,
8114 Duration::from_secs(1),
8115 ModuleState::Stopped,
8116 Some(false),
8117 )
8118 .await
8119 .unwrap();
8120
8121 let state = lock_snapshot(&snapshot).unwrap();
8122 assert_eq!(
8123 state.last_exit.as_ref().map(|exit| exit.kind),
8124 Some(ExitKind::DeliberateSeverance)
8125 );
8126 assert_eq!(state.lifetime_restarts, 1);
8127 assert_eq!(state.crash_restarts.len(), 0);
8128 drop(state);
8129 let history = runtime.terminal_ring.lock().unwrap().snapshot();
8130 assert_eq!(
8131 history.entries[0].exit_kind,
8132 subc_control::TerminalExitKind::DeliberateSeverance
8133 );
8134 }
8135
8136 #[tokio::test]
8137 async fn ordinary_drain_reap_does_not_record_a_lifetime_restart() {
8138 let registry = Registry::default();
8139 let supervisor = Supervisor::new(
8140 Arc::new(Registry::default()),
8141 RestartPolicy::new(3, Duration::ZERO),
8142 );
8143 let runtime = supervisor.runtime_config();
8144 let snapshot = Arc::new(Mutex::new(SupervisorSnapshot::starting()));
8145 let spec = ModuleSpec {
8146 module_id: "ordinary-drain".to_string(),
8147 program: fake_aft_stub_path(),
8148 args: Vec::new(),
8149 env: vec![("FAKE_AFT_EXIT_CODE".to_string(), "23".to_string())],
8150 reserved: false,
8151 reserved_prefixes: Vec::new(),
8152 protocol: ModuleProtocol::Subc,
8153 overlap: Default::default(),
8154 };
8155 let child = spawn_and_mark_running(&spec, &runtime, &snapshot).unwrap();
8156
8157 drain_child_to_state(
8158 &spec.module_id,
8159 spec.protocol,
8160 ®istry,
8161 &snapshot,
8162 &runtime.terminal_ring,
8163 &runtime.spawn_events,
8164 child,
8165 Duration::from_secs(1),
8166 ModuleState::Stopped,
8167 Some(false),
8168 )
8169 .await
8170 .unwrap();
8171
8172 let state = lock_snapshot(&snapshot).unwrap();
8173 assert_eq!(
8174 state.last_exit.as_ref().map(|exit| exit.kind),
8175 Some(ExitKind::Crash)
8176 );
8177 assert_eq!(state.lifetime_restarts, 0);
8178 assert_eq!(state.crash_restarts.len(), 0);
8179 }
8180
8181 #[test]
8182 fn fatal_connection_teardown_cannot_arm_a_marker_for_a_surviving_process() {
8183 assert!(!include_str!("server.rs")
8189 .contains("router.record_deliberate_connection_severance(ctx.connection_id)"));
8190 }
8191
8192 #[test]
8199 fn drained_after_quiescence_wait_passes_ok_through_and_forces_false_on_err() {
8200 assert!(drained_after_quiescence_wait(&Ok(true)));
8201 assert!(!drained_after_quiescence_wait(&Ok(false)));
8202 assert!(!drained_after_quiescence_wait(&Err(
8203 SuperviseError::StatePoisoned { module_id: None }
8204 )));
8205 }
8206
8207 #[test]
8216 fn wait_error_exit_report_records_a_failed_terminal_with_no_code_or_signal() {
8217 let ring = Arc::new(Mutex::new(TerminalRing::new(
8218 TerminalRingConfig::default(),
8219 0,
8220 )));
8221 record_wait_error_terminal("wait-error", &ring, &super::SpawnEventFeed::default());
8222
8223 let snapshot = ring.lock().unwrap().snapshot();
8224 assert_eq!(snapshot.entries.len(), 1);
8225 let entry = &snapshot.entries[0];
8226 assert_eq!(entry.exit_code, None);
8227 assert_eq!(entry.exit_signal, None);
8228 assert_eq!(entry.disposition, TerminalDisposition::Failed);
8229 }
8230
8231 #[test]
8232 fn wait_error_exit_path_preserves_spawn_event_density() {
8233 let feed = super::SpawnEventFeed::default();
8234 feed.configure_incarnation("wait-error-density".to_string());
8235 feed.emit_spawned("wait-error", 41, 1);
8236 let ring = Arc::new(Mutex::new(TerminalRing::new(
8237 TerminalRingConfig::default(),
8238 0,
8239 )));
8240
8241 record_wait_error_terminal("wait-error", &ring, &feed);
8242 feed.emit_spawned("after-wait-error", 42, 2);
8243
8244 let state = feed.0.lock().unwrap();
8245 let sequences = state
8246 .events
8247 .iter()
8248 .map(|event| event.cursor.seq)
8249 .collect::<Vec<_>>();
8250 assert_eq!(sequences, vec![1, 2, 3]);
8251 assert_eq!(state.events[1].kind, SpawnEventKind::Exited);
8252 assert_eq!(state.events[1].exit_code, None);
8253 assert_eq!(state.events[1].exit_signal, None);
8254 }
8255
8256 #[test]
8260 fn wait_error_exit_report_is_classified_as_a_crash() {
8261 assert_eq!(wait_error_exit_report().kind, ExitKind::Crash);
8262 }
8263}
8264
8265#[cfg(test)]
8266mod health_evidence_tests {
8267 use super::{HealthProbeError, HealthProbeEvidence};
8268 use std::collections::HashSet;
8269
8270 #[test]
8278 fn only_a_dead_lane_is_proof_of_death() {
8279 assert!(HealthProbeError::lane_dead("gone").is_proof_of_death());
8280 assert!(!HealthProbeError::no_answer("timed out").is_proof_of_death());
8284 assert!(!HealthProbeError::bad_answer("garbage").is_proof_of_death());
8285 assert!(!HealthProbeError::misconfigured("no table").is_proof_of_death());
8286 }
8287
8288 #[test]
8294 fn every_evidence_class_has_a_distinct_label() {
8295 let labels = [
8296 HealthProbeError::lane_dead("").label(),
8297 HealthProbeError::no_answer("").label(),
8298 HealthProbeError::bad_answer("").label(),
8299 HealthProbeError::misconfigured("").label(),
8300 ];
8301 let unique: HashSet<_> = labels.iter().collect();
8302 assert_eq!(unique.len(), labels.len(), "labels collided: {labels:?}");
8303 }
8304
8305 #[test]
8311 fn classification_preserves_the_original_message() {
8312 let err = HealthProbeError::no_answer("module did not answer within 5s");
8313 assert_eq!(err.to_string(), "module did not answer within 5s");
8314 assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
8315 }
8316}
8317
8318#[cfg(test)]
8319mod health_tombstone_tests {
8320 use std::{path::PathBuf, sync::Arc, time::Duration};
8321
8322 use subc_protocol::{
8323 manifest::Concurrency,
8324 session::{HealthStatus, ModuleControlResponse},
8325 };
8326 use tokio::sync::mpsc;
8327
8328 use super::{
8329 probe_module_health, HealthAction, HealthConfig, HealthProbeEvidence, ModuleProtocol,
8330 ModuleSpec, RestartPolicy, Supervisor, SupervisorRuntimeConfig,
8331 };
8332 use crate::{
8333 control::ControlHandler,
8334 forwarding::{ForwardingTable, ModuleControlRpcCompletion, ModuleControlRpcOutcome},
8335 registry::{ConnectionId, Registry},
8336 router::FrameSink,
8337 };
8338
8339 struct ProbeHarness {
8340 spec: ModuleSpec,
8341 runtime: SupervisorRuntimeConfig,
8342 forwarding: Arc<ForwardingTable>,
8343 module_connection: ConnectionId,
8344 module_rx: mpsc::Receiver<crate::router::OutboundFrame>,
8345 handler: ControlHandler,
8346 module: super::SupervisedModule,
8347 }
8348
8349 fn probe_harness() -> ProbeHarness {
8350 let registry = Arc::new(Registry::default());
8351 let forwarding = Arc::new(ForwardingTable::default());
8352 let supervisor_handle = super::SupervisorHandle::new();
8353 let health = HealthConfig {
8354 cadence: Duration::from_secs(30),
8355 deadline: Duration::from_secs(5),
8356 failure_threshold: 3,
8357 on_degraded: HealthAction::Report,
8358 on_failing: HealthAction::Report,
8359 critical: false,
8360 };
8361 let supervisor = Supervisor::new(Arc::clone(®istry), RestartPolicy::default())
8362 .with_forwarding(Arc::clone(&forwarding))
8363 .with_handle(supervisor_handle.clone())
8364 .with_health_config(health);
8365 let spec = ModuleSpec {
8366 module_id: "late-health-module".to_string(),
8367 program: PathBuf::from("disabled-module"),
8368 args: Vec::new(),
8369 env: Vec::new(),
8370 reserved: false,
8371 reserved_prefixes: Vec::new(),
8372 protocol: ModuleProtocol::Subc,
8373 overlap: Default::default(),
8374 };
8375 let module = supervisor
8376 .supervise_configured(spec.clone(), false)
8377 .unwrap();
8378 let runtime = supervisor.runtime_config();
8379 let handler = ControlHandler::with_forwarding(registry, Arc::clone(&forwarding))
8380 .with_supervisor(supervisor_handle);
8381 let module_connection = ConnectionId::new(700);
8382 let (module_tx, module_rx) = mpsc::channel(8);
8383 forwarding
8384 .register_module_connection(
8385 module_connection,
8386 spec.module_id.clone(),
8387 subc_protocol::PROTOCOL_VERSION,
8388 Concurrency::ModuleManaged,
8389 FrameSink::new(module_tx),
8390 )
8391 .unwrap();
8392
8393 ProbeHarness {
8394 spec,
8395 runtime,
8396 forwarding,
8397 module_connection,
8398 module_rx,
8399 handler,
8400 module,
8401 }
8402 }
8403
8404 async fn finish_after(
8405 harness: &mut ProbeHarness,
8406 stall: Duration,
8407 ) -> ModuleControlRpcCompletion {
8408 assert!(stall > harness.runtime.health.deadline);
8409 let deadline = harness.runtime.health.deadline;
8410 let probe = probe_module_health(&harness.spec.module_id, &harness.runtime, None);
8411 let answer = async {
8412 let frame = harness.module_rx.recv().await.expect("health.check frame");
8413 tokio::time::advance(deadline).await;
8414 tokio::task::yield_now().await;
8415 tokio::time::advance(stall - deadline).await;
8416 harness
8417 .forwarding
8418 .complete_module_control_rpc(
8419 harness.module_connection,
8420 frame.header.corr,
8421 Some("health.check"),
8422 ModuleControlRpcOutcome::Response(ModuleControlResponse::HealthCheck {
8423 status: HealthStatus::Ok,
8424 detail: None,
8425 metrics: None,
8426 }),
8427 )
8428 .unwrap()
8429 };
8430 let (probe_result, completion) = tokio::join!(probe, answer);
8431 let err = probe_result.expect_err("probe must miss its deadline");
8432 assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
8433 completion
8434 }
8435
8436 async fn time_out_without_answer(harness: &mut ProbeHarness) {
8437 let deadline = harness.runtime.health.deadline;
8438 let probe = probe_module_health(&harness.spec.module_id, &harness.runtime, None);
8439 let exhaust_deadline = async {
8440 let _frame = harness.module_rx.recv().await.expect("health.check frame");
8441 tokio::time::advance(deadline).await;
8442 tokio::task::yield_now().await;
8443 };
8444 let (probe_result, ()) = tokio::join!(probe, exhaust_deadline);
8445 let err = probe_result.expect_err("probe must miss its deadline");
8446 assert!(matches!(err.evidence, HealthProbeEvidence::NoAnswer));
8447 }
8448
8449 #[tokio::test(start_paused = true)]
8450 async fn late_health_answers_record_start_anchored_latency_for_two_stalls() {
8451 let mut harness = probe_harness();
8452
8453 let first = finish_after(&mut harness, Duration::from_secs(8)).await;
8454 let first_latency = match &first {
8455 ModuleControlRpcCompletion::LateHealthAnswer { latency, .. } => *latency,
8456 other => panic!("late answer was not retained: {other:?}"),
8457 };
8458 assert!(harness.handler.observe_module_control_completion(first));
8459
8460 let second = finish_after(&mut harness, Duration::from_secs(11)).await;
8461 let second_latency = match &second {
8462 ModuleControlRpcCompletion::LateHealthAnswer { latency, .. } => *latency,
8463 other => panic!("late answer was not retained: {other:?}"),
8464 };
8465 assert!(harness.handler.observe_module_control_completion(second));
8466
8467 assert_eq!(first_latency, Duration::from_secs(8));
8468 assert_eq!(
8469 second_latency - first_latency,
8470 Duration::from_secs(3),
8471 "latency must grow linearly with the additional stall"
8472 );
8473 let health = harness.module.status().unwrap().health;
8474 assert_eq!(health.late_answer_count, 2);
8475 assert_eq!(health.last_late_answer_latency_ms, Some(11_000));
8476 }
8477
8478 #[tokio::test(start_paused = true)]
8486 async fn late_answer_clears_the_consecutive_failure_streak() {
8487 let mut harness = probe_harness();
8488
8489 time_out_without_answer(&mut harness).await;
8491 harness
8492 .module
8493 .record_health_probe_failure_for_test("[no-answer] test miss")
8494 .unwrap();
8495 assert_eq!(
8496 harness.module.status().unwrap().health.consecutive_failures,
8497 1,
8498 "precondition: the miss must be on the streak before the late answer"
8499 );
8500
8501 let late = finish_after(&mut harness, Duration::from_secs(9)).await;
8503 assert!(matches!(
8504 late,
8505 ModuleControlRpcCompletion::LateHealthAnswer { .. }
8506 ));
8507 assert!(harness.handler.observe_module_control_completion(late));
8508
8509 let health = harness.module.status().unwrap().health;
8510 assert_eq!(
8511 health.consecutive_failures, 0,
8512 "a late answer is an answer: the streak must reset"
8513 );
8514 assert_eq!(health.late_answer_count, 1);
8515 }
8516
8517 #[tokio::test(start_paused = true)]
8518 async fn repeated_serial_probe_cycles_keep_one_tombstone_per_endpoint() {
8519 let mut harness = probe_harness();
8520
8521 for _ in 0..20 {
8522 time_out_without_answer(&mut harness).await;
8523 assert_eq!(
8524 harness.forwarding.health_probe_tombstone_count().unwrap(),
8525 1
8526 );
8527 }
8528 }
8529}
8530
8531#[cfg(test)]
8532mod child_env_tests {
8533 use super::{
8534 apply_child_env, apply_spawn_role, apply_wire_spawn_args, ModuleProtocol, ModuleSpec,
8535 SpawnRole, SupervisorHandle, SPAWN_ROLE_SWAP_CANDIDATE, SUBC_ARG, SUBC_LAUNCH_NONCE_ENV,
8536 SUBC_MODULE_ID_ENV, SUBC_SPAWN_ROLE_ENV,
8537 };
8538 use std::{ffi::OsStr, path::PathBuf};
8539 use tokio::process::Command;
8540
8541 fn spec(env: Vec<(String, String)>) -> ModuleSpec {
8542 ModuleSpec {
8543 module_id: "env-plan".to_string(),
8544 program: PathBuf::from("/nonexistent"),
8545 args: Vec::new(),
8546 env,
8547 reserved: false,
8548 reserved_prefixes: Vec::new(),
8549 protocol: ModuleProtocol::Subc,
8550 overlap: Default::default(),
8551 }
8552 }
8553
8554 #[test]
8568 fn ambient_ck_log_is_removed_and_a_configured_one_survives() {
8569 let mut command = Command::new("/nonexistent");
8570 apply_child_env(&mut command, &spec(Vec::new()));
8571 let removed = command
8572 .as_std()
8573 .get_envs()
8574 .any(|(key, value)| key == OsStr::new("CK_LOG") && value.is_none());
8575 assert!(
8576 removed,
8577 "ambient CK_LOG must be explicitly removed for an unconfigured module"
8578 );
8579
8580 let mut configured = Command::new("/nonexistent");
8581 apply_child_env(
8582 &mut configured,
8583 &spec(vec![("CK_LOG".to_string(), "debug".to_string())]),
8584 );
8585 let effective = configured
8586 .as_std()
8587 .get_envs()
8588 .filter(|(key, _)| *key == OsStr::new("CK_LOG"))
8589 .last()
8590 .map(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()));
8591 assert_eq!(
8592 effective,
8593 Some(Some("debug".to_string())),
8594 "a module's configured CK_LOG must survive the ambient removal"
8595 );
8596 }
8597
8598 #[test]
8607 fn protocol_none_spawn_carries_no_subc_argument_and_no_nonce() {
8608 let connection_file = std::path::Path::new("/run/subc-connection.json");
8609 let handle = SupervisorHandle::new();
8610
8611 let mut none_spec = spec(Vec::new());
8612 none_spec.protocol = ModuleProtocol::None;
8613 let mut none = Command::new("/nonexistent");
8614 apply_wire_spawn_args(&mut none, &none_spec, Some(connection_file), Some(&handle))
8615 .expect("protocol-none spawn args apply");
8616 let none_args: Vec<String> = none
8617 .as_std()
8618 .get_args()
8619 .map(|a| a.to_string_lossy().into_owned())
8620 .collect();
8621 assert!(
8622 !none_args.iter().any(|a| a == SUBC_ARG),
8623 "protocol:none argv must not carry --subc; got {none_args:?}"
8624 );
8625 let none_has_nonce = none
8626 .as_std()
8627 .get_envs()
8628 .any(|(key, value)| key == OsStr::new(SUBC_LAUNCH_NONCE_ENV) && value.is_some());
8629 assert!(
8630 !none_has_nonce,
8631 "protocol:none spawn must not receive a launch nonce"
8632 );
8633 let none_has_module_id = none
8634 .as_std()
8635 .get_envs()
8636 .any(|(key, value)| key == OsStr::new(SUBC_MODULE_ID_ENV) && value.is_some());
8637 assert!(
8638 none_has_module_id,
8639 "SUBC_MODULE_ID is inert and stays on every path"
8640 );
8641 assert!(
8642 handle.spawn_nonce(&none_spec.module_id).is_none(),
8643 "no nonce record for a process that will never present one"
8644 );
8645
8646 let wire_spec = spec(Vec::new());
8648 let mut wire = Command::new("/nonexistent");
8649 apply_wire_spawn_args(&mut wire, &wire_spec, Some(connection_file), Some(&handle))
8650 .expect("subc-wire spawn args apply");
8651 let wire_args: Vec<String> = wire
8652 .as_std()
8653 .get_args()
8654 .map(|a| a.to_string_lossy().into_owned())
8655 .collect();
8656 assert_eq!(
8657 wire_args,
8658 vec![
8659 SUBC_ARG.to_string(),
8660 connection_file.to_string_lossy().into_owned()
8661 ],
8662 "a subc-wire spawn still carries --subc <path>"
8663 );
8664 assert!(wire
8665 .as_std()
8666 .get_envs()
8667 .any(|(key, value)| key == OsStr::new(SUBC_LAUNCH_NONCE_ENV) && value.is_some()));
8668 assert!(handle.spawn_nonce(&wire_spec.module_id).is_some());
8669 }
8670
8671 #[test]
8681 fn plain_spawn_removes_the_spawn_role_even_when_the_spec_sets_it() {
8682 let role = |command: &Command| {
8683 command
8684 .as_std()
8685 .get_envs()
8686 .filter(|(key, _)| *key == OsStr::new(SUBC_SPAWN_ROLE_ENV))
8687 .last()
8688 .map(|(_, value)| value.map(|v| v.to_string_lossy().into_owned()))
8689 };
8690 let forged = spec(vec![(
8691 SUBC_SPAWN_ROLE_ENV.to_string(),
8692 SPAWN_ROLE_SWAP_CANDIDATE.to_string(),
8693 )]);
8694
8695 let mut plain = Command::new("/nonexistent");
8696 apply_child_env(&mut plain, &forged);
8697 apply_spawn_role(&mut plain, SpawnRole::Plain);
8698 assert_eq!(
8699 role(&plain),
8700 Some(None),
8701 "a plain spawn must remove SUBC_SPAWN_ROLE, whatever the spec says"
8702 );
8703
8704 let mut candidate = Command::new("/nonexistent");
8705 apply_child_env(&mut candidate, &spec(Vec::new()));
8706 apply_spawn_role(&mut candidate, SpawnRole::SwapCandidate);
8707 assert_eq!(
8708 role(&candidate),
8709 Some(Some(SPAWN_ROLE_SWAP_CANDIDATE.to_string()))
8710 );
8711 }
8712
8713 #[test]
8719 fn daemon_private_capture_keys_are_not_passed_to_the_child() {
8720 let mut command = Command::new("/nonexistent");
8721 apply_child_env(
8722 &mut command,
8723 &spec(vec![
8724 (super::CAPTURE_KEEP_ENV.to_string(), "5".to_string()),
8725 ("KEPT".to_string(), "yes".to_string()),
8726 ]),
8727 );
8728 let keys: Vec<String> = command
8729 .as_std()
8730 .get_envs()
8731 .filter(|(_, value)| value.is_some())
8732 .map(|(key, _)| key.to_string_lossy().into_owned())
8733 .collect();
8734 assert!(keys.contains(&"KEPT".to_string()), "got {keys:?}");
8735 assert!(
8736 !keys.contains(&super::CAPTURE_KEEP_ENV.to_string()),
8737 "daemon-private capture key leaked to the child: {keys:?}"
8738 );
8739 }
8740}
8741
8742#[cfg(test)]
8743mod jitter_tests {
8744 use super::jittered_health_delay;
8745 use std::{collections::HashSet, time::Duration};
8746
8747 const FLEET: [&str; 14] = [
8756 "aft",
8757 "alfonso-core",
8758 "magic-context",
8759 "broca",
8760 "thalamus",
8761 "quota",
8762 "engram",
8763 "plexus",
8764 "cerebellum",
8765 "astrocyte",
8766 "synapse",
8767 "subc-mcp",
8768 "cortexkit-credentials",
8769 "subc-federation",
8770 ];
8771
8772 #[test]
8780 fn probe_delays_disperse_across_the_fleet() {
8781 let cadence = Duration::from_secs(30);
8782 let delays: HashSet<Duration> = FLEET
8783 .iter()
8784 .map(|id| jittered_health_delay(id, 0, cadence))
8785 .collect();
8786 assert_eq!(
8787 delays.len(),
8788 FLEET.len(),
8789 "every supervised module must land on its own probe offset"
8790 );
8791 }
8792
8793 #[test]
8799 fn jitter_only_delays_and_stays_within_one_tenth_of_cadence() {
8800 let cadence = Duration::from_secs(30);
8801 let span = cadence / 10;
8802 for id in FLEET {
8803 for probe_index in 0..8 {
8804 let delay = jittered_health_delay(id, probe_index, cadence);
8805 assert!(
8806 delay >= cadence,
8807 "{id}#{probe_index}: jitter must not shorten the cadence"
8808 );
8809 assert!(
8810 delay < cadence + span,
8811 "{id}#{probe_index}: jitter must stay inside one tenth of the cadence"
8812 );
8813 }
8814 }
8815 }
8816
8817 #[test]
8823 fn a_module_offset_is_stable_across_restarts() {
8824 let cadence = Duration::from_secs(30);
8825 for id in FLEET {
8826 assert_eq!(
8827 jittered_health_delay(id, 0, cadence),
8828 jittered_health_delay(id, 0, cadence),
8829 "{id}: the same module and probe index must produce the same offset"
8830 );
8831 }
8832 }
8833
8834 #[test]
8836 fn zero_cadence_yields_zero_delay() {
8837 assert_eq!(
8838 jittered_health_delay("aft", 0, Duration::ZERO),
8839 Duration::ZERO
8840 );
8841 }
8842}
8843
8844#[cfg(all(test, target_os = "linux"))]
8845mod cgroup_placement_tests {
8846 use super::{
8847 apply_cgroup_placement, remove_module_cgroup, ModuleProtocol, ModuleSpec, SuperviseError,
8848 SupervisedChild,
8849 };
8850 use crate::{
8851 stderr_tail::{StderrRing, StderrTailConfig},
8852 test_support::TestTempDir,
8853 };
8854 use std::{
8855 fs, io,
8856 path::{Path, PathBuf},
8857 sync::{Arc, Mutex},
8858 };
8859 use tokio::process::Command;
8860
8861 #[test]
8862 fn failed_parent_cgroup_open_is_a_cgroup_supervision_error() {
8863 let path = Path::new("/definitely-missing-subc-cgroup");
8864 let mut command = Command::new("true");
8865 let error = apply_cgroup_placement(
8866 &mut command,
8867 &ModuleSpec {
8868 module_id: "broken-cgroup".to_string(),
8869 program: PathBuf::from("true"),
8870 args: Vec::new(),
8871 env: Vec::new(),
8872 reserved: false,
8873 reserved_prefixes: Vec::new(),
8874 protocol: ModuleProtocol::Subc,
8875 overlap: Default::default(),
8876 },
8877 path,
8878 )
8879 .expect_err("a parent cgroup open failure must reject the supervised spawn");
8880 let reason = error.to_string();
8881
8882 assert!(
8883 matches!(error, SuperviseError::Cgroup { .. }),
8884 "parent cgroup open must be reported as a cgroup supervision error: {reason}"
8885 );
8886 assert!(
8887 reason.contains("/definitely-missing-subc-cgroup/cgroup.procs"),
8888 "parent cgroup open failure must name cgroup.procs: {reason}"
8889 );
8890 }
8891
8892 #[tokio::test]
8893 async fn reaping_a_child_removes_its_empty_module_cgroup() {
8894 let root = TestTempDir::new("supervisor-reap-cgroup");
8895 fs::write(root.join("cgroup.procs"), b"").expect("write scratch cgroup marker");
8896 let placement = subc_cgroup::prepare_at(&root)
8897 .expect("prepare scratch cgroup root")
8898 .expect("scratch root has a cgroup.procs marker");
8899 let module_id = "reaped-module";
8900 let module = placement
8901 .module_path(module_id)
8902 .expect("create scratch module cgroup");
8903 let child = Command::new("true")
8904 .spawn()
8905 .expect("spawn short-lived child");
8906 let pid = child.id().expect("spawned child has pid");
8907 let mut child = SupervisedChild {
8908 child,
8909 module_id: module_id.to_string(),
8910 cgroup_placement: Some(placement),
8911 stdout_pump: None,
8912 stderr_pump: None,
8913 stderr_ring: Arc::new(Mutex::new(StderrRing::new(StderrTailConfig::default()))),
8914 spawned_at_ms: 0,
8915 spawned_from: PathBuf::from("true"),
8916 spawned_file_identity: None,
8917 process_start_time: None,
8918 process_identity: None,
8919 pid,
8920 roster_guard: None,
8921 };
8922
8923 child.wait().await.expect("reap short-lived child");
8924
8925 assert!(
8926 !module.exists(),
8927 "reaping the supervised child must remove its empty cgroup"
8928 );
8929 }
8930
8931 #[test]
8932 fn non_empty_cgroup_removal_is_reported_without_blocking_teardown() {
8933 let root = TestTempDir::new("supervisor-non-empty-cgroup");
8934 fs::write(root.join("cgroup.procs"), b"").expect("write scratch cgroup marker");
8935 let placement = subc_cgroup::prepare_at(&root)
8936 .expect("prepare scratch cgroup root")
8937 .expect("scratch root has a cgroup.procs marker");
8938 let module = placement
8939 .module_path("surviving-module")
8940 .expect("create scratch module cgroup");
8941 fs::write(module.join("surviving-process"), b"still present")
8942 .expect("make scratch cgroup non-empty");
8943 let (logs, _guard) = crate::router::test_log::log_capture(tracing::Level::WARN);
8944
8945 remove_module_cgroup(&placement, "surviving-module");
8946
8947 let logs = crate::router::test_log::captured_logs(&logs);
8948 assert!(
8949 module.exists(),
8950 "failed removal must leave the cgroup intact"
8951 );
8952 assert!(
8953 logs.contains("could not remove module cgroup after process exit; continuing teardown")
8954 && logs.contains("surviving-module"),
8955 "best-effort removal must report the failure without returning it: {logs}"
8956 );
8957 }
8958
8959 #[test]
8960 fn cgroup_pre_exec_spawn_failure_names_the_cgroup_path() {
8961 let cgroup_path = PathBuf::from("/sys/fs/cgroup/subc-modules/broken-module");
8962 let reason = SuperviseError::Spawn {
8963 program: PathBuf::from("/bin/true"),
8964 source: io::Error::from_raw_os_error(13),
8965 cgroup_path: Some(cgroup_path.clone()),
8966 }
8967 .to_string();
8968
8969 assert!(
8970 reason.contains(&cgroup_path.display().to_string()),
8971 "a pre_exec spawn failure must name the cgroup path: {reason}"
8972 );
8973 }
8974}
8975
8976#[cfg(test)]
8977mod spawn_subscriber_lag_tests {
8978 use super::*;
8979
8980 #[tokio::test]
8985 async fn lagged_spawn_subscriber_receives_a_terminal_lagged_error_after_its_queued_frames() {
8986 let feed = SpawnEventFeed::default();
8987 feed.configure_incarnation("lag-incarnation".to_string());
8988 let (tx, mut rx) = mpsc::channel(1);
8991 feed.subscribe(ConnectionId::new(1), 7, 1, None, FrameSink::new(tx))
8992 .expect("subscribe");
8993 let emitted = SPAWN_SUBSCRIBER_BUFFER + 16;
8994 for index in 0..emitted {
8995 feed.emit_spawned(&format!("lag-module-{index}"), 1000, 0);
8996 tokio::task::yield_now().await;
8999 }
9000 assert_eq!(
9001 feed.subscriber_count(),
9002 0,
9003 "the lagged subscriber must be removed"
9004 );
9005
9006 let mut data = Vec::new();
9007 let mut last = None;
9008 loop {
9009 let next = tokio::time::timeout(Duration::from_secs(5), rx.recv())
9010 .await
9011 .expect("the forwarder must finish once the subscriber is dropped");
9012 let Some(outbound) = next else { break };
9013 let frame = outbound.frame;
9014 if frame.header.ty == FrameType::StreamData {
9015 assert!(last.is_none(), "no data may follow the terminal frame");
9016 let event: SpawnEvent = serde_json::from_slice(&frame.body).unwrap();
9017 data.push(event.cursor.seq);
9018 } else {
9019 assert!(last.is_none(), "exactly one terminal frame");
9020 last = Some(frame);
9021 }
9022 }
9023 assert!(!data.is_empty(), "queued frames drain before the terminal");
9024 for pair in data.windows(2) {
9025 assert_eq!(
9026 pair[1],
9027 pair[0] + 1,
9028 "queued frames arrive dense and in order"
9029 );
9030 }
9031 let terminal = last.expect("a lagged subscriber must receive a terminal frame");
9032 assert_eq!(terminal.header.ty, FrameType::Error);
9033 assert_eq!(terminal.header.corr, 7);
9034 let body: subc_protocol::ErrorBody = serde_json::from_slice(&terminal.body).unwrap();
9035 assert_eq!(body.code, SPAWN_SUBSCRIBER_LAGGED_CODE);
9036 let detail = body.detail.expect("lagged error carries detail");
9037 assert_eq!(
9038 detail["first_undelivered_cursor"]["seq"],
9039 data.last().unwrap() + 1,
9040 "the named cursor is the first event the subscriber did not receive"
9041 );
9042 assert_eq!(
9043 detail["first_undelivered_cursor"]["daemon_incarnation"],
9044 "lag-incarnation"
9045 );
9046 }
9047}
9048
9049#[cfg(test)]
9050mod terminal_history_read_concurrency_tests {
9051 use super::*;
9052 use crate::{terminal_journal::read_pause, test_support::TestTempDir};
9053 use std::sync::mpsc as std_mpsc;
9054
9055 fn journaled_ring(
9056 journal: &Arc<crate::terminal_journal::TerminalJournal>,
9057 ) -> Arc<Mutex<TerminalRing>> {
9058 Arc::new(Mutex::new(
9059 TerminalRing::new(TerminalRingConfig::default(), 1)
9060 .with_journal(Some(Arc::clone(journal))),
9061 ))
9062 }
9063
9064 fn crash(at_ms: u64) -> ExitReport {
9065 ExitReport {
9066 kind: ExitKind::Crash,
9067 code: Some(1),
9068 signal: None,
9069 at_ms,
9070 }
9071 }
9072
9073 fn record_within(
9076 module_id: &'static str,
9077 ring: &Arc<Mutex<TerminalRing>>,
9078 at_ms: u64,
9079 bound: Duration,
9080 ) -> bool {
9081 let ring = Arc::clone(ring);
9082 let (done, done_rx) = std_mpsc::channel();
9083 std::thread::spawn(move || {
9084 record_terminal(
9085 module_id,
9086 &ring,
9087 &SpawnEventFeed::default(),
9088 &crash(at_ms),
9089 TerminalDisposition::Restarting,
9090 );
9091 let _ = done.send(());
9092 });
9093 done_rx.recv_timeout(bound).is_ok()
9094 }
9095
9096 #[test]
9101 fn exits_recorded_during_a_paused_history_read_are_not_blocked_or_half_merged() {
9102 let dir = TestTempDir::new("terminal-history-concurrent-read");
9103 let path = dir.join("terminals.jsonl");
9104 let journal = Arc::new(crate::terminal_journal::TerminalJournal::open(
9105 path.clone(),
9106 "daemon".into(),
9107 ));
9108 let reader_ring = journaled_ring(&journal);
9109 let other_ring = journaled_ring(&journal);
9110 assert!(record_within(
9111 "reader-module",
9112 &reader_ring,
9113 10,
9114 Duration::from_secs(5)
9115 ));
9116
9117 let (started, release) = read_pause::install(&path);
9118 let reading = {
9119 let ring = Arc::clone(&reader_ring);
9120 std::thread::spawn(move || durable_terminal_history_of(&ring, "reader-module"))
9121 };
9122 started
9123 .recv_timeout(Duration::from_secs(5))
9124 .expect("the history read reached its pause");
9125
9126 let bound = Duration::from_secs(1);
9127 assert!(
9128 record_within("other-module", &other_ring, 20, bound),
9129 "another module's exit waited on a history read (journal writer held)"
9130 );
9131 assert!(
9132 record_within("reader-module", &reader_ring, 30, bound),
9133 "the read module's own exit waited on its history read (ring held)"
9134 );
9135
9136 drop(release);
9137 let paused = reading.join().unwrap();
9138 assert_eq!(
9139 paused.entries.iter().map(|e| e.at_ms).collect::<Vec<_>>(),
9140 vec![10],
9141 "an exit recorded after the read began lands in neither half of it"
9142 );
9143 assert_eq!(paused.journal_skipped_lines, 0);
9144 assert_eq!(paused.journal_read_errors, 0);
9145
9146 let after = durable_terminal_history_of(&reader_ring, "reader-module");
9147 assert_eq!(
9148 after.entries.iter().map(|e| e.at_ms).collect::<Vec<_>>(),
9149 vec![10, 30],
9150 "the next read merges ring and journal with no duplicate"
9151 );
9152 assert_eq!(after.journal_skipped_lines, 0);
9153 }
9154}