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}