Skip to main content

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}