sqlite_graphrag/signals.rs
1//! Cross-platform signal handling: SIGINT, SIGTERM, SIGHUP.
2
3use std::sync::atomic::Ordering;
4
5/// Registers the global shutdown handler for Ctrl+C / SIGTERM / SIGHUP.
6///
7/// First signal: sets [`SHUTDOWN`](crate::SHUTDOWN) flag, cancels the global
8/// cancellation token and emits a best-effort notice on stderr.
9///
10/// Second signal: calls [`std::process::exit(130)`] for immediate termination
11/// following Unix convention (128 + SIGINT=2) — with ZERO I/O on that path.
12///
13/// # G42/S8 — panic-free by contract
14///
15/// The pre-v1.0.79 handler used `eprintln!` (second signal) and
16/// `tracing::warn!` (first signal). When the parent shell dies the CLI is
17/// reparented to PID 1 and stderr becomes a CLOSED pipe; `eprintln!` then
18/// panics with `BrokenPipe`, which under `panic = "abort"` becomes the
19/// SIGABRT observed on the "ctrl-c" thread (G42/C2 crash report). This
20/// handler therefore:
21/// - writes the first-signal notice with `writeln!` and IGNORES any I/O
22/// error (`let _ =`), never panicking;
23/// - performs NO I/O at all on the forced-exit path.
24///
25/// BrokenPipe on stdout/stderr elsewhere is handled by resetting SIGPIPE
26/// to its default disposition in `main` (clean exit 141, Unix convention).
27pub fn register_shutdown_handler() {
28 if let Err(e) = ctrlc::set_handler(move || {
29 let prev = crate::SIGNAL_COUNT.fetch_add(1, Ordering::AcqRel);
30 if prev == 0 {
31 crate::SHUTDOWN.store(true, Ordering::Release);
32 crate::SIGNAL_NUMBER.store(2, Ordering::Release);
33 crate::cancel_token().cancel();
34 // Best-effort notice: a closed stderr pipe must NEVER abort
35 // the process (G42/S8). `writeln!` returns the io::Error that
36 // the panicking macro would swallow into an abort; we
37 // discard it explicitly.
38 use std::io::Write;
39 let _ = writeln!(
40 std::io::stderr(),
41 "shutdown signal received; finishing current operation gracefully"
42 );
43 } else {
44 // Forced shutdown: NO I/O of any kind before exiting (a
45 // write here was the exact SIGABRT trigger of G42/C2).
46 std::process::exit(130);
47 }
48 }) {
49 tracing::warn!(target: "signals", error = %e, "signal handler registration failed");
50 }
51}
52
53#[cfg(test)]
54mod tests {
55 /// G42/S8 regression guard: the handler source must not contain
56 /// `eprintln!` or `tracing::warn!` inside the signal closure — both
57 /// can panic (and abort under `panic = "abort"`) when stderr is a
58 /// closed pipe in an orphaned process.
59 #[test]
60 fn handler_source_has_no_panicking_io() {
61 let source = include_str!("signals.rs");
62 let closure_start = source
63 .find("ctrlc::set_handler")
64 .expect("handler registration must exist");
65 // The closure body ends at the forced-exit call (searched FROM
66 // the closure start — the doc comment above the fn also mentions
67 // exit(130)); the registration-failure log AFTER the closure may
68 // use tracing (it runs on the main thread with a live stderr).
69 let closure_end = closure_start
70 + source[closure_start..]
71 .find("std::process::exit(130)")
72 .expect("forced-exit path must exist");
73 let closure_body = &source[closure_start..closure_end];
74 assert!(
75 !closure_body.contains("eprintln!"),
76 "signal closure must not use eprintln! (BrokenPipe panic, G42/C2)"
77 );
78 assert!(
79 !closure_body.contains("tracing::"),
80 "signal closure must not use tracing (stderr I/O can panic, G42/C2)"
81 );
82 assert!(
83 closure_body.contains("let _ = writeln!"),
84 "first-signal notice must be a best-effort write"
85 );
86 }
87}