epics_libcom_rs/runtime/supervise.rs
1//! Auto-restart supervisor — sliding-window NRESTARTS pattern.
2//!
3//! Shared by every component that needs "relaunch this thing if it
4//! exits, but give up after too many restarts in too short a time":
5//!
6//! - `epics-bridge-rs::ca_gateway::master` — wraps the gateway daemon
7//! - `epics-tools-rs::procserv` — wraps the supervised child process
8//!
9//! Mirrors the C ca-gateway master semantics (NRESTARTS=10,
10//! RESTART_INTERVAL=600s, RESTART_DELAY=10s — `gateway.cc:22-24`)
11//! and the C procServ `holdoffTime` floor.
12//!
13//! ## Other restart shapes
14//!
15//! Note that some workspace components use different restart shapes
16//! (exponential-backoff retry instead of sliding-window — e.g.
17//! `epics-pva-rs` upstream-monitor restart, `epics-ca-rs` name-server
18//! reconnect). Those are NOT subsumed by this module — exponential
19//! backoff is a different policy with different semantics. This
20//! module is exclusively for the sliding-window pattern.
21
22use std::time::{Duration, Instant};
23
24/// Policy: at most `max_restarts` attempts inside `window`,
25/// pausing `delay` between consecutive restarts.
26#[derive(Debug, Clone, Copy)]
27pub struct RestartPolicy {
28 /// Maximum number of launch attempts within `window`, counted at the
29 /// launch boundary. C ca-gateway's master records each child's start
30 /// time and refuses the next fork once `NRESTARTS` starts already sit
31 /// inside `RESTART_INTERVAL` (`gateway.cc:1506-1539`), so at most this
32 /// many launches occur per window and the next is refused. The initial
33 /// launch consumes the first slot — this is the total launch count,
34 /// not "restarts after the first".
35 pub max_restarts: u32,
36 /// Sliding window over which `max_restarts` is counted.
37 pub window: Duration,
38 /// Delay between restart attempts. Doubles as a "min holdoff
39 /// between consecutive child launches" floor.
40 pub delay: Duration,
41}
42
43impl Default for RestartPolicy {
44 /// Defaults match C ca-gateway: 10 restarts in 600s, 10s delay.
45 fn default() -> Self {
46 Self {
47 max_restarts: 10,
48 window: Duration::from_secs(600),
49 delay: Duration::from_secs(10),
50 }
51 }
52}
53
54/// In-memory bookkeeping for the sliding window. Construct fresh per
55/// supervised target (gateway, procserv child, etc.).
56#[derive(Debug, Default)]
57pub struct RestartTracker {
58 timestamps: Vec<Instant>,
59}
60
61impl RestartTracker {
62 pub fn new() -> Self {
63 Self::default()
64 }
65
66 /// Returns `Ok(())` if a fresh restart fits inside `policy`, in
67 /// which case the current timestamp is appended to the window.
68 /// Returns `Err((max, window_secs))` if the limit was hit.
69 pub fn try_record(&mut self, policy: &RestartPolicy) -> Result<(), (u32, u64)> {
70 let now = Instant::now();
71 // Drop entries outside the window.
72 self.timestamps
73 .retain(|t| now.duration_since(*t) < policy.window);
74 if self.timestamps.len() as u32 >= policy.max_restarts {
75 return Err((policy.max_restarts, policy.window.as_secs()));
76 }
77 self.timestamps.push(now);
78 Ok(())
79 }
80
81 /// Most recent restart timestamp, if any.
82 pub fn last(&self) -> Option<Instant> {
83 self.timestamps.last().copied()
84 }
85
86 /// Reset the window — used by callers that explicitly want to
87 /// "forget" past failures (e.g. operator re-enabled auto-restart).
88 pub fn reset(&mut self) {
89 self.timestamps.clear();
90 }
91}
92
93/// Error returned by [`supervise`] when the policy refuses another
94/// restart.
95#[derive(Debug)]
96pub enum SuperviseError<E> {
97 /// Restart policy hit `max_restarts` inside `window`. Carries the
98 /// final inner error that triggered the abandoned restart, so the
99 /// caller does not lose the root cause — the C ca-gateway master
100 /// loop likewise reports the last child-exit reason when it gives
101 /// up (`gateway.cc` restart loop).
102 TooManyRestarts {
103 /// `max_restarts` from the policy that was exceeded.
104 max_restarts: u32,
105 /// `window` (seconds) the count was measured over.
106 window_secs: u64,
107 /// The inner-task error from the final failed attempt.
108 last_error: E,
109 },
110}
111
112impl<E: std::fmt::Display> std::fmt::Display for SuperviseError<E> {
113 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114 match self {
115 Self::TooManyRestarts {
116 max_restarts,
117 window_secs,
118 last_error,
119 } => write!(
120 f,
121 "supervisor: too many restarts ({max_restarts} in {window_secs}s); \
122 last error: {last_error}"
123 ),
124 }
125 }
126}
127
128impl<E: std::fmt::Display + std::fmt::Debug> std::error::Error for SuperviseError<E> {}
129
130/// Supervise an async task with auto-restart. Returns `Ok` the
131/// first time the task ever returns `Ok`. Returns
132/// `Err(SuperviseError::TooManyRestarts)` once the policy refuses
133/// another attempt.
134///
135/// The restart-window admission check runs at the **launch boundary** —
136/// before each launch, not after a launch has failed — so a fast
137/// crash-loop performs at most `policy.max_restarts` launches per window,
138/// matching C ca-gateway's master, which checks `RESTART_INTERVAL` before
139/// forking the next child (`gateway.cc:1506-1539`).
140///
141/// ```ignore
142/// use epics_base_rs::runtime::supervise::{supervise, RestartPolicy};
143///
144/// supervise(RestartPolicy::default(), || async {
145/// run_my_task().await
146/// }).await
147/// ```
148pub async fn supervise<F, Fut, E>(
149 policy: RestartPolicy,
150 mut task_factory: F,
151) -> Result<(), SuperviseError<E>>
152where
153 F: FnMut() -> Fut,
154 Fut: std::future::Future<Output = Result<(), E>>,
155 E: std::fmt::Debug,
156{
157 let mut tracker = RestartTracker::new();
158 let mut attempt = 0u32;
159 let mut last_error: Option<E> = None;
160
161 loop {
162 // Admission at the LAUNCH boundary. C ca-gateway's master records
163 // each child's start time and, once the window already holds
164 // `NRESTARTS` starts, prints "too many [N+1] restarts" and exits
165 // *before* forking the next child (`gateway.cc:1506-1539`).
166 // `try_record` admits iff fewer than `max_restarts` starts sit
167 // inside `window`, recording this start on success — so the cap
168 // counts launches and a fast crash-loop performs at most
169 // `max_restarts` launches per window. (The pre-fix loop recorded
170 // on failure *after* the launch, comparing the cap against
171 // completed failures, which let one extra launch through.)
172 if let Err((max, win)) = tracker.try_record(&policy) {
173 match last_error {
174 Some(last_error) => {
175 tracing::error!(max, window_secs = win, "supervise: too many restarts");
176 // Carry the final inner error so the caller sees the
177 // root cause of the abandoned supervision, not the cap.
178 return Err(SuperviseError::TooManyRestarts {
179 max_restarts: max,
180 window_secs: win,
181 last_error,
182 });
183 }
184 // `max_restarts == 0` refuses even the first start (no C
185 // analog — C's NRESTARTS is a fixed `10`). The supervisor's
186 // contract is to run the task at least once, so launch this
187 // one time rather than return a TooManyRestarts with no
188 // root-cause error to report.
189 None => tracing::warn!(
190 max,
191 "supervise: max_restarts=0 is degenerate; running the task once"
192 ),
193 }
194 }
195
196 attempt += 1;
197 if attempt > 1 {
198 // Delay between consecutive launches (never before the first),
199 // applied after passing the admission gate — C sleeps
200 // RESTART_DELAY right before each refork (`gateway.cc:1532-1539`).
201 tracing::info!(
202 attempt,
203 delay_ms = policy.delay.as_millis() as u64,
204 "supervise: scheduling restart"
205 );
206 crate::runtime::task::sleep(policy.delay).await;
207 }
208
209 tracing::info!(attempt, "supervise: starting attempt");
210 match task_factory().await {
211 Ok(()) => {
212 tracing::info!(attempt, "supervise: task exited normally");
213 return Ok(());
214 }
215 Err(e) => {
216 tracing::warn!(attempt, error = ?e, "supervise: task failed");
217 last_error = Some(e);
218 }
219 }
220 }
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226 use std::sync::Arc;
227 use std::sync::atomic::{AtomicU32, Ordering};
228
229 #[test]
230 fn rate_limit_bails_after_max() {
231 let policy = RestartPolicy {
232 max_restarts: 3,
233 window: Duration::from_secs(60),
234 delay: Duration::ZERO,
235 };
236 let mut t = RestartTracker::new();
237 assert!(t.try_record(&policy).is_ok());
238 assert!(t.try_record(&policy).is_ok());
239 assert!(t.try_record(&policy).is_ok());
240 assert!(t.try_record(&policy).is_err());
241 }
242
243 #[test]
244 fn reset_clears_window() {
245 let policy = RestartPolicy {
246 max_restarts: 2,
247 window: Duration::from_secs(60),
248 delay: Duration::ZERO,
249 };
250 let mut t = RestartTracker::new();
251 t.try_record(&policy).unwrap();
252 t.try_record(&policy).unwrap();
253 assert!(t.try_record(&policy).is_err());
254 t.reset();
255 assert!(t.try_record(&policy).is_ok());
256 }
257
258 #[epics_macros_rs::epics_test]
259 async fn supervise_immediate_success() {
260 let policy = RestartPolicy {
261 max_restarts: 3,
262 window: Duration::from_secs(60),
263 delay: Duration::from_millis(1),
264 };
265 let result: Result<(), SuperviseError<&str>> =
266 supervise(policy, || async { Ok::<(), &str>(()) }).await;
267 assert!(result.is_ok());
268 }
269
270 #[epics_macros_rs::epics_test]
271 async fn supervise_eventual_success() {
272 let count = Arc::new(AtomicU32::new(0));
273 let policy = RestartPolicy {
274 max_restarts: 5,
275 window: Duration::from_secs(60),
276 delay: Duration::from_millis(1),
277 };
278 let count_clone = count.clone();
279 let result: Result<(), SuperviseError<&str>> = supervise(policy, || {
280 let c = count_clone.clone();
281 async move {
282 let n = c.fetch_add(1, Ordering::Relaxed);
283 if n < 2 {
284 Err::<(), &str>("not yet")
285 } else {
286 Ok::<(), &str>(())
287 }
288 }
289 })
290 .await;
291 assert!(result.is_ok());
292 assert_eq!(count.load(Ordering::Relaxed), 3);
293 }
294
295 #[epics_macros_rs::epics_test]
296 async fn supervise_too_many_restarts() {
297 let policy = RestartPolicy {
298 max_restarts: 2,
299 window: Duration::from_secs(60),
300 delay: Duration::from_millis(1),
301 };
302 let result: Result<(), SuperviseError<&str>> =
303 supervise(policy, || async { Err::<(), &str>("always fails") }).await;
304 match result {
305 Err(SuperviseError::TooManyRestarts {
306 max_restarts,
307 window_secs,
308 last_error,
309 }) => {
310 assert_eq!(max_restarts, 2);
311 assert_eq!(window_secs, 60);
312 // L5: the final inner error is preserved, not discarded.
313 assert_eq!(last_error, "always fails");
314 }
315 other => panic!("expected TooManyRestarts, got {other:?}"),
316 }
317 }
318
319 /// C parity: a fast crash-loop performs at most `max_restarts`
320 /// launches per window before the supervisor gives up — the admission
321 /// check sits at the launch boundary, not after a failed launch. The
322 /// pre-fix loop recorded on failure and admitted `max_restarts + 1`
323 /// launches (one extra full gateway start vs C NRESTARTS).
324 #[epics_macros_rs::epics_test]
325 async fn supervise_launch_count_equals_max_restarts() {
326 for max in [1u32, 2, 3, 10] {
327 let launches = Arc::new(AtomicU32::new(0));
328 let policy = RestartPolicy {
329 max_restarts: max,
330 window: Duration::from_secs(60),
331 delay: Duration::ZERO,
332 };
333 let launches_clone = launches.clone();
334 let result: Result<(), SuperviseError<&str>> = supervise(policy, || {
335 let c = launches_clone.clone();
336 async move {
337 c.fetch_add(1, Ordering::Relaxed);
338 Err::<(), &str>("always fails")
339 }
340 })
341 .await;
342 assert!(
343 matches!(result, Err(SuperviseError::TooManyRestarts { .. })),
344 "max_restarts={max} must end in TooManyRestarts"
345 );
346 assert_eq!(
347 launches.load(Ordering::Relaxed),
348 max,
349 "exactly max_restarts={max} launches must occur, not {} (the pre-fix off-by-one)",
350 max + 1
351 );
352 }
353 }
354
355 /// `max_restarts == 0` has no C analog (NRESTARTS is a fixed 10). The
356 /// supervisor's contract is to run the task at least once, so it must
357 /// launch exactly once and then honor the cap — never panic on the
358 /// missing root-cause error.
359 #[epics_macros_rs::epics_test]
360 async fn supervise_zero_max_runs_task_once() {
361 let launches = Arc::new(AtomicU32::new(0));
362 let policy = RestartPolicy {
363 max_restarts: 0,
364 window: Duration::from_secs(60),
365 delay: Duration::ZERO,
366 };
367 let launches_clone = launches.clone();
368 let result: Result<(), SuperviseError<&str>> = supervise(policy, || {
369 let c = launches_clone.clone();
370 async move {
371 c.fetch_add(1, Ordering::Relaxed);
372 Err::<(), &str>("boom")
373 }
374 })
375 .await;
376 match result {
377 Err(SuperviseError::TooManyRestarts { last_error, .. }) => {
378 assert_eq!(last_error, "boom");
379 }
380 other => panic!("expected TooManyRestarts, got {other:?}"),
381 }
382 assert_eq!(launches.load(Ordering::Relaxed), 1);
383 }
384}