Skip to main content

subc_daemon/
terminal_ring.rs

1use std::{collections::VecDeque, sync::Arc};
2
3use crate::{
4    child_roster::DaemonShutdownFlag,
5    terminal_journal::{ring_history, JournalRead, TerminalJournal},
6};
7
8use subc_control::{TerminalDisposition, TerminalExitKind};
9
10const DEFAULT_MAX_ENTRIES: usize = 32;
11
12/// Fixed-size retention policy for one module's terminal exits.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub struct TerminalRingConfig {
15    max_entries: usize,
16}
17
18impl TerminalRingConfig {
19    /// A zero-sized history cannot answer whether an exit was observed, so clamp it
20    /// to one record instead of constructing a ring that lies by omission.
21    pub const fn new(max_entries: usize) -> Self {
22        Self {
23            max_entries: if max_entries == 0 { 1 } else { max_entries },
24        }
25    }
26}
27
28impl Default for TerminalRingConfig {
29    fn default() -> Self {
30        Self::new(DEFAULT_MAX_ENTRIES)
31    }
32}
33
34/// One observed child exit and the disposition chosen by its supervisor.
35#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
36pub struct TerminalRecord {
37    pub exit_code: Option<i32>,
38    pub exit_signal: Option<i32>,
39    pub at_ms: u64,
40    pub disposition: TerminalDisposition,
41    pub exit_kind: TerminalExitKind,
42    /// Why the supervisor chose this disposition, when the disposition alone
43    /// does not say. `failed` records the exhausted crash budget here, naming
44    /// the limit AND the window it was counted over, because a module stopped
45    /// by three crashes in ten minutes and one stopped by three crashes in a
46    /// week are the same `failed` and call for different reactions.
47    pub disposition_detail: Option<String>,
48}
49
50/// The retained terminal suffix for one module.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct TerminalHistorySnapshot {
53    pub daemon_started_at_ms: u64,
54    pub entries: Vec<TerminalRecord>,
55    pub dropped: u64,
56}
57
58/// A durable history read captured under the ring lock and finished after it.
59pub(crate) enum DurableHistoryRead {
60    RingOnly(TerminalHistorySnapshot),
61    Journal(JournalRead),
62}
63
64impl DurableHistoryRead {
65    /// Blocking when a journal is configured: it reads journal files.
66    pub(crate) fn read(self, module_id: &str) -> subc_control::TerminalHistory {
67        match self {
68            Self::RingOnly(snapshot) => ring_history(snapshot, None),
69            Self::Journal(read) => read.read(module_id),
70        }
71    }
72}
73
74/// Bounded terminal-exit history for one supervised module.
75///
76/// This belongs to the module rather than an individual child so a replacement
77/// process retains the exits that caused it to exist.
78#[derive(Debug)]
79pub struct TerminalRing {
80    journal: Option<Arc<TerminalJournal>>,
81    daemon_shutdown: Option<DaemonShutdownFlag>,
82    config: TerminalRingConfig,
83    daemon_started_at_ms: u64,
84    start_clock: Option<crate::clock::StartClock>,
85    entries: VecDeque<TerminalRecord>,
86    dropped: u64,
87}
88
89impl TerminalRing {
90    pub fn new(config: TerminalRingConfig, daemon_started_at_ms: u64) -> Self {
91        Self {
92            journal: None,
93            daemon_shutdown: None,
94            config,
95            daemon_started_at_ms,
96            start_clock: None,
97            entries: VecDeque::new(),
98            dropped: 0,
99        }
100    }
101
102    pub(crate) fn with_start_clock(mut self, clock: crate::clock::StartClock) -> Self {
103        self.start_clock = Some(clock);
104        self
105    }
106
107    pub(crate) fn with_journal(mut self, journal: Option<Arc<TerminalJournal>>) -> Self {
108        self.journal = journal;
109        self
110    }
111
112    pub(crate) fn with_daemon_shutdown(mut self, flag: DaemonShutdownFlag) -> Self {
113        self.daemon_shutdown = Some(flag);
114        self
115    }
116
117    /// Whether the daemon has begun its announced shutdown.
118    pub(crate) fn daemon_shutting_down(&self) -> bool {
119        self.daemon_shutdown
120            .as_ref()
121            .is_some_and(DaemonShutdownFlag::is_set)
122    }
123
124    /// Journal and retain one observed exit.
125    ///
126    /// An exit observed after the daemon began shutting down is recorded as
127    /// `daemon_shutdown` whichever path observed it (the reap after a crash, an
128    /// operator stop or restart in flight, a swap retiring its incumbent), so
129    /// no exit caused by the daemon going away reads as a module crash or a
130    /// pending restart. Any detail the caller gave is kept.
131    pub(crate) fn record_exit(&mut self, module_id: &str, mut entry: TerminalRecord) {
132        if self.daemon_shutting_down() {
133            entry.disposition = TerminalDisposition::DaemonShutdown;
134        }
135        self.append_journal(module_id, &entry);
136        self.push(entry);
137    }
138
139    pub(crate) fn append_journal(&self, module_id: &str, entry: &TerminalRecord) {
140        if let Some(journal) = &self.journal {
141            journal.append(module_id, entry);
142        }
143    }
144
145    /// Capture and read in one step, for tests that own the ring directly.
146    #[cfg(test)]
147    pub(crate) fn durable_history(&self, module_id: &str) -> subc_control::TerminalHistory {
148        self.capture_durable_history().read(module_id)
149    }
150
151    /// Pin this ring's snapshot and the journal files a history read will
152    /// see. Cheap; the caller may then release the ring lock before the
153    /// file reading in [`DurableHistoryRead::read`].
154    pub(crate) fn capture_durable_history(&self) -> DurableHistoryRead {
155        match &self.journal {
156            Some(journal) => DurableHistoryRead::Journal(journal.capture_read(self.snapshot())),
157            None => DurableHistoryRead::RingOnly(self.snapshot()),
158        }
159    }
160
161    pub fn push(&mut self, entry: TerminalRecord) {
162        self.entries.push_back(entry);
163        while self.entries.len() > self.config.max_entries {
164            self.entries.pop_front();
165            self.dropped = self.dropped.saturating_add(1);
166        }
167    }
168
169    pub fn snapshot(&self) -> TerminalHistorySnapshot {
170        TerminalHistorySnapshot {
171            daemon_started_at_ms: self
172                .start_clock
173                .map_or(self.daemon_started_at_ms, |clock| clock.started_at_ms()),
174            entries: self.entries.iter().cloned().collect(),
175            dropped: self.dropped,
176        }
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::{TerminalRecord, TerminalRing, TerminalRingConfig};
183    use subc_control::{TerminalDisposition, TerminalExitKind};
184
185    fn record(at_ms: u64) -> TerminalRecord {
186        TerminalRecord {
187            exit_code: Some(1),
188            exit_signal: None,
189            at_ms,
190            disposition: TerminalDisposition::Restarting,
191            exit_kind: TerminalExitKind::Crash,
192            disposition_detail: None,
193        }
194    }
195
196    #[test]
197    fn the_ring_evicts_oldest_exits_and_counts_them() {
198        let mut ring = TerminalRing::new(TerminalRingConfig::new(2), 10);
199        ring.push(record(11));
200        ring.push(record(12));
201        ring.push(record(13));
202
203        let snapshot = ring.snapshot();
204        assert_eq!(snapshot.daemon_started_at_ms, 10);
205        assert_eq!(snapshot.dropped, 1);
206        assert_eq!(
207            snapshot
208                .entries
209                .iter()
210                .map(|entry| entry.at_ms)
211                .collect::<Vec<_>>(),
212            vec![12, 13]
213        );
214    }
215
216    #[test]
217    fn an_incoherent_zero_capacity_keeps_one_terminal() {
218        let mut ring = TerminalRing::new(TerminalRingConfig::new(0), 10);
219        ring.push(record(11));
220        ring.push(record(12));
221
222        let snapshot = ring.snapshot();
223        assert_eq!(snapshot.dropped, 1);
224        assert_eq!(snapshot.entries.len(), 1);
225        assert_eq!(snapshot.entries[0].at_ms, 12);
226    }
227}