feat: handle SIGTERM in +watch and +subscribe for clean shutdown (#550)

* feat: handle SIGTERM in +watch and +subscribe for clean shutdown

Add shared shutdown_signal() helper that merges SIGINT and SIGTERM
into a single future. Replace tokio::signal::ctrl_c() in both watch
and subscribe pull loops so they exit cleanly under Kubernetes,
Docker, and systemd.

On non-Unix platforms, only SIGINT (Ctrl+C) is handled.

* fix: register SIGTERM handler once via persistent background task

Use OnceLock + tokio::sync::Notify so the signal handler stays active
for the process lifetime. Eliminates the race window between loop
iterations where a SIGTERM would bypass the handler.

* fix: graceful fallback when SIGTERM registration fails

Replace expect() with match: if signal(SIGTERM) fails, log a warning
and fall back to SIGINT-only. Prevents silent task death that would
hang all shutdown_signal() callers indefinitely.

* fix: prevent spurious shutdown from ignored ctrl_c errors

Use Ok(_) pattern matching in select! branches and expect() for
standalone ctrl_c().await calls. Previously .ok() silently swallowed
errors, causing notify_waiters() to fire immediately.

* fix: handle ctrl_c error in select! to avoid losing SIGINT branch

Bind the full Result from ctrl_c() and expect() on it instead of
pattern matching Ok(_), which silently dropped the branch on Err.

---------

Co-authored-by: jpoehnelt-bot <jpoehnelt-bot@users.noreply.github.com>
This commit is contained in:
Justin Poehnelt
2026-03-18 14:42:11 -06:00
committed by GitHub
parent 23ffc33cf2
commit a87037b337
4 changed files with 74 additions and 9 deletions
+8
View File
@@ -0,0 +1,8 @@
---
"@googleworkspace/cli": patch
---
Handle SIGTERM in `gws gmail +watch` and `gws events +subscribe` for clean container shutdown.
Long-running pull loops now exit gracefully on SIGTERM (in addition to Ctrl+C),
enabling clean shutdown under Kubernetes, Docker, and systemd.
+5 -5
View File
@@ -345,8 +345,8 @@ async fn pull_loop(
Err(e) => return Err(anyhow::anyhow!("Pub/Sub pull failed: {e}").into()),
}
}
_ = tokio::signal::ctrl_c() => {
eprintln!("\nReceived interrupt, stopping...");
_ = super::super::shutdown_signal() => {
eprintln!("\nReceived shutdown signal, stopping...");
return Ok(());
}
};
@@ -411,11 +411,11 @@ async fn pull_loop(
break;
}
// Check for SIGINT between polls
// Check for SIGINT/SIGTERM between polls
tokio::select! {
_ = tokio::time::sleep(std::time::Duration::from_secs(config.poll_interval)) => {},
_ = tokio::signal::ctrl_c() => {
eprintln!("\nReceived interrupt, stopping...");
_ = super::super::shutdown_signal() => {
eprintln!("\nReceived shutdown signal, stopping...");
break;
}
}
+4 -4
View File
@@ -288,8 +288,8 @@ async fn watch_pull_loop(
Err(e) => return Err(GwsError::Other(anyhow::anyhow!("Pub/Sub pull failed: {e}"))),
}
}
_ = tokio::signal::ctrl_c() => {
eprintln!("\nReceived interrupt, stopping...");
_ = super::super::shutdown_signal() => {
eprintln!("\nReceived shutdown signal, stopping...");
return Ok(());
}
};
@@ -348,8 +348,8 @@ async fn watch_pull_loop(
tokio::select! {
_ = tokio::time::sleep(std::time::Duration::from_secs(config.poll_interval)) => {},
_ = tokio::signal::ctrl_c() => {
eprintln!("\nReceived interrupt, stopping...");
_ = super::super::shutdown_signal() => {
eprintln!("\nReceived shutdown signal, stopping...");
break;
}
}
+57
View File
@@ -33,6 +33,63 @@ pub mod workflows;
/// is defined in a single place.
pub(crate) const PUBSUB_API_BASE: &str = "https://pubsub.googleapis.com/v1";
/// Returns a future that completes when a shutdown signal is received.
///
/// On Unix this listens for both SIGINT (Ctrl+C) and SIGTERM; on other
/// platforms only SIGINT is handled. Used by long-running pull loops
/// (`gmail::watch`, `events::subscribe`) to exit cleanly under container
/// orchestrators (Kubernetes, Docker, systemd) that send SIGTERM.
///
/// The signal handler is registered once in a background task on first call
/// so it remains active for the lifetime of the process — no gap between
/// loop iterations.
pub(crate) async fn shutdown_signal() {
use std::sync::OnceLock;
use tokio::sync::Notify;
static NOTIFY: OnceLock<std::sync::Arc<Notify>> = OnceLock::new();
let notify = NOTIFY.get_or_init(|| {
let n = std::sync::Arc::new(Notify::new());
let n2 = n.clone();
tokio::spawn(async move {
#[cfg(unix)]
{
use tokio::signal::unix::{signal, SignalKind};
match signal(SignalKind::terminate()) {
Ok(mut sigterm) => {
tokio::select! {
res = tokio::signal::ctrl_c() => {
res.expect("failed to listen for SIGINT");
}
Some(_) = sigterm.recv() => {}
}
}
Err(e) => {
eprintln!(
"warning: could not register SIGTERM handler: {e}. \
Listening for Ctrl+C only."
);
tokio::signal::ctrl_c()
.await
.expect("failed to listen for SIGINT");
}
}
}
#[cfg(not(unix))]
{
tokio::signal::ctrl_c()
.await
.expect("failed to listen for SIGINT");
}
n2.notify_waiters();
});
n
});
notify.notified().await;
}
/// A trait for service-specific CLI helpers that inject custom commands.
pub trait Helper: Send + Sync {
/// Injects subcommands into the service command.