Skip to main content

rs_teststand_bridge/
watch.rs

1//! Noticing that nobody is watching any more.
2//!
3//! A host started by an orchestrator outlives it if the orchestrator dies: the
4//! socket closes, nothing else happens, and a station is left running a test
5//! with no one able to stop it. That is the failure this guards against.
6//!
7//! The rule is deliberately simple. While at least one client is connected,
8//! nothing happens. When the last one goes, a clock starts; if it runs out
9//! before anyone reconnects, the host stops the runs and exits.
10//!
11//! # Not a heartbeat
12//!
13//! Connection count is the signal, not a ping. A client that is connected but
14//! wedged still counts as present here, which is the honest limit of a
15//! socket-level check: a closed connection is evidence, an idle one is not.
16//! A host that needs liveness rather than presence should have its panels send
17//! [`Command::VersionString`](crate::Command::VersionString) on a timer and reset the clock on
18//! receipt.
19
20use std::time::{Duration, Instant};
21
22/// How long a host waits with nobody connected before it stops.
23///
24/// [`Default`] is ten seconds, which is long enough to survive a panel
25/// reloading and short enough that a dead orchestrator does not leave hardware
26/// running for minutes.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum ClientTimeout {
29    /// Stop once nobody has been connected for this long.
30    After(Duration),
31    /// Never stop on an idle connection.
32    ///
33    /// For a host meant to outlive its clients: one started by hand, or one an
34    /// operator connects to occasionally. Nothing here will end the process, so
35    /// something else has to.
36    Never,
37}
38
39impl Default for ClientTimeout {
40    fn default() -> Self {
41        Self::After(Duration::from_secs(10))
42    }
43}
44
45impl ClientTimeout {
46    /// Builds a timeout from seconds, where `0` means [`Never`](Self::Never).
47    ///
48    /// For a command line or a config file, where "no limit" has to be
49    /// expressible as a number. Zero is the sentinel because a zero-second
50    /// timeout would otherwise mean "stop immediately", which nobody wants and
51    /// which would make the host unusable.
52    #[must_use]
53    pub const fn from_seconds(seconds: u64) -> Self {
54        if seconds == 0 {
55            Self::Never
56        } else {
57            Self::After(Duration::from_secs(seconds))
58        }
59    }
60
61    /// The limit, when there is one.
62    #[must_use]
63    pub const fn duration(self) -> Option<Duration> {
64        match self {
65            Self::After(limit) => Some(limit),
66            Self::Never => None,
67        }
68    }
69}
70
71/// What the host should do about the clients it currently has.
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum WatchState {
74    /// Someone is connected. Carry on.
75    Connected,
76    /// Nobody is connected, and the clock is running.
77    Waiting {
78        /// How long is left before the host should stop.
79        remaining: Duration,
80    },
81    /// Nobody has been connected for longer than the timeout.
82    ///
83    /// The host should terminate its executions and exit.
84    Expired,
85}
86
87impl WatchState {
88    /// Whether the host should now shut down.
89    #[must_use]
90    pub const fn is_expired(self) -> bool {
91        matches!(self, Self::Expired)
92    }
93}
94
95/// Tracks how long a host has gone without a client.
96///
97/// Pure bookkeeping, with the clock passed in, so the whole rule is testable
98/// without sockets or sleeping. A host calls [`observe`](Self::observe) each
99/// pass of its loop with the current client count.
100#[derive(Debug)]
101pub struct ClientWatch {
102    timeout: ClientTimeout,
103    /// When the last client went away. `None` while someone is connected.
104    alone_since: Option<Instant>,
105    /// Whether any client has ever connected.
106    ///
107    /// The clock runs from startup too, so an orchestrator that dies before it
108    /// ever connects does not leave the host waiting forever for a first client
109    /// that is never coming.
110    ever_connected: bool,
111}
112
113impl ClientWatch {
114    /// Starts watching, with the clock already running from `now`.
115    #[must_use]
116    pub const fn new(timeout: ClientTimeout, now: Instant) -> Self {
117        Self {
118            timeout,
119            alone_since: Some(now),
120            ever_connected: false,
121        }
122    }
123
124    /// Whether any client has connected since the host started.
125    #[must_use]
126    pub const fn ever_connected(&self) -> bool {
127        self.ever_connected
128    }
129
130    /// Records the current client count and says what to do.
131    ///
132    /// `now` is passed rather than read so the rule can be tested at any point
133    /// on the clock without waiting for real time to pass.
134    pub fn observe(&mut self, clients: usize, now: Instant) -> WatchState {
135        if clients > 0 {
136            self.alone_since = None;
137            self.ever_connected = true;
138            return WatchState::Connected;
139        }
140
141        let Some(limit) = self.timeout.duration() else {
142            // No limit: note the moment for reporting, but never expire.
143            self.alone_since.get_or_insert(now);
144            return WatchState::Waiting {
145                remaining: Duration::MAX,
146            };
147        };
148
149        let since = *self.alone_since.get_or_insert(now);
150        let waited = now.saturating_duration_since(since);
151        limit
152            .checked_sub(waited)
153            .filter(|remaining| !remaining.is_zero())
154            .map_or(WatchState::Expired, |remaining| WatchState::Waiting {
155                remaining,
156            })
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use std::time::{Duration, Instant};
163
164    use super::{ClientTimeout, ClientWatch, WatchState};
165
166    #[test]
167    fn ten_seconds_is_the_default() {
168        // Stated in the docs and relied on by every host that does not choose;
169        // changing it silently would change when stations stop.
170        assert_eq!(
171            ClientTimeout::default(),
172            ClientTimeout::After(Duration::from_secs(10))
173        );
174    }
175
176    #[test]
177    fn zero_seconds_means_never_rather_than_immediately() {
178        // A zero-second limit read literally would stop the host on its first
179        // pass, before any client could connect. Zero is how a config file says
180        // "no limit".
181        assert_eq!(ClientTimeout::from_seconds(0), ClientTimeout::Never);
182        assert_eq!(ClientTimeout::Never.duration(), None);
183        assert_eq!(
184            ClientTimeout::from_seconds(30),
185            ClientTimeout::After(Duration::from_secs(30))
186        );
187    }
188
189    #[test]
190    fn a_connected_client_stops_the_clock() {
191        let start = Instant::now();
192        let mut watch = ClientWatch::new(ClientTimeout::from_seconds(10), start);
193
194        assert_eq!(watch.observe(1, start), WatchState::Connected);
195        // Well past the limit, but somebody is there.
196        let later = start + Duration::from_secs(600);
197        assert_eq!(watch.observe(1, later), WatchState::Connected);
198        assert!(watch.ever_connected());
199    }
200
201    #[test]
202    fn the_clock_runs_from_startup_when_nobody_ever_connects() {
203        // The orchestrator died before it connected. Waiting forever for a
204        // first client that is never coming is the bug this prevents.
205        let start = Instant::now();
206        let mut watch = ClientWatch::new(ClientTimeout::from_seconds(10), start);
207
208        assert!(matches!(
209            watch.observe(0, start + Duration::from_secs(9)),
210            WatchState::Waiting { .. }
211        ));
212        assert_eq!(
213            watch.observe(0, start + Duration::from_secs(10)),
214            WatchState::Expired
215        );
216        assert!(!watch.ever_connected(), "nobody ever connected");
217    }
218
219    #[test]
220    fn losing_the_last_client_starts_the_clock_again() {
221        let start = Instant::now();
222        let mut watch = ClientWatch::new(ClientTimeout::from_seconds(10), start);
223
224        // Connected for a while, then gone.
225        watch.observe(1, start);
226        let left = start + Duration::from_secs(100);
227        assert!(matches!(watch.observe(0, left), WatchState::Waiting { .. }));
228
229        // The limit is measured from when they left, not from startup.
230        assert!(matches!(
231            watch.observe(0, left + Duration::from_secs(9)),
232            WatchState::Waiting { .. }
233        ));
234        assert_eq!(
235            watch.observe(0, left + Duration::from_secs(10)),
236            WatchState::Expired
237        );
238    }
239
240    #[test]
241    fn reconnecting_before_the_limit_cancels_it() {
242        // A panel reloading in a browser drops and remakes its connection. That
243        // must not take the station down.
244        let start = Instant::now();
245        let mut watch = ClientWatch::new(ClientTimeout::from_seconds(10), start);
246
247        watch.observe(1, start);
248        let left = start + Duration::from_secs(50);
249        watch.observe(0, left);
250        assert_eq!(
251            watch.observe(1, left + Duration::from_secs(5)),
252            WatchState::Connected
253        );
254        // The clock is off; long past the old deadline, still fine.
255        assert_eq!(
256            watch.observe(1, left + Duration::from_secs(500)),
257            WatchState::Connected
258        );
259    }
260
261    #[test]
262    fn never_does_not_expire_however_long_it_waits() {
263        // A host meant to outlive its clients. Nothing here may end it.
264        let start = Instant::now();
265        let mut watch = ClientWatch::new(ClientTimeout::Never, start);
266
267        for days in [1, 7, 365] {
268            let state = watch.observe(0, start + Duration::from_secs(days * 86_400));
269            assert!(
270                !state.is_expired(),
271                "an unlimited host must never expire, but did after {days} day(s)"
272            );
273        }
274    }
275
276    #[test]
277    fn remaining_counts_down() {
278        // A host reports this while it waits, so an operator watching a console
279        // can see how long is left.
280        let start = Instant::now();
281        let mut watch = ClientWatch::new(ClientTimeout::from_seconds(10), start);
282
283        assert_eq!(
284            watch.observe(0, start + Duration::from_secs(3)),
285            WatchState::Waiting {
286                remaining: Duration::from_secs(7)
287            }
288        );
289        assert_eq!(
290            watch.observe(0, start + Duration::from_secs(8)),
291            WatchState::Waiting {
292                remaining: Duration::from_secs(2)
293            }
294        );
295    }
296}