From 7bdf8eb60993a1c1efe74cd091ffcc06a1fb7fee Mon Sep 17 00:00:00 2001 From: argenis de la rosa Date: Mon, 2 Mar 2026 13:32:32 -0500 Subject: [PATCH] fix(daemon): handle sigterm shutdown signal Wait for either SIGINT or SIGTERM on Unix so daemon mode behaves correctly under container and process-manager termination flows. Record signal-specific shutdown reasons and add unit tests for shutdown signal labeling. Refs #2529 --- src/daemon/mod.rs | 56 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 3 deletions(-) diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index 1d948e51b..bb0a4720d 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -8,6 +8,40 @@ use tokio::time::Duration; const STATUS_FLUSH_SECONDS: u64 = 5; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ShutdownSignal { + CtrlC, + SigTerm, +} + +fn shutdown_reason(signal: ShutdownSignal) -> &'static str { + match signal { + ShutdownSignal::CtrlC => "shutdown requested (SIGINT)", + ShutdownSignal::SigTerm => "shutdown requested (SIGTERM)", + } +} + +async fn wait_for_shutdown_signal() -> Result { + #[cfg(unix)] + { + use tokio::signal::unix::{signal, SignalKind}; + + let mut sigterm = signal(SignalKind::terminate())?; + tokio::select! { + ctrl_c = tokio::signal::ctrl_c() => { + ctrl_c?; + Ok(ShutdownSignal::CtrlC) + } + _ = sigterm.recv() => Ok(ShutdownSignal::SigTerm), + } + } + #[cfg(not(unix))] + { + tokio::signal::ctrl_c().await?; + Ok(ShutdownSignal::CtrlC) + } +} + pub async fn run(config: Config, host: String, port: u16) -> Result<()> { // Pre-flight: check if port is already in use by another zeroclaw daemon if let Err(_e) = check_port_available(&host, port).await { @@ -106,10 +140,10 @@ pub async fn run(config: Config, host: String, port: u16) -> Result<()> { println!("🧠 ZeroClaw daemon started"); println!(" Gateway: http://{host}:{port}"); println!(" Components: gateway, channels, heartbeat, scheduler"); - println!(" Ctrl+C to stop"); + println!(" Ctrl+C or SIGTERM to stop"); - tokio::signal::ctrl_c().await?; - crate::health::mark_component_error("daemon", "shutdown requested"); + let signal = wait_for_shutdown_signal().await?; + crate::health::mark_component_error("daemon", shutdown_reason(signal)); for handle in &handles { handle.abort(); @@ -444,6 +478,22 @@ mod tests { assert_eq!(path, tmp.path().join("daemon_state.json")); } + #[test] + fn shutdown_reason_for_ctrl_c_mentions_sigint() { + assert_eq!( + shutdown_reason(ShutdownSignal::CtrlC), + "shutdown requested (SIGINT)" + ); + } + + #[test] + fn shutdown_reason_for_sigterm_mentions_sigterm() { + assert_eq!( + shutdown_reason(ShutdownSignal::SigTerm), + "shutdown requested (SIGTERM)" + ); + } + #[tokio::test] async fn supervisor_marks_error_and_restart_on_failure() { let handle = spawn_component_supervisor("daemon-test-fail", 1, 1, || async {