Skip to main content

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}