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}