Skip to main content

subc_daemon/
terminal_ring.rs

1use std::{collections::VecDeque, sync::Arc};
2
3use crate::terminal_journal::{ring_history, TerminalJournal};
4
5use subc_control::{TerminalDisposition, TerminalExitKind};
6
7const DEFAULT_MAX_ENTRIES: usize = 32;
8
9/// Fixed-size retention policy for one module's terminal exits.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub struct TerminalRingConfig {
12    max_entries: usize,
13}
14
15impl TerminalRingConfig {
16    /// A zero-sized history cannot answer whether an exit was observed, so clamp it
17    /// to one record instead of constructing a ring that lies by omission.
18    pub const fn new(max_entries: usize) -> Self {
19        Self {
20            max_entries: if max_entries == 0 { 1 } else { max_entries },
21        }
22    }
23}
24
25impl Default for TerminalRingConfig {
26    fn default() -> Self {
27        Self::new(DEFAULT_MAX_ENTRIES)
28    }
29}
30
31/// One observed child exit and the disposition chosen by its supervisor.
32#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
33pub struct TerminalRecord {
34    pub exit_code: Option<i32>,
35    pub exit_signal: Option<i32>,
36    pub at_ms: u64,
37    pub disposition: TerminalDisposition,
38    pub exit_kind: TerminalExitKind,
39    /// Why the supervisor chose this disposition, when the disposition alone
40    /// does not say. `failed` records the exhausted crash budget here, naming
41    /// the limit AND the window it was counted over, because a module stopped
42    /// by three crashes in ten minutes and one stopped by three crashes in a
43    /// week are the same `failed` and call for different reactions.
44    pub disposition_detail: Option<String>,
45}
46
47/// The retained terminal suffix for one module.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct TerminalHistorySnapshot {
50    pub daemon_started_at_ms: u64,
51    pub entries: Vec<TerminalRecord>,
52    pub dropped: u64,
53}
54
55/// Bounded terminal-exit history for one supervised module.
56///
57/// This belongs to the module rather than an individual child so a replacement
58/// process retains the exits that caused it to exist.
59#[derive(Debug)]
60pub struct TerminalRing {
61    journal: Option<Arc<TerminalJournal>>,
62    config: TerminalRingConfig,
63    daemon_started_at_ms: u64,
64    start_clock: Option<crate::clock::StartClock>,
65    entries: VecDeque<TerminalRecord>,
66    dropped: u64,
67}
68
69impl TerminalRing {
70    pub fn new(config: TerminalRingConfig, daemon_started_at_ms: u64) -> Self {
71        Self {
72            journal: None,
73            config,
74            daemon_started_at_ms,
75            start_clock: None,
76            entries: VecDeque::new(),
77            dropped: 0,
78        }
79    }
80
81    pub(crate) fn with_start_clock(mut self, clock: crate::clock::StartClock) -> Self {
82        self.start_clock = Some(clock);
83        self
84    }
85
86    pub(crate) fn with_journal(mut self, journal: Option<Arc<TerminalJournal>>) -> Self {
87        self.journal = journal;
88        self
89    }
90
91    pub(crate) fn append_journal(&self, module_id: &str, entry: &TerminalRecord) {
92        if let Some(journal) = &self.journal {
93            journal.append(module_id, entry);
94        }
95    }
96
97    pub(crate) fn durable_history(&self, module_id: &str) -> subc_control::TerminalHistory {
98        match &self.journal {
99            Some(journal) => journal.merge(module_id, self.snapshot()),
100            None => ring_history(self.snapshot(), None),
101        }
102    }
103
104    pub fn push(&mut self, entry: TerminalRecord) {
105        self.entries.push_back(entry);
106        while self.entries.len() > self.config.max_entries {
107            self.entries.pop_front();
108            self.dropped = self.dropped.saturating_add(1);
109        }
110    }
111
112    pub fn snapshot(&self) -> TerminalHistorySnapshot {
113        TerminalHistorySnapshot {
114            daemon_started_at_ms: self
115                .start_clock
116                .map_or(self.daemon_started_at_ms, |clock| clock.started_at_ms()),
117            entries: self.entries.iter().cloned().collect(),
118            dropped: self.dropped,
119        }
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::{TerminalRecord, TerminalRing, TerminalRingConfig};
126    use subc_control::{TerminalDisposition, TerminalExitKind};
127
128    fn record(at_ms: u64) -> TerminalRecord {
129        TerminalRecord {
130            exit_code: Some(1),
131            exit_signal: None,
132            at_ms,
133            disposition: TerminalDisposition::Restarting,
134            exit_kind: TerminalExitKind::Crash,
135            disposition_detail: None,
136        }
137    }
138
139    #[test]
140    fn the_ring_evicts_oldest_exits_and_counts_them() {
141        let mut ring = TerminalRing::new(TerminalRingConfig::new(2), 10);
142        ring.push(record(11));
143        ring.push(record(12));
144        ring.push(record(13));
145
146        let snapshot = ring.snapshot();
147        assert_eq!(snapshot.daemon_started_at_ms, 10);
148        assert_eq!(snapshot.dropped, 1);
149        assert_eq!(
150            snapshot
151                .entries
152                .iter()
153                .map(|entry| entry.at_ms)
154                .collect::<Vec<_>>(),
155            vec![12, 13]
156        );
157    }
158
159    #[test]
160    fn an_incoherent_zero_capacity_keeps_one_terminal() {
161        let mut ring = TerminalRing::new(TerminalRingConfig::new(0), 10);
162        ring.push(record(11));
163        ring.push(record(12));
164
165        let snapshot = ring.snapshot();
166        assert_eq!(snapshot.dropped, 1);
167        assert_eq!(snapshot.entries.len(), 1);
168        assert_eq!(snapshot.entries[0].at_ms, 12);
169    }
170}