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}