Skip to main content

chio_supervisor/
thread.rs

1//! Supervised OS thread: retains the join handle, wraps the worker body in
2//! `catch_unwind`, restarts with capped backoff, and trips the health flag on a
3//! non-shutdown exit, a panic, or a spawn failure.
4
5use crate::config::{backoff_delay, SupervisedOutcome, SupervisorConfig};
6use crate::health::HealthFlag;
7use crate::time::now_unix_ms;
8use std::panic::{catch_unwind, AssertUnwindSafe};
9use std::sync::atomic::{AtomicBool, Ordering};
10use std::sync::Arc;
11use std::thread::JoinHandle;
12
13/// A supervised OS thread that owns its join handle and a [`HealthFlag`]. The worker
14/// closure is the loop body; it receives the shutdown flag so it can exit promptly
15/// and return [`SupervisedOutcome::Shutdown`].
16pub struct SupervisedThread {
17    handle: Option<JoinHandle<()>>,
18    health: HealthFlag,
19    shutdown: Arc<AtomicBool>,
20}
21
22impl SupervisedThread {
23    /// Spawn a supervised thread. On a caught panic (under an unwind profile) or a
24    /// [`SupervisedOutcome::Restart`], the worker body is re-entered with capped
25    /// backoff, reusing the same owned resources it captured. If the thread cannot be
26    /// spawned at all, the flag is tripped straight to `Failed` so no surface reads
27    /// green off a worker that never started.
28    pub fn spawn<F>(config: SupervisorConfig, worker: F) -> Self
29    where
30        F: FnMut(&Arc<AtomicBool>) -> SupervisedOutcome + Send + 'static,
31    {
32        let health = HealthFlag::new(config.tcb_critical);
33        let shutdown = Arc::new(AtomicBool::new(false));
34        let worker_health = health.clone();
35        let worker_shutdown = Arc::clone(&shutdown);
36        let handle = match std::thread::Builder::new()
37            .name(config.name.to_string())
38            .spawn(move || supervise_loop(config, worker, worker_health, worker_shutdown))
39        {
40            Ok(handle) => Some(handle),
41            Err(error) => {
42                let now = now_unix_ms();
43                health.record_failure(
44                    format!("supervisor could not spawn thread: {error}"),
45                    now,
46                    0,
47                );
48                health.escalate_failed(now);
49                None
50            }
51        };
52        Self {
53            handle,
54            health,
55            shutdown,
56        }
57    }
58
59    /// A cloneable handle to this thread's health.
60    #[must_use]
61    pub fn health(&self) -> HealthFlag {
62        self.health.clone()
63    }
64
65    /// Signal shutdown and join the worker. Returns the terminal health level.
66    pub fn shutdown(self) -> crate::HealthLevel {
67        self.signal_shutdown();
68        self.join()
69    }
70
71    /// Join after the worker reaches its own terminal condition without setting
72    /// the shutdown flag. Use when another owned resource, such as a disconnected
73    /// command channel, tells the worker to drain and exit.
74    pub fn join(mut self) -> crate::HealthLevel {
75        if let Some(handle) = self.handle.take() {
76            let _ = handle.join();
77        }
78        self.health.level()
79    }
80
81    fn signal_shutdown(&self) {
82        self.shutdown.store(true, Ordering::SeqCst);
83    }
84}
85
86impl Drop for SupervisedThread {
87    fn drop(&mut self) {
88        // Never leak a running worker: signal shutdown so the loop observes it and
89        // exits on its next iteration even if the caller forgot to join.
90        self.signal_shutdown();
91    }
92}
93
94fn supervise_loop<F>(
95    config: SupervisorConfig,
96    mut worker: F,
97    health: HealthFlag,
98    shutdown: Arc<AtomicBool>,
99) where
100    F: FnMut(&Arc<AtomicBool>) -> SupervisedOutcome,
101{
102    loop {
103        if shutdown.load(Ordering::SeqCst) {
104            return;
105        }
106        let outcome = catch_unwind(AssertUnwindSafe(|| worker(&shutdown)));
107        match outcome {
108            Ok(SupervisedOutcome::Shutdown) => return,
109            Ok(SupervisedOutcome::Continue) => {
110                // A completed tick resets the consecutive-failure counter and
111                // stamps liveness. Without this, isolated faults separated by
112                // healthy work accumulate and eventually trip (or escalate) a
113                // worker that is in fact recovering between them. A tripped level
114                // is never lowered here; only an operator clear recovers that.
115                health.record_ok(now_unix_ms());
116                continue;
117            }
118            Ok(SupervisedOutcome::Restart) | Err(_) => {
119                if shutdown.load(Ordering::SeqCst) {
120                    return;
121                }
122                let now = now_unix_ms();
123                let reason = match &outcome {
124                    Err(_) => format!("{} worker panicked", config.name),
125                    _ => format!("{} worker exited", config.name),
126                };
127                let count = health.record_failure(reason, now, config.trip_after);
128                if count >= config.max_restarts {
129                    health.escalate_failed(now);
130                    return;
131                }
132                std::thread::sleep(backoff_delay(&config, count));
133            }
134        }
135    }
136}
137
138#[cfg(all(test, not(loom)))]
139mod tests {
140    use super::*;
141    use crate::HealthLevel;
142    use std::sync::atomic::AtomicU32;
143    use std::time::Duration;
144
145    fn fast_config(name: &'static str, tcb: bool, max_restarts: u32) -> SupervisorConfig {
146        SupervisorConfig {
147            name,
148            tcb_critical: tcb,
149            trip_after: 2,
150            max_restarts,
151            base_backoff: Duration::from_millis(1),
152            max_backoff: Duration::from_millis(2),
153        }
154    }
155
156    #[test]
157    fn clean_shutdown_leaves_flag_healthy() {
158        let thread = SupervisedThread::spawn(fast_config("clean", false, 5), |shutdown| {
159            while !shutdown.load(Ordering::SeqCst) {
160                std::thread::sleep(Duration::from_millis(1));
161            }
162            SupervisedOutcome::Shutdown
163        });
164        let level = thread.shutdown();
165        assert_eq!(level, HealthLevel::Healthy);
166    }
167
168    #[test]
169    fn panicking_worker_restarts_then_escalates_to_failed() {
170        let attempts = Arc::new(AtomicU32::new(0));
171        let worker_attempts = Arc::clone(&attempts);
172        let thread = SupervisedThread::spawn(fast_config("panic", true, 3), move |_shutdown| {
173            worker_attempts.fetch_add(1, Ordering::SeqCst);
174            panic!("worker blew up");
175        });
176        let health = thread.health();
177        // Wait for the restart budget to be exhausted.
178        let deadline = std::time::Instant::now() + Duration::from_secs(5);
179        while health.level() != HealthLevel::Failed && std::time::Instant::now() < deadline {
180            std::thread::sleep(Duration::from_millis(2));
181        }
182        assert_eq!(health.level(), HealthLevel::Failed);
183        assert!(health.is_serving_closed());
184        // The supervisor stopped respawning at the budget, not before.
185        assert_eq!(attempts.load(Ordering::SeqCst), 3);
186        thread.shutdown();
187    }
188
189    #[test]
190    fn restart_outcome_trips_to_degraded_at_trip_after() {
191        let thread = SupervisedThread::spawn(fast_config("restart", false, 100), |_shutdown| {
192            SupervisedOutcome::Restart
193        });
194        let health = thread.health();
195        let deadline = std::time::Instant::now() + Duration::from_secs(5);
196        while health.level() == HealthLevel::Healthy && std::time::Instant::now() < deadline {
197            std::thread::sleep(Duration::from_millis(2));
198        }
199        assert_eq!(health.level(), HealthLevel::Degraded);
200        thread.shutdown();
201    }
202
203    #[test]
204    fn interleaved_restart_and_continue_never_trips() {
205        // A worker that fails, recovers, fails, recovers must stay healthy: each
206        // successful tick resets the consecutive-failure counter, so trip_after
207        // (2 here) is never reached across non-consecutive faults. Without the
208        // reset on Continue, the second Restart alone would trip to Degraded even
209        // though the worker keeps succeeding between faults.
210        let phase = Arc::new(AtomicU32::new(0));
211        let worker_phase = Arc::clone(&phase);
212        let done = Arc::new(AtomicBool::new(false));
213        let worker_done = Arc::clone(&done);
214        let thread = SupervisedThread::spawn(fast_config("interleave", false, 1000), move |_s| {
215            let n = worker_phase.fetch_add(1, Ordering::SeqCst);
216            match n {
217                0 | 2 | 4 => SupervisedOutcome::Restart,
218                1 | 3 => SupervisedOutcome::Continue,
219                _ => {
220                    worker_done.store(true, Ordering::SeqCst);
221                    std::thread::sleep(Duration::from_millis(1));
222                    SupervisedOutcome::Continue
223                }
224            }
225        });
226        let health = thread.health();
227        let deadline = std::time::Instant::now() + Duration::from_secs(5);
228        while !done.load(Ordering::SeqCst) && std::time::Instant::now() < deadline {
229            std::thread::sleep(Duration::from_millis(1));
230        }
231        assert_eq!(health.level(), HealthLevel::Healthy);
232        thread.shutdown();
233    }
234
235    #[test]
236    fn continue_outcome_never_trips() {
237        let ticks = Arc::new(AtomicU32::new(0));
238        let worker_ticks = Arc::clone(&ticks);
239        let thread = SupervisedThread::spawn(fast_config("continue", false, 5), move |shutdown| {
240            if shutdown.load(Ordering::SeqCst) {
241                return SupervisedOutcome::Shutdown;
242            }
243            worker_ticks.fetch_add(1, Ordering::SeqCst);
244            std::thread::sleep(Duration::from_millis(1));
245            SupervisedOutcome::Continue
246        });
247        std::thread::sleep(Duration::from_millis(20));
248        assert_eq!(thread.health().level(), HealthLevel::Healthy);
249        thread.shutdown();
250        assert!(ticks.load(Ordering::SeqCst) >= 1);
251    }
252
253    #[test]
254    fn join_does_not_cancel_before_the_worker_enters() {
255        let (release_tx, release_rx) = std::sync::mpsc::sync_channel(1);
256        let (entered_tx, entered_rx) = std::sync::mpsc::sync_channel(1);
257        let thread = SupervisedThread::spawn(fast_config("join-entry", true, 5), move |shutdown| {
258            release_rx.recv().unwrap_or(());
259            assert!(!shutdown.load(Ordering::SeqCst));
260            entered_tx.send(()).unwrap_or(());
261            SupervisedOutcome::Shutdown
262        });
263
264        let joiner = std::thread::spawn(move || thread.join());
265        release_tx.send(()).unwrap_or(());
266        let level = joiner.join().unwrap_or(HealthLevel::Failed);
267        assert_eq!(entered_rx.try_recv(), Ok(()));
268        assert_eq!(level, HealthLevel::Healthy);
269    }
270
271    #[test]
272    fn join_waits_through_restart_backoff() {
273        let attempts = Arc::new(AtomicU32::new(0));
274        let worker_attempts = Arc::clone(&attempts);
275        let (restarting_tx, restarting_rx) = std::sync::mpsc::sync_channel(1);
276        let (restarted_tx, restarted_rx) = std::sync::mpsc::sync_channel(1);
277        let thread = SupervisedThread::spawn(
278            SupervisorConfig {
279                base_backoff: Duration::from_millis(25),
280                max_backoff: Duration::from_millis(25),
281                ..fast_config("join-backoff", true, 5)
282            },
283            move |shutdown| {
284                let attempt = worker_attempts.fetch_add(1, Ordering::SeqCst);
285                if attempt == 0 {
286                    restarting_tx.send(()).unwrap_or(());
287                    SupervisedOutcome::Restart
288                } else {
289                    assert!(!shutdown.load(Ordering::SeqCst));
290                    restarted_tx.send(()).unwrap_or(());
291                    SupervisedOutcome::Shutdown
292                }
293            },
294        );
295
296        restarting_rx.recv().unwrap_or(());
297        let level = thread.join();
298        assert_eq!(restarted_rx.try_recv(), Ok(()));
299        assert_eq!(level, HealthLevel::Healthy);
300        assert_eq!(attempts.load(Ordering::SeqCst), 2);
301    }
302}