dwara_core/
supervision.rs

1//! Panic supervision for accept loops (#120, #130).
2//!
3//! One place for the bounded respawn policy every accept surface uses:
4//! the gateway's data-plane listeners (dwara-bin) and the admin API's
5//! accept loop (dwara-admin). A panicked accept task is respawned on
6//! the SAME bound socket (callers share their `TcpListener` behind an
7//! `Arc`), up to a per-caller cap, after which the surface is given up
8//! on with a loud ERROR log — the process itself keeps running (a dead
9//! accept surface must not take sibling listeners and in-flight work
10//! with it; that is not the shutdown idiom anywhere else either).
11//!
12//! This module depends on no other domain: it is pure task plumbing
13//! over tokio handles plus tracing.
14
15/// Supervise one accept task for panics: when an incarnation ends
16/// cleanly (shutdown flush completed) supervision is done; when it
17/// panics it is respawned up to `max_respawns` times with a warning per
18/// respawn, and after the cap an ERROR is logged and the surface is
19/// left down — loudly, but without aborting the process.
20///
21/// `kind` names the supervised surface in the log codes ("listener",
22/// "admin"); `label` identifies the instance (listener name, bind
23/// address). `spawn` builds one incarnation; it is called once
24/// immediately and once per respawn, so it must hand each incarnation
25/// fresh clones of everything the accept loop consumes.
26pub async fn supervise_panics(
27    kind: &str,
28    label: &str,
29    max_respawns: u32,
30    mut spawn: impl FnMut() -> tokio::task::JoinHandle<()>,
31) {
32    let mut respawns = 0u32;
33    loop {
34        let task = spawn();
35        match task.await {
36            // Clean return: the shutdown flush finished (or the task
37            // ended deliberately). Nothing to supervise anymore.
38            Ok(()) => return,
39            Err(err) if err.is_panic() => {
40                if respawns >= max_respawns {
41                    tracing::error!(
42                        code = format!("{kind}_respawn_exhausted"),
43                        kind,
44                        name = %label,
45                        respawns,
46                        "accept loop panicked again after {respawns} respawns; giving up: {kind} '{label}' is DOWN, the rest of the process keeps serving"
47                    );
48                    return;
49                }
50                respawns += 1;
51                tracing::warn!(
52                    code = format!("{kind}_respawn"),
53                    kind,
54                    name = %label,
55                    respawns,
56                    "accept task panicked ({err}); respawning {kind} accept loop"
57                );
58            }
59            // JoinError without a panic only occurs on cancellation,
60            // which this code never does: treat it as a clean end.
61            Err(_) => return,
62        }
63    }
64}
65
66#[cfg(test)]
67mod tests {
68    // White-box tests in src/ per AGENTS.md: the panic boundary
69    // (JoinHandle::is_panic + bounded respawn) is exercised by inducing
70    // real task panics, which is not expressible through any consumer's
71    // public surface. Moved here from dwara-bin's listeners.rs when the
72    // supervisor was extracted (#130) so both accept surfaces test the
73    // one implementation they share.
74
75    use std::sync::atomic::{AtomicU32, Ordering};
76
77    use super::supervise_panics;
78
79    /// Silences (and counts) the default panic hook for spawned-task
80    /// panics so these tests do not spray backtraces into the log; the
81    /// original hook is restored on drop.
82    struct PanicHookQuiet {
83        prev: Box<dyn Fn(&std::panic::PanicHookInfo<'_>) + Send + Sync>,
84    }
85
86    impl PanicHookQuiet {
87        fn install() -> Self {
88            let prev = std::panic::take_hook();
89            std::panic::set_hook(Box::new(|_info| {}));
90            PanicHookQuiet { prev }
91        }
92    }
93
94    impl Drop for PanicHookQuiet {
95        fn drop(&mut self) {
96            let prev = std::mem::replace(
97                &mut self.prev,
98                Box::new(|_info: &std::panic::PanicHookInfo| {}),
99            );
100            std::panic::set_hook(prev);
101        }
102    }
103
104    #[tokio::test]
105    async fn panicking_incarnations_are_respawned_up_to_the_cap_then_given_up() {
106        let _quiet = PanicHookQuiet::install();
107        let spawns = AtomicU32::new(0);
108        supervise_panics("listener", "test-listener", 3, || {
109            spawns.fetch_add(1, Ordering::SeqCst);
110            tokio::spawn(async { panic!("induced accept-loop panic") })
111        })
112        .await;
113        // One initial incarnation plus three respawns; the fourth panic
114        // exhausts the budget and supervision returns (surface left
115        // down loudly, process keeps running).
116        assert_eq!(spawns.load(Ordering::SeqCst), 4);
117    }
118
119    #[tokio::test]
120    async fn clean_incarnation_ends_supervision_without_respawn() {
121        let spawns = AtomicU32::new(0);
122        supervise_panics("listener", "test-listener", 3, || {
123            spawns.fetch_add(1, Ordering::SeqCst);
124            tokio::spawn(async {})
125        })
126        .await;
127        assert_eq!(spawns.load(Ordering::SeqCst), 1);
128    }
129
130    #[tokio::test]
131    async fn respawned_incarnation_ending_cleanly_stops_supervision() {
132        let _quiet = PanicHookQuiet::install();
133        let spawns = AtomicU32::new(0);
134        supervise_panics("admin", "127.0.0.1:0", 3, || {
135            let n = spawns.fetch_add(1, Ordering::SeqCst);
136            // First incarnation panics; the respawn recovers and ends
137            // cleanly (the real-world recovery path: respawn serves the
138            // shutdown flush and returns Ok).
139            if n == 0 {
140                tokio::spawn(async { panic!("induced accept-loop panic") })
141            } else {
142                tokio::spawn(async {})
143            }
144        })
145        .await;
146        assert_eq!(spawns.load(Ordering::SeqCst), 2);
147    }
148
149    #[tokio::test]
150    async fn exhausted_respawn_budget_leaves_sibling_surfaces_serving() {
151        // Process-stays-up analog (#120): inducing a REAL accept-loop
152        // panic in a served binary is not externally expressible, so
153        // the containment property is pinned here — while one surface
154        // panics through its entire respawn budget (and supervision
155        // gives up on it), an independent sibling accept loop keeps
156        // serving both during and after the failure. The process-level
157        // guarantee this models: a dead accept surface never takes the
158        // other listeners down with it.
159        let _quiet = PanicHookQuiet::install();
160        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
161        let addr = listener.local_addr().unwrap();
162        let sibling = tokio::spawn(async move {
163            use tokio::io::{AsyncReadExt, AsyncWriteExt};
164            let mut served = 0usize;
165            while served < 2 {
166                let Ok((mut sock, _)) = listener.accept().await else {
167                    break;
168                };
169                let mut one = [0u8; 1];
170                if sock.read(&mut one).await.is_ok() {
171                    let _ = sock.write_all(b"up").await;
172                    served += 1;
173                }
174            }
175        });
176        // Budget of one: initial incarnation panics, one respawn panics
177        // again, supervision logs the give-up and RETURNS.
178        supervise_panics("listener", "doomed", 1, || {
179            tokio::spawn(async { panic!("induced accept-loop panic") })
180        })
181        .await;
182
183        use tokio::io::{AsyncReadExt, AsyncWriteExt};
184        for _ in 0..2 {
185            let mut sock = tokio::time::timeout(
186                std::time::Duration::from_secs(2),
187                tokio::net::TcpStream::connect(addr),
188            )
189            .await
190            .expect("sibling connect within timeout")
191            .expect("sibling listener is still up");
192            sock.write_all(b"x").await.expect("probe write");
193            let mut got = [0u8; 2];
194            tokio::time::timeout(std::time::Duration::from_secs(2), sock.read_exact(&mut got))
195                .await
196                .expect("sibling reply within timeout")
197                .expect("sibling reply read");
198            assert_eq!(&got, b"up", "sibling listener still serves");
199        }
200        tokio::time::timeout(std::time::Duration::from_secs(2), sibling)
201            .await
202            .expect("sibling task finishes")
203            .expect("sibling task ended cleanly");
204    }
205}