use tokio_util::sync::CancellationToken;
#[cfg(unix)]
use crate::error::DaemonError;
use crate::error::DaemonResult;
#[derive(Debug)]
#[must_use = "SignalGuard must be kept alive for signal handling to remain active"]
pub struct SignalGuard {
handles: Vec<tokio::task::JoinHandle<()>>,
}
impl Drop for SignalGuard {
fn drop(&mut self) {
for handle in self.handles.drain(..) {
handle.abort();
}
}
}
pub fn install_signal_handlers(shutdown: CancellationToken) -> DaemonResult<SignalGuard> {
let handles = register_handlers(shutdown)?;
Ok(SignalGuard { handles })
}
#[cfg(unix)]
fn register_handlers(
shutdown: CancellationToken,
) -> DaemonResult<Vec<tokio::task::JoinHandle<()>>> {
use tokio::signal::unix::{SignalKind, signal};
let mut sigterm =
signal(SignalKind::terminate()).map_err(|e| DaemonError::SignalSetup { source: e })?;
let mut sigint =
signal(SignalKind::interrupt()).map_err(|e| DaemonError::SignalSetup { source: e })?;
let mut sighup =
signal(SignalKind::hangup()).map_err(|e| DaemonError::SignalSetup { source: e })?;
let shutdown_term = shutdown.clone();
let h_term = tokio::spawn(async move {
loop {
if sigterm.recv().await.is_none() {
tracing::warn!("SIGTERM signal driver closed — listener exiting");
break;
}
tracing::info!("received SIGTERM — initiating graceful shutdown");
shutdown_term.cancel();
}
});
let shutdown_int = shutdown.clone();
let h_int = tokio::spawn(async move {
loop {
if sigint.recv().await.is_none() {
tracing::warn!("SIGINT signal driver closed — listener exiting");
break;
}
tracing::info!("received SIGINT — initiating graceful shutdown");
shutdown_int.cancel();
}
});
let h_hup = tokio::spawn(async move {
loop {
if sighup.recv().await.is_none() {
tracing::warn!("SIGHUP signal driver closed — listener exiting");
break;
}
tracing::warn!(
"received SIGHUP — treating as graceful shutdown per Task 9 §B.4 \
(hot-reload is out of scope)"
);
shutdown.cancel();
}
});
Ok(vec![h_term, h_int, h_hup])
}
#[cfg(not(unix))]
fn register_handlers(
shutdown: CancellationToken,
) -> DaemonResult<Vec<tokio::task::JoinHandle<()>>> {
let h = tokio::spawn(async move {
loop {
if let Err(e) = tokio::signal::ctrl_c().await {
tracing::warn!("ctrl-c listener error: {e} — stopping Ctrl-C handler");
break;
}
tracing::info!("received Ctrl-C — initiating graceful shutdown");
shutdown.cancel();
}
});
Ok(vec![h])
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use tokio_util::sync::CancellationToken;
use super::install_signal_handlers;
async fn wait_cancelled(token: &CancellationToken) -> bool {
tokio::time::timeout(Duration::from_secs(5), token.cancelled())
.await
.is_ok()
}
#[cfg(unix)]
#[tokio::test]
async fn sigterm_triggers_cancellation_token() {
let shutdown = CancellationToken::new();
let _guard = install_signal_handlers(shutdown.clone())
.expect("install_signal_handlers must succeed");
tokio::task::yield_now().await;
let pid = unsafe { libc::getpid() };
let rc = unsafe { libc::kill(pid, libc::SIGTERM) };
assert_eq!(rc, 0, "kill(getpid(), SIGTERM) must succeed");
assert!(
wait_cancelled(&shutdown).await,
"shutdown token must be cancelled after SIGTERM"
);
}
#[cfg(unix)]
#[tokio::test]
async fn sigint_triggers_cancellation_token() {
let shutdown = CancellationToken::new();
let _guard = install_signal_handlers(shutdown.clone())
.expect("install_signal_handlers must succeed");
tokio::task::yield_now().await;
let pid = unsafe { libc::getpid() };
let rc = unsafe { libc::kill(pid, libc::SIGINT) };
assert_eq!(rc, 0, "kill(getpid(), SIGINT) must succeed");
assert!(
wait_cancelled(&shutdown).await,
"shutdown token must be cancelled after SIGINT"
);
}
#[cfg(unix)]
#[tokio::test]
async fn sighup_triggers_cancellation_token_with_warn_log() {
let shutdown = CancellationToken::new();
let _guard = install_signal_handlers(shutdown.clone())
.expect("install_signal_handlers must succeed");
tokio::task::yield_now().await;
let pid = unsafe { libc::getpid() };
let rc = unsafe { libc::kill(pid, libc::SIGHUP) };
assert_eq!(rc, 0, "kill(getpid(), SIGHUP) must succeed");
assert!(
wait_cancelled(&shutdown).await,
"shutdown token must be cancelled after SIGHUP"
);
}
#[cfg(unix)]
#[tokio::test]
async fn install_is_idempotent_across_independent_invocations() {
{
let shutdown = CancellationToken::new();
let _guard =
install_signal_handlers(shutdown.clone()).expect("first install must succeed");
}
let shutdown2 = CancellationToken::new();
let _guard2 = install_signal_handlers(shutdown2.clone())
.expect("second install after drop must succeed");
tokio::task::yield_now().await;
let pid = unsafe { libc::getpid() };
let rc = unsafe { libc::kill(pid, libc::SIGTERM) };
assert_eq!(rc, 0, "kill(getpid(), SIGTERM) must succeed");
assert!(
wait_cancelled(&shutdown2).await,
"second shutdown token must be cancelled after SIGTERM"
);
}
#[cfg(unix)]
#[tokio::test]
async fn dropping_guard_before_signal_does_not_cancel_token() {
let shutdown = CancellationToken::new();
drop(install_signal_handlers(shutdown.clone()).expect("install must succeed"));
assert!(
!shutdown.is_cancelled(),
"token must not be cancelled when no signal has been sent"
);
}
#[cfg(unix)]
#[tokio::test]
async fn install_signal_handlers_returns_ok_on_happy_path() {
let shutdown = CancellationToken::new();
let result = install_signal_handlers(shutdown);
assert!(
result.is_ok(),
"install_signal_handlers must return Ok on a normal Unix host: {result:?}"
);
}
#[cfg(not(unix))]
#[tokio::test]
async fn install_signal_handlers_returns_ok_on_non_unix() {
let shutdown = CancellationToken::new();
let result = install_signal_handlers(shutdown);
assert!(
result.is_ok(),
"install_signal_handlers must return Ok on a non-Unix host: {result:?}"
);
}
}