Skip to main content

sqlite_graphrag/
signals.rs

1//! Cooperative shutdown wiring, stated per platform.
2//!
3//! Every platform reaches the SAME `handle_first_signal` body, so the
4//! observable contract — `SHUTDOWN` flag, cancellation token, stderr notice,
5//! JSON envelope with `code: 19`, forced exit 130 on the second event — does not
6//! vary. What varies is which OS events can reach it:
7//!
8//! - **Unix (Linux, macOS)**: `SIGINT` through the `ctrlc` crate; `SIGTERM` and
9//!   `SIGHUP` through `signal-hook`. `SIGPIPE` is reset to its default
10//!   disposition in `main`, so a closed stdout pipe kills the process with the
11//!   conventional exit 141.
12//! - **Windows**: `SetConsoleCtrlHandler` covers `CTRL_C_EVENT`,
13//!   `CTRL_BREAK_EVENT`, `CTRL_CLOSE_EVENT`, `CTRL_LOGOFF_EVENT` and
14//!   `CTRL_SHUTDOWN_EVENT`. There is no `SIGTERM`, no `SIGHUP` and no `SIGPIPE`:
15//!   console-close/logoff/shutdown are the closest equivalents of a termination
16//!   request, and the exit-141 half of the contract is produced in `main` by
17//!   classifying the stdout write error as `ErrorKind::BrokenPipe` instead of by
18//!   a signal.
19//! - **Anything else**: `SIGINT` only, via `ctrlc`.
20//!
21//! Windows does NOT go through `ctrlc`. Console control handlers are called
22//! last-registered-first and `ctrlc`'s handler consumes every control type, so
23//! registering both would either double-count one Ctrl+C — which the second-event
24//! rule turns into an immediate exit 130 — or leave one of them dead. One
25//! handler owns the console, and it is this module's.
26
27use std::sync::atomic::Ordering;
28
29/// Registers the global shutdown handler for every event the platform offers.
30///
31/// See the module docs for the exact per-platform event set; on Unix that is
32/// Ctrl+C / SIGTERM / SIGHUP, on Windows the five console control events.
33///
34/// First signal: sets [`SHUTDOWN`](crate::SHUTDOWN) flag, cancels the global
35/// cancellation token and emits a best-effort notice on stderr.
36///
37/// Second signal: calls [`std::process::exit(130)`] for immediate termination
38/// following Unix convention (128 + SIGINT=2) — with ZERO I/O on that path.
39///
40/// # G42/S8 — panic-free by contract
41///
42/// The pre-v1.0.79 handler used `eprintln!` (second signal) and
43/// `tracing::warn!` (first signal). When the parent shell dies the CLI is
44/// reparented to PID 1 and stderr becomes a CLOSED pipe; `eprintln!` then
45/// panics with `BrokenPipe`, which under `panic = "abort"` becomes the
46/// SIGABRT observed on the "ctrl-c" thread (G42/C2 crash report). This
47/// handler therefore:
48/// - writes the first-signal notice with `writeln!` and IGNORES any I/O
49///   error (`let _ =`), never panicking;
50/// - performs NO I/O at all on the forced-exit path.
51///
52/// BrokenPipe on stdout/stderr elsewhere is handled by resetting SIGPIPE
53/// to its default disposition in `main` on Unix, and by classifying the stdout
54/// write error in `main` on Windows — both reach the same clean exit 141.
55pub fn register_shutdown_handler() {
56    // SIGINT via the ctrlc crate everywhere EXCEPT Windows, where the console
57    // control handler below owns every control event (see module docs: two
58    // registered handlers would double-count one Ctrl+C).
59    #[cfg(not(windows))]
60    if let Err(e) = ctrlc::set_handler(move || {
61        handle_first_signal("SIGINT", 2);
62    }) {
63        tracing::warn!(target: "signals", error = %e, "SIGINT handler registration failed");
64    }
65
66    #[cfg(windows)]
67    register_console_ctrl_handler();
68
69    // SIGTERM + SIGHUP: signal-hook (Unix only; neither signal exists on
70    // Windows — its termination requests arrive as console control events).
71    #[cfg(unix)]
72    {
73        use std::sync::mpsc;
74        let (tx, rx) = mpsc::channel::<i32>();
75
76        let mut signals = match signal_hook::iterator::Signals::new([
77            signal_hook::consts::SIGTERM,
78            signal_hook::consts::SIGHUP,
79        ]) {
80            Ok(s) => s,
81            Err(e) => {
82                tracing::warn!(target: "signals", error = %e, "SIGTERM/SIGHUP handler registration failed");
83                return;
84            }
85        };
86
87        // Detached thread: lives until process exit. The kernel kills it
88        // automatically on process termination. We do NOT join it because
89        // that would require the CLI to wait for an indeterminate signal.
90        std::thread::Builder::new()
91            .name("sqlite-graphrag-sigterm".into())
92            .spawn(move || {
93                for sig in signals.forever() {
94                    if tx.send(sig).is_err() {
95                        break;
96                    }
97                }
98            })
99            .inspect_err(|e| tracing::warn!(target: "signals", error = %e, "SIGTERM/SIGHUP handler thread spawn failed"))
100            .ok();
101
102        // Drain thread: blocks on the channel and calls the same handler
103        // used by the SIGINT path. Synchronous main() can't await this,
104        // but the channel is bounded so a 100ms wait is fine.
105        std::thread::Builder::new()
106            .name("sqlite-graphrag-sigterm-drain".into())
107            .spawn(move || {
108                while let Ok(sig) = rx.recv() {
109                    let (name, number) = match sig {
110                        libc::SIGTERM => ("SIGTERM", 15u8),
111                        libc::SIGHUP => ("SIGHUP", 1u8),
112                        _ => continue,
113                    };
114                    handle_first_signal(name, number);
115                }
116            })
117            .inspect_err(|e| tracing::warn!(target: "signals", error = %e, "SIGTERM drain thread spawn failed"))
118            .ok();
119    }
120}
121
122/// Windows counterpart of the Unix signal registration.
123///
124/// `SetConsoleCtrlHandler` is the only mechanism Windows offers for cooperative
125/// termination: there is no `SIGTERM` (`TerminateProcess` is unconditional and
126/// runs no user code) and no `SIGHUP`. The five control events map onto the same
127/// `handle_first_signal` body the Unix paths use, so the shutdown contract is
128/// identical across platforms.
129///
130/// The handler returns `TRUE` for every event it recognises, which claims the
131/// event and stops the default handler from terminating the process outright.
132/// For `CTRL_CLOSE_EVENT`, `CTRL_LOGOFF_EVENT` and `CTRL_SHUTDOWN_EVENT` Windows
133/// still terminates the process a few seconds after the handler returns; that
134/// window is exactly what the graceful path needs to flush its envelope.
135#[cfg(windows)]
136fn register_console_ctrl_handler() {
137    use windows_sys::Win32::Foundation::BOOL;
138    use windows_sys::Win32::System::Console::{
139        SetConsoleCtrlHandler, CTRL_BREAK_EVENT, CTRL_CLOSE_EVENT, CTRL_C_EVENT, CTRL_LOGOFF_EVENT,
140        CTRL_SHUTDOWN_EVENT,
141    };
142
143    /// `windows_sys` models Win32 `BOOL` as `i32`; these are its two values.
144    const TRUE: BOOL = 1;
145    const FALSE: BOOL = 0;
146
147    /// Signal numbers reported through `crate::SIGNAL_NUMBER`. Windows has no
148    /// signal table, so the Unix numbers of the closest equivalents are reused
149    /// to keep the field readable by the same agent logic on every platform.
150    unsafe extern "system" fn console_ctrl_handler(ctrl_type: u32) -> BOOL {
151        let (name, number) = match ctrl_type {
152            CTRL_C_EVENT => ("SIGINT", 2u8),
153            CTRL_BREAK_EVENT => ("SIGBREAK", 21u8),
154            // Closing the console window is the Windows analogue of losing the
155            // controlling terminal, which on Unix arrives as SIGHUP.
156            CTRL_CLOSE_EVENT => ("SIGHUP", 1u8),
157            CTRL_LOGOFF_EVENT | CTRL_SHUTDOWN_EVENT => ("SIGTERM", 15u8),
158            // Unknown control type: decline it so the default handler decides.
159            _ => return FALSE,
160        };
161        handle_first_signal(name, number);
162        TRUE
163    }
164
165    // SAFETY: `console_ctrl_handler` is a free `extern "system"` function with
166    // no captured state and a `'static` lifetime; `SetConsoleCtrlHandler` only
167    // stores the pointer in the process-wide handler list.
168    let registered = unsafe { SetConsoleCtrlHandler(Some(console_ctrl_handler), TRUE) };
169    if registered == FALSE {
170        tracing::warn!(
171            target: "signals",
172            error = %std::io::Error::last_os_error(),
173            "console control handler registration failed"
174        );
175    }
176}
177
178/// First-signal handler shared by SIGINT (via the `ctrlc` crate), SIGTERM and
179/// SIGHUP (via `signal-hook`), and the Windows console control handler.
180///
181/// Idempotent: only the first invocation does work, and every later one takes
182/// the forced-exit path. All three registration paths are plain synchronous
183/// callbacks — no tokio runtime is built in the LLM-only `main` path, and the
184/// signal-hook drain is an ordinary thread blocked on a channel — so the only
185/// cross-thread coordination needed is the `SIGNAL_COUNT.fetch_add` below, whose
186/// atomic result decides first-versus-second event without a lock.
187fn handle_first_signal(signal_name: &'static str, signal_number: u8) {
188    let prev = crate::SIGNAL_COUNT.fetch_add(1, Ordering::AcqRel);
189    if prev != 0 {
190        // Second signal: forced shutdown. GAP-SG-99: best-effort flush of the
191        // non-blocking file appender before exit so the last diagnostics land.
192        // Avoid stdout I/O (G42/S8); flush is stderr/file only.
193        crate::tracing_init::flush_tracing();
194        std::process::exit(130);
195    }
196    crate::SHUTDOWN.store(true, Ordering::Release);
197    crate::SIGNAL_NUMBER.store(signal_number, Ordering::Release);
198    crate::cancel_token().cancel();
199
200    // Best-effort stderr notice: closed pipe must NEVER abort (G42/S8).
201    use std::io::Write;
202    let _ = writeln!(
203        std::io::stderr(),
204        "shutdown signal received ({signal_name}); finishing current operation gracefully"
205    );
206
207    // GAP-002 (v1.0.82): emit JSON envelope to stdout before exit so that
208    // piped consumers receive a parseable error with `code: 19`
209    // (SHUTDOWN_EXIT_CODE) instead of an empty stdout that triggers
210    // a parse error. Best-effort: if stdout is closed, writeln fails
211    // silently.
212    let envelope = format!(
213        "{{\"error\":true,\"code\":19,\"message\":\"shutdown signal received; operation cancelled by {signal_name}\",\"signal\":\"{signal_name}\",\"graceful\":true}}"
214    );
215    let mut stdout = std::io::stdout().lock();
216    let _ = writeln!(stdout, "{envelope}");
217    let _ = stdout.flush();
218}
219
220#[cfg(test)]
221mod tests {
222    /// G42/S8 regression guard: the SHARED `handle_first_signal` function
223    /// (called by both the SIGINT ctrlc closure and the SIGTERM/SIGHUP
224    /// signal-hook drain) must not contain `eprintln!` or `tracing::warn!`
225    /// — both can panic (and abort under `panic = "abort"`) when stderr
226    /// is a closed pipe in an orphaned process.
227    #[test]
228    fn handler_source_has_no_panicking_io() {
229        let source = include_str!("signals.rs");
230        // The shared first-signal body starts at `fn handle_first_signal`
231        // and ends at the closing brace of the function. We locate the
232        // start of the next free-standing function or the test module
233        // as the boundary.
234        let body_start = source
235            .find("fn handle_first_signal(")
236            .expect("handle_first_signal must exist");
237        let after_body = source[body_start..]
238            .find("\nfn ")
239            .or_else(|| source[body_start..].find("\n#[cfg(test)]"))
240            .expect("body boundary not found");
241        let body = &source[body_start..body_start + after_body];
242        assert!(
243            !body.contains("eprintln!"),
244            "handle_first_signal must not use eprintln! (BrokenPipe panic, G42/C2)"
245        );
246        assert!(
247            !body.contains("tracing::"),
248            "handle_first_signal must not use tracing (stderr I/O can panic, G42/C2)"
249        );
250        assert!(
251            body.contains("let _ = writeln!"),
252            "first-signal notice must be a best-effort write"
253        );
254        assert!(
255            body.contains("std::process::exit(130)"),
256            "forced-exit path must remain in the shared handler"
257        );
258    }
259
260    /// GAP-002 (v1.0.82) regression guard: the JSON envelope must use
261    /// the deterministic SHUTDOWN_EXIT_CODE (19) so LLM agents can
262    /// branch on a single code regardless of the triggering signal.
263    #[test]
264    fn envelope_uses_shutdown_exit_code() {
265        let source = include_str!("signals.rs");
266        // The envelope format string contains "code":19.
267        assert!(
268            source.contains("\\\"code\\\":19"),
269            "shutdown envelope must embed SHUTDOWN_EXIT_CODE = 19"
270        );
271    }
272
273    /// GAP-002 (v1.0.82) regression guard: `AppError::Shutdown` is the
274    /// canonical error variant for shutdown. Constants and i18n are
275    /// wired in lock-step — if SHUTDOWN_EXIT_CODE drifts away from 19,
276    /// this test fails.
277    #[test]
278    fn shutdown_exit_code_is_19() {
279        use crate::constants::SHUTDOWN_EXIT_CODE;
280        assert_eq!(SHUTDOWN_EXIT_CODE, 19);
281    }
282}