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 // SIGINT: ctrlc crate (cross-platform, the only signal that works on
29 // both Unix and Windows without a tokio runtime).
30 if let Err(e) = ctrlc::set_handler(move || {
31 handle_first_signal("SIGINT", 2);
32 }) {
33 tracing::warn!(target: "signals", error = %e, "SIGINT handler registration failed");
34 }
35
36 // SIGTERM + SIGHUP: signal-hook (Unix only; Windows uses TerminateProcess
37 // for SIGTERM equivalents and has no SIGHUP).
38 #[cfg(unix)]
39 {
40 use std::sync::mpsc;
41 let (tx, rx) = mpsc::channel::<i32>();
42
43 let mut signals = match signal_hook::iterator::Signals::new([
44 signal_hook::consts::SIGTERM,
45 signal_hook::consts::SIGHUP,
46 ]) {
47 Ok(s) => s,
48 Err(e) => {
49 tracing::warn!(target: "signals", error = %e, "SIGTERM/SIGHUP handler registration failed");
50 return;
51 }
52 };
53
54 // Detached thread: lives until process exit. The kernel kills it
55 // automatically on process termination. We do NOT join it because
56 // that would require the CLI to wait for an indeterminate signal.
57 std::thread::Builder::new()
58 .name("sqlite-graphrag-sigterm".into())
59 .spawn(move || {
60 for sig in signals.forever() {
61 if tx.send(sig).is_err() {
62 break;
63 }
64 }
65 })
66 .inspect_err(|e| tracing::warn!(target: "signals", error = %e, "SIGTERM/SIGHUP handler thread spawn failed"))
67 .ok();
68
69 // Drain thread: blocks on the channel and calls the same handler
70 // used by the SIGINT path. Synchronous main() can't await this,
71 // but the channel is bounded so a 100ms wait is fine.
72 std::thread::Builder::new()
73 .name("sqlite-graphrag-sigterm-drain".into())
74 .spawn(move || {
75 while let Ok(sig) = rx.recv() {
76 let (name, number) = match sig {
77 libc::SIGTERM => ("SIGTERM", 15u8),
78 libc::SIGHUP => ("SIGHUP", 1u8),
79 _ => continue,
80 };
81 handle_first_signal(name, number);
82 }
83 })
84 .inspect_err(|e| tracing::warn!(target: "signals", error = %e, "SIGTERM drain thread spawn failed"))
85 .ok();
86 }
87}
88
89/// First-signal handler shared by both SIGINT (via crate) and
90/// SIGTERM/SIGHUP (via signal-hook).
91///
92/// Idempotent: only the first invocation does work. The Ctrl+C handler is
93/// synchronous (no tokio runtime is built in the LLM-only main path).
94/// The SIGTERM/SIGHUP task is async but the underlying work is atomic via
95/// the fetch_add pattern.
96fn handle_first_signal(signal_name: &'static str, signal_number: u8) {
97 let prev = crate::SIGNAL_COUNT.fetch_add(1, Ordering::AcqRel);
98 if prev != 0 {
99 // Second signal: forced shutdown. GAP-SG-99: best-effort flush of the
100 // non-blocking file appender before exit so the last diagnostics land.
101 // Avoid stdout I/O (G42/S8); flush is stderr/file only.
102 crate::tracing_init::flush_tracing();
103 std::process::exit(130);
104 }
105 crate::SHUTDOWN.store(true, Ordering::Release);
106 crate::SIGNAL_NUMBER.store(signal_number, Ordering::Release);
107 crate::cancel_token().cancel();
108
109 // Best-effort stderr notice: closed pipe must NEVER abort (G42/S8).
110 use std::io::Write;
111 let _ = writeln!(
112 std::io::stderr(),
113 "shutdown signal received ({signal_name}); finishing current operation gracefully"
114 );
115
116 // GAP-002 (v1.0.82): emit JSON envelope to stdout before exit so that
117 // piped consumers receive a parseable error with `code: 19`
118 // (SHUTDOWN_EXIT_CODE) instead of an empty stdout that triggers
119 // a parse error. Best-effort: if stdout is closed, writeln fails
120 // silently.
121 let envelope = format!(
122 "{{\"error\":true,\"code\":19,\"message\":\"shutdown signal received; operation cancelled by {signal_name}\",\"signal\":\"{signal_name}\",\"graceful\":true}}"
123 );
124 let mut stdout = std::io::stdout().lock();
125 let _ = writeln!(stdout, "{envelope}");
126 let _ = stdout.flush();
127}
128
129#[cfg(test)]
130mod tests {
131 /// G42/S8 regression guard: the SHARED `handle_first_signal` function
132 /// (called by both the SIGINT ctrlc closure and the SIGTERM/SIGHUP
133 /// signal-hook drain) must not contain `eprintln!` or `tracing::warn!`
134 /// — both can panic (and abort under `panic = "abort"`) when stderr
135 /// is a closed pipe in an orphaned process.
136 #[test]
137 fn handler_source_has_no_panicking_io() {
138 let source = include_str!("signals.rs");
139 // The shared first-signal body starts at `fn handle_first_signal`
140 // and ends at the closing brace of the function. We locate the
141 // start of the next free-standing function or the test module
142 // as the boundary.
143 let body_start = source
144 .find("fn handle_first_signal(")
145 .expect("handle_first_signal must exist");
146 let after_body = source[body_start..]
147 .find("\nfn ")
148 .or_else(|| source[body_start..].find("\n#[cfg(test)]"))
149 .expect("body boundary not found");
150 let body = &source[body_start..body_start + after_body];
151 assert!(
152 !body.contains("eprintln!"),
153 "handle_first_signal must not use eprintln! (BrokenPipe panic, G42/C2)"
154 );
155 assert!(
156 !body.contains("tracing::"),
157 "handle_first_signal must not use tracing (stderr I/O can panic, G42/C2)"
158 );
159 assert!(
160 body.contains("let _ = writeln!"),
161 "first-signal notice must be a best-effort write"
162 );
163 assert!(
164 body.contains("std::process::exit(130)"),
165 "forced-exit path must remain in the shared handler"
166 );
167 }
168
169 /// GAP-002 (v1.0.82) regression guard: the JSON envelope must use
170 /// the deterministic SHUTDOWN_EXIT_CODE (19) so LLM agents can
171 /// branch on a single code regardless of the triggering signal.
172 #[test]
173 fn envelope_uses_shutdown_exit_code() {
174 let source = include_str!("signals.rs");
175 // The envelope format string contains "code":19.
176 assert!(
177 source.contains("\\\"code\\\":19"),
178 "shutdown envelope must embed SHUTDOWN_EXIT_CODE = 19"
179 );
180 }
181
182 /// GAP-002 (v1.0.82) regression guard: `AppError::Shutdown` is the
183 /// canonical error variant for shutdown. Constants and i18n are
184 /// wired in lock-step — if SHUTDOWN_EXIT_CODE drifts away from 19,
185 /// this test fails.
186 #[test]
187 fn shutdown_exit_code_is_19() {
188 use crate::constants::SHUTDOWN_EXIT_CODE;
189 assert_eq!(SHUTDOWN_EXIT_CODE, 19);
190 }
191}