use std::sync::atomic::Ordering;
pub fn register_shutdown_handler() {
if let Err(e) = ctrlc::set_handler(move || {
handle_first_signal("SIGINT", 2);
}) {
tracing::warn!(target: "signals", error = %e, "SIGINT handler registration failed");
}
#[cfg(unix)]
{
use std::sync::mpsc;
let (tx, rx) = mpsc::channel::<i32>();
let mut signals = match signal_hook::iterator::Signals::new([
signal_hook::consts::SIGTERM,
signal_hook::consts::SIGHUP,
]) {
Ok(s) => s,
Err(e) => {
tracing::warn!(target: "signals", error = %e, "SIGTERM/SIGHUP handler registration failed");
return;
}
};
std::thread::Builder::new()
.name("sqlite-graphrag-sigterm".into())
.spawn(move || {
for sig in signals.forever() {
if tx.send(sig).is_err() {
break;
}
}
})
.expect("failed to spawn SIGTERM/SIGHUP handler thread");
std::thread::Builder::new()
.name("sqlite-graphrag-sigterm-drain".into())
.spawn(move || {
while let Ok(sig) = rx.recv() {
let (name, number) = match sig {
libc::SIGTERM => ("SIGTERM", 15u8),
libc::SIGHUP => ("SIGHUP", 1u8),
_ => continue,
};
handle_first_signal(name, number);
}
})
.expect("failed to spawn SIGTERM drain thread");
}
}
fn handle_first_signal(signal_name: &'static str, signal_number: u8) {
let prev = crate::SIGNAL_COUNT.fetch_add(1, Ordering::AcqRel);
if prev != 0 {
std::process::exit(130);
}
crate::SHUTDOWN.store(true, Ordering::Release);
crate::SIGNAL_NUMBER.store(signal_number, Ordering::Release);
crate::cancel_token().cancel();
use std::io::Write;
let _ = writeln!(
std::io::stderr(),
"shutdown signal received ({signal_name}); finishing current operation gracefully"
);
let envelope = format!(
"{{\"error\":true,\"code\":19,\"message\":\"shutdown signal received; operation cancelled by {signal_name}\",\"signal\":\"{signal_name}\",\"graceful\":true}}"
);
let mut stdout = std::io::stdout().lock();
let _ = writeln!(stdout, "{envelope}");
let _ = stdout.flush();
}
#[cfg(test)]
mod tests {
#[test]
fn handler_source_has_no_panicking_io() {
let source = include_str!("signals.rs");
let body_start = source
.find("fn handle_first_signal(")
.expect("handle_first_signal must exist");
let after_body = source[body_start..]
.find("\nfn ")
.or_else(|| source[body_start..].find("\n#[cfg(test)]"))
.expect("body boundary not found");
let body = &source[body_start..body_start + after_body];
assert!(
!body.contains("eprintln!"),
"handle_first_signal must not use eprintln! (BrokenPipe panic, G42/C2)"
);
assert!(
!body.contains("tracing::"),
"handle_first_signal must not use tracing (stderr I/O can panic, G42/C2)"
);
assert!(
body.contains("let _ = writeln!"),
"first-signal notice must be a best-effort write"
);
assert!(
body.contains("std::process::exit(130)"),
"forced-exit path must remain in the shared handler"
);
}
#[test]
fn envelope_uses_shutdown_exit_code() {
let source = include_str!("signals.rs");
assert!(
source.contains("\\\"code\\\":19"),
"shutdown envelope must embed SHUTDOWN_EXIT_CODE = 19"
);
}
#[test]
fn shutdown_exit_code_is_19() {
use crate::constants::SHUTDOWN_EXIT_CODE;
assert_eq!(SHUTDOWN_EXIT_CODE, 19);
}
}