taktora_executor/fatal.rs
1//! Fatal-handler API — `FatalContext`, `FatalSite`, `FatalHandler`, and the
2//! crate-internal `FatalDispatch` that owns the handler plus a swappable
3//! terminal action (production: `std::process::abort`; test: recording stub).
4//!
5//! This module also houses `panic_payload_message`, moved here from
6//! `executor.rs` (Task 1) because it is the natural extraction point for
7//! panic-payload introspection shared by the fatal path.
8
9// This is a private module; pub(crate) on items is intentional — they are used
10// by executor.rs / pool.rs once Task 3 wires the hot path.
11#![allow(clippy::redundant_pub_crate)]
12
13use std::sync::Arc;
14
15// ── Public API ────────────────────────────────────────────────────────────────
16
17/// Why the runtime is about to abort. Passed to the fatal handler.
18///
19/// Marked `#[non_exhaustive]` so future fields (e.g. a thread name, a
20/// stack-trace fragment) do not break existing match/struct-init expressions.
21#[non_exhaustive]
22pub struct FatalContext {
23 /// Best-effort message extracted from the panic payload.
24 pub cause: String,
25 /// Which runtime boundary caught it.
26 pub site: FatalSite,
27}
28
29/// Which executor boundary detected the unrecoverable fault.
30///
31/// Marked `#[non_exhaustive]` so new boundaries (e.g. a future timer thread)
32/// can be added without breaking `match` arms in caller code.
33#[non_exhaustive]
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum FatalSite {
36 /// A pool worker thread's `catch_unwind` boundary re-panicked.
37 PoolWorker,
38 /// The inline-submit path (pool size 0) caught a second panic.
39 InlineSubmit,
40 /// The executor's main run-loop caught an unrecoverable panic.
41 ExecutorRunLoop,
42}
43
44/// An `Arc`-wrapped callback invoked once on the fail-fast path.
45///
46/// **Contract** (from [`crate::ExecutorBuilder::on_fatal`]):
47/// - Runs over known-unsound executor state.
48/// - MUST NOT touch executor internals.
49/// - A panic inside the handler routes straight to `abort()`.
50pub type FatalHandler = Arc<dyn Fn(&FatalContext) + Send + Sync + 'static>;
51
52// ── crate-internal dispatch ────────────────────────────────────────────────────
53
54/// Owns a user handler and a terminal action. The terminal is
55/// `std::process::abort` in production; tests may substitute a recording stub
56/// via `FatalDispatch::with_terminal` (available in `#[cfg(test)]` only).
57pub(crate) struct FatalDispatch {
58 handler: FatalHandler,
59 terminal: Arc<dyn Fn(&FatalContext) + Send + Sync + 'static>,
60}
61
62impl FatalDispatch {
63 /// Production constructor. Terminal is `std::process::abort`.
64 pub(crate) fn new(handler: FatalHandler) -> Self {
65 Self {
66 handler,
67 terminal: Arc::new(|_ctx| std::process::abort()),
68 }
69 }
70
71 /// Test-only constructor that allows substituting the terminal.
72 ///
73 /// Only available in `cfg(test)` builds — the abort terminal is the only
74 /// terminal reachable in release.
75 #[cfg(test)]
76 pub(crate) fn with_terminal(
77 handler: FatalHandler,
78 terminal: impl Fn(&FatalContext) + Send + Sync + 'static,
79 ) -> Self {
80 Self {
81 handler,
82 terminal: Arc::new(terminal),
83 }
84 }
85
86 /// Return a reference to the stored handler.
87 ///
88 /// Only available in test builds — the handler is an implementation detail;
89 /// production code has no need to inspect it.
90 #[cfg(test)]
91 pub(crate) fn handler(&self) -> &FatalHandler {
92 &self.handler
93 }
94
95 /// Invoke the handler (catch-guarded so a handler panic still reaches the
96 /// terminal), then invoke the terminal.
97 ///
98 /// In production the terminal calls `std::process::abort()` and therefore
99 /// this function diverges. In tests the terminal records and returns.
100 pub(crate) fn fire(&self, ctx: &FatalContext) {
101 // SAFETY: on the production path the terminal calls std::process::abort()
102 // and the process never resumes use of `ctx` or any state captured by the
103 // handler closure, so inconsistent state is never observable. The test
104 // terminal returns, but test closures hold no cross-unwind invariants.
105 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (self.handler)(ctx)));
106 // Production terminal diverges (abort). Test terminal records + returns.
107 //
108 // Deliberately NOT catch-guarded: the production terminal is
109 // `std::process::abort()`, which cannot unwind, so there is nothing to
110 // catch. A panicking terminal can only come from a `#[cfg(test)]`
111 // fixture, where it is a test bug that must surface loudly rather than
112 // be masked.
113 (self.terminal)(ctx);
114 }
115}
116
117/// Run `f`, converting any escaping (framework-internal) panic into a fail-fast.
118/// Returns `Some(r)` on success. On panic, calls `fatal.fire(...)`; in production
119/// `fire` aborts and this never returns, so the `None` is observable only under a
120/// test terminal.
121pub(crate) fn guard_or_fatal<R>(
122 fatal: &FatalDispatch,
123 site: FatalSite,
124 f: impl FnOnce() -> R,
125) -> Option<R> {
126 // SAFETY: on the production path `fatal.fire` calls std::process::abort() and
127 // the process never resumes use of any state captured by `f`, so a
128 // possibly-inconsistent captured state is never observed after the panic.
129 // (The test terminal returns, but test closures hold no cross-unwind
130 // invariants.) This matches the existing AssertUnwindSafe convention in this
131 // crate's catch-unwind boundaries.
132 match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
133 Ok(r) => Some(r),
134 Err(payload) => {
135 let cause =
136 panic_payload_message(&*payload).unwrap_or_else(|| "framework panic".to_string());
137 fatal.fire(&FatalContext { cause, site });
138 None
139 }
140 }
141}
142
143// ── Shared helper (moved from executor.rs Task 1) ─────────────────────────────
144
145/// Extract a human-readable message from a panic payload.
146///
147/// Returns `Some(msg)` when the payload is a `&str` or `String`, and `None`
148/// for any other payload type. Callers may supply their own fallback for the
149/// `None` case, which makes the helper reusable across different catch-unwind
150/// boundaries that may want different default messages.
151pub(crate) fn panic_payload_message(payload: &(dyn core::any::Any + Send)) -> Option<String> {
152 payload
153 .downcast_ref::<&str>()
154 .map(|s| (*s).to_string())
155 .or_else(|| payload.downcast_ref::<String>().cloned())
156}
157
158// ── Tests ─────────────────────────────────────────────────────────────────────
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163 use std::sync::{Arc, Mutex};
164
165 // ── panic_payload_message ─────────────────────────────────────────────────
166
167 #[test]
168 fn panic_payload_message_str_payload() {
169 let payload = std::panic::catch_unwind(|| panic!("static str msg")).unwrap_err();
170 assert_eq!(
171 panic_payload_message(&*payload),
172 Some("static str msg".to_string())
173 );
174 }
175
176 #[test]
177 fn panic_payload_message_string_payload() {
178 let msg = "owned string msg".to_string();
179 let payload = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| panic!("{}", msg)))
180 .unwrap_err();
181 assert_eq!(
182 panic_payload_message(&*payload),
183 Some("owned string msg".to_string())
184 );
185 }
186
187 #[test]
188 fn panic_payload_message_non_string_payload() {
189 let payload = std::panic::catch_unwind(|| std::panic::panic_any(42_u32)).unwrap_err();
190 assert_eq!(panic_payload_message(&*payload), None);
191 }
192
193 // ── FatalDispatch ─────────────────────────────────────────────────────────
194
195 /// Helper: build a recording terminal + a shared log Vec.
196 fn recording_terminal() -> (
197 Arc<Mutex<Vec<String>>>,
198 impl Fn(&FatalContext) + Send + Sync + 'static,
199 ) {
200 let log: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
201 let log2 = Arc::clone(&log);
202 let terminal = move |ctx: &FatalContext| {
203 log2.lock().unwrap().push(format!("terminal:{}", ctx.cause));
204 };
205 (log, terminal)
206 }
207
208 #[test]
209 fn fire_runs_handler_then_terminal_in_order() {
210 let order: Arc<Mutex<Vec<&'static str>>> = Arc::new(Mutex::new(Vec::new()));
211
212 let order_h = Arc::clone(&order);
213 let handler: FatalHandler = Arc::new(move |_ctx| {
214 order_h.lock().unwrap().push("handler");
215 });
216
217 let order_t = Arc::clone(&order);
218 let terminal = move |_ctx: &FatalContext| {
219 order_t.lock().unwrap().push("terminal");
220 };
221
222 let dispatch = FatalDispatch::with_terminal(handler, terminal);
223 dispatch.fire(&FatalContext {
224 cause: "boom".to_string(),
225 site: FatalSite::PoolWorker,
226 });
227
228 let log = order.lock().unwrap().clone();
229 assert_eq!(
230 log,
231 vec!["handler", "terminal"],
232 "handler must run before terminal"
233 );
234 }
235
236 #[test]
237 fn fire_handler_panic_still_reaches_terminal() {
238 let (log, terminal) = recording_terminal();
239
240 let panicking_handler: FatalHandler = Arc::new(|_ctx| panic!("handler exploded"));
241
242 let dispatch = FatalDispatch::with_terminal(panicking_handler, terminal);
243 dispatch.fire(&FatalContext {
244 cause: "cause-xyz".to_string(),
245 site: FatalSite::ExecutorRunLoop,
246 });
247
248 let entries = log.lock().unwrap().clone();
249 // Terminal must have been reached even though handler panicked.
250 assert!(
251 entries.iter().any(|e| e.contains("terminal:cause-xyz")),
252 "terminal not reached after handler panic; log: {entries:?}"
253 );
254 }
255
256 // ── guard_or_fatal (TEST_0823 mechanism) ──────────────────────────────────
257
258 /// Helper: a `FatalDispatch` whose terminal records `(site, cause)` into a
259 /// shared Vec instead of aborting, so the boundary is observable in-process.
260 type Recorder = Arc<Mutex<Vec<(FatalSite, String)>>>;
261
262 fn recording_dispatch() -> (Recorder, FatalDispatch) {
263 let rec: Recorder = Arc::new(Mutex::new(Vec::new()));
264 let rec2 = Arc::clone(&rec);
265 let handler: FatalHandler = Arc::new(|_ctx| {});
266 let dispatch = FatalDispatch::with_terminal(handler, move |ctx| {
267 rec2.lock().unwrap().push((ctx.site, ctx.cause.clone()));
268 });
269 (rec, dispatch)
270 }
271
272 #[test]
273 fn guard_or_fatal_success_returns_some_and_does_not_fire() {
274 let (rec, dispatch) = recording_dispatch();
275 let out = guard_or_fatal(&dispatch, FatalSite::ExecutorRunLoop, || 7_u32);
276 assert_eq!(out, Some(7));
277 assert!(
278 rec.lock().unwrap().is_empty(),
279 "terminal must not fire on success"
280 );
281 }
282
283 /// `TEST_0823` — a synthetic framework panic injected through
284 /// `guard_or_fatal` fires the fatal terminal exactly once with the
285 /// expected `FatalSite` (`PoolWorker`) and cause.
286 #[test]
287 fn guard_or_fatal_panic_fires_once_with_site_and_cause() {
288 let (rec, dispatch) = recording_dispatch();
289 let out: Option<()> = guard_or_fatal(&dispatch, FatalSite::PoolWorker, || {
290 panic!("synthetic infra panic")
291 });
292 // Under the recording terminal `fire` returns, so `guard_or_fatal`
293 // yields `None`.
294 assert!(
295 out.is_none(),
296 "panic path must yield None under test terminal"
297 );
298 let entries = rec.lock().unwrap().clone();
299 assert_eq!(entries.len(), 1, "fatal must fire exactly once");
300 assert_eq!(entries[0].0, FatalSite::PoolWorker);
301 assert_eq!(entries[0].1, "synthetic infra panic");
302 }
303
304 /// `TEST_0823` — the `ExecutorRunLoop` site is verified at the
305 /// `guard_or_fatal` mechanism level (no deterministic run-loop
306 /// fault-injection seam exists).
307 #[test]
308 fn guard_or_fatal_propagates_run_loop_site() {
309 // Covers the ExecutorRunLoop site via the same mechanism (a full
310 // end-to-end executor trigger that panics *inside* the WaitSet drive is
311 // impractical to provoke deterministically without an artificial fault
312 // injection seam, so the boundary is proven at the helper level).
313 let (rec, dispatch) = recording_dispatch();
314 let out: Option<()> = guard_or_fatal(&dispatch, FatalSite::ExecutorRunLoop, || {
315 panic!("run-loop boom")
316 });
317 assert!(out.is_none());
318 let entries = rec.lock().unwrap().clone();
319 assert_eq!(entries.len(), 1);
320 assert_eq!(entries[0].0, FatalSite::ExecutorRunLoop);
321 assert_eq!(entries[0].1, "run-loop boom");
322 }
323
324 #[test]
325 fn guard_or_fatal_non_string_payload_uses_fallback_cause() {
326 let (rec, dispatch) = recording_dispatch();
327 let out: Option<()> = guard_or_fatal(&dispatch, FatalSite::InlineSubmit, || {
328 std::panic::panic_any(42_u32)
329 });
330 assert!(out.is_none());
331 let entries = rec.lock().unwrap().clone();
332 assert_eq!(entries.len(), 1);
333 assert_eq!(entries[0].1, "framework panic");
334 }
335
336 #[test]
337 fn fire_default_noop_handler_reaches_terminal() {
338 let (log, terminal) = recording_terminal();
339
340 // No-op handler — same as what ExecutorBuilder::build produces when
341 // on_fatal is not called.
342 let noop: FatalHandler = Arc::new(|_ctx| {});
343
344 let dispatch = FatalDispatch::with_terminal(noop, terminal);
345 dispatch.fire(&FatalContext {
346 cause: "default".to_string(),
347 site: FatalSite::InlineSubmit,
348 });
349
350 let entries = log.lock().unwrap().clone();
351 assert!(
352 entries.iter().any(|e| e.contains("terminal:default")),
353 "terminal not reached for default no-op handler; log: {entries:?}"
354 );
355 }
356}