diff --git a/.changeset/sigterm-shutdown.md b/.changeset/sigterm-shutdown.md new file mode 100644 index 00000000..8fa5ed54 --- /dev/null +++ b/.changeset/sigterm-shutdown.md @@ -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. diff --git a/src/helpers/events/subscribe.rs b/src/helpers/events/subscribe.rs index 00474ac7..fea14e94 100644 --- a/src/helpers/events/subscribe.rs +++ b/src/helpers/events/subscribe.rs @@ -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; } } diff --git a/src/helpers/gmail/watch.rs b/src/helpers/gmail/watch.rs index 43707ee6..027446fd 100644 --- a/src/helpers/gmail/watch.rs +++ b/src/helpers/gmail/watch.rs @@ -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; } } diff --git a/src/helpers/mod.rs b/src/helpers/mod.rs index 72d31272..48ae881b 100644 --- a/src/helpers/mod.rs +++ b/src/helpers/mod.rs @@ -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> = 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.