pub(crate) use agent_engine::session::budgets::{
CANCEL_DRAIN_TIMEOUT_SECS, HOOKS_TIMEOUT_SECS, SAVE_TIMEOUT_SECS, TEARDOWN_TIMEOUT_SECS,
};
pub(crate) const FLUSH_TIMEOUT_SECS: u64 =
agent_engine::runtime::telemetry::DEFAULT_SHUTDOWN_FLUSH_TIMEOUT.as_secs();
pub(crate) const SESSION_END_MARGIN_SECS: u64 = 2;
pub(crate) const SESSION_END_TIMEOUT_SECS: u64 = CANCEL_DRAIN_TIMEOUT_SECS
+ SAVE_TIMEOUT_SECS
+ SAVE_TIMEOUT_SECS
+ HOOKS_TIMEOUT_SECS
+ FLUSH_TIMEOUT_SECS
+ SESSION_END_MARGIN_SECS;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ShutdownSignal {
Interrupt,
Terminate,
Hangup,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ShutdownAction {
ImmediateExit,
}
pub(crate) fn shutdown_action(_signal: ShutdownSignal) -> ShutdownAction {
ShutdownAction::ImmediateExit
}
pub(crate) fn signal_label(signal: ShutdownSignal) -> &'static str {
match signal {
ShutdownSignal::Interrupt => "interrupt",
ShutdownSignal::Terminate => "terminate",
ShutdownSignal::Hangup => "hangup",
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SignalBackend {
Thread,
Tokio,
}
impl SignalBackend {
pub(crate) fn for_socket(socket: bool) -> Self {
if socket && !std::env::var("SYNAPS_CLIENT_SIGNAL_THREAD").is_ok_and(|v| v == "1") {
Self::Tokio
} else {
Self::Thread
}
}
}
pub(crate) struct SignalHandle {
#[cfg(unix)]
inner: SignalHandleInner,
}
#[cfg(unix)]
enum SignalHandleInner {
Thread(signal_hook::iterator::Handle),
Tokio(tokio::task::JoinHandle<()>),
}
impl SignalHandle {
pub(crate) fn close(self) {
#[cfg(unix)]
match self.inner {
SignalHandleInner::Thread(h) => h.close(),
SignalHandleInner::Tokio(t) => t.abort(),
}
}
}
#[cfg(unix)]
pub(crate) fn spawn_shutdown_signal_task_with(
tx: tokio::sync::mpsc::UnboundedSender<ShutdownSignal>,
backend: SignalBackend,
) -> SignalHandle {
if backend == SignalBackend::Thread {
return spawn_shutdown_signal_task(tx);
}
use tokio::signal::unix::{signal, SignalKind};
let task = tokio::spawn(async move {
let (Ok(mut term), Ok(mut hup), Ok(mut int)) = (
signal(SignalKind::terminate()),
signal(SignalKind::hangup()),
signal(SignalKind::interrupt()),
) else {
tracing::warn!("signals: tokio backend unavailable; no OS signal handling");
return;
};
let shutdown = tokio::select! {
_ = term.recv() => ShutdownSignal::Terminate,
_ = hup.recv() => ShutdownSignal::Hangup,
_ = int.recv() => ShutdownSignal::Interrupt,
};
let _ = tx.send(shutdown);
});
SignalHandle { inner: SignalHandleInner::Tokio(task) }
}
#[cfg(not(unix))]
pub(crate) fn spawn_shutdown_signal_task_with(
tx: tokio::sync::mpsc::UnboundedSender<ShutdownSignal>,
_backend: SignalBackend,
) -> SignalHandle {
spawn_shutdown_signal_task(tx)
}
#[cfg(unix)]
pub(crate) fn spawn_shutdown_signal_task(
tx: tokio::sync::mpsc::UnboundedSender<ShutdownSignal>,
) -> SignalHandle {
use signal_hook::consts::signal::{SIGHUP, SIGINT, SIGTERM};
use signal_hook::iterator::Signals;
let mut signals =
Signals::new([SIGTERM, SIGHUP, SIGINT]).expect("failed to register signal hooks");
let handle = signals.handle();
std::thread::Builder::new()
.name("signal-listener".into())
.spawn(move || {
for sig in signals.forever() {
tracing::debug!(sig, "signal-listener: received signal");
let shutdown = match sig {
SIGINT => ShutdownSignal::Interrupt,
SIGTERM => ShutdownSignal::Terminate,
SIGHUP => ShutdownSignal::Hangup,
_ => continue,
};
let _ = tx.send(shutdown);
break;
}
})
.expect("failed to spawn signal-listener thread");
SignalHandle { inner: SignalHandleInner::Thread(handle) }
}
#[cfg(not(unix))]
pub(crate) fn spawn_shutdown_signal_task(
tx: tokio::sync::mpsc::UnboundedSender<ShutdownSignal>,
) -> SignalHandle {
tokio::spawn(async move {
let _ = tokio::signal::ctrl_c().await;
super::lifecycle::emergency_teardown_terminal();
let _ = tx.send(ShutdownSignal::Interrupt);
});
SignalHandle {}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn labels_are_human_readable() {
assert_eq!(signal_label(ShutdownSignal::Interrupt), "interrupt");
assert_eq!(signal_label(ShutdownSignal::Terminate), "terminate");
assert_eq!(signal_label(ShutdownSignal::Hangup), "hangup");
}
#[test]
fn all_signals_are_immediate_exit() {
assert_eq!(
shutdown_action(ShutdownSignal::Terminate),
ShutdownAction::ImmediateExit
);
assert_eq!(
shutdown_action(ShutdownSignal::Hangup),
ShutdownAction::ImmediateExit
);
assert_eq!(
shutdown_action(ShutdownSignal::Interrupt),
ShutdownAction::ImmediateExit
);
}
#[test]
fn all_signals_have_labels() {
for sig in [
ShutdownSignal::Interrupt,
ShutdownSignal::Terminate,
ShutdownSignal::Hangup,
] {
assert!(!signal_label(sig).is_empty());
}
}
#[cfg(unix)]
#[test]
fn tokio_backend_delivers_sigterm() {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(async {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let handle = spawn_shutdown_signal_task_with(tx, SignalBackend::Tokio);
tokio::task::yield_now().await;
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
signal_hook::low_level::raise(signal_hook::consts::signal::SIGTERM).unwrap();
let got = tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv())
.await
.expect("SIGTERM never delivered");
assert_eq!(got, Some(ShutdownSignal::Terminate));
handle.close();
});
}
#[test]
fn backend_selection() {
assert_eq!(SignalBackend::for_socket(false), SignalBackend::Thread);
if std::env::var("SYNAPS_CLIENT_SIGNAL_THREAD").is_err() {
assert_eq!(SignalBackend::for_socket(true), SignalBackend::Tokio);
}
}
#[test]
fn teardown_budget_is_sum_of_parts() {
assert_eq!(
TEARDOWN_TIMEOUT_SECS,
SAVE_TIMEOUT_SECS + HOOKS_TIMEOUT_SECS
);
}
}
#[cfg(test)]
mod budget_tests {
use super::*;
#[test]
fn session_end_wait_covers_actor_finish_worst_case() {
let actor_worst_case = CANCEL_DRAIN_TIMEOUT_SECS
+ SAVE_TIMEOUT_SECS
+ SAVE_TIMEOUT_SECS
+ HOOKS_TIMEOUT_SECS
+ FLUSH_TIMEOUT_SECS;
assert_eq!(actor_worst_case, 12);
assert!(SESSION_END_TIMEOUT_SECS > actor_worst_case);
assert!(SESSION_END_TIMEOUT_SECS >= actor_worst_case + SESSION_END_MARGIN_SECS);
const { assert!(SESSION_END_TIMEOUT_SECS > TEARDOWN_TIMEOUT_SECS) };
}
}