From 4ec0a7a829e16d81208830150fb2929bcc56738f Mon Sep 17 00:00:00 2001 From: jpoehnelt-bot Date: Wed, 18 Mar 2026 13:00:19 -0600 Subject: [PATCH 1/5] 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. --- .changeset/sigterm-shutdown.md | 8 ++++++++ src/helpers/events/subscribe.rs | 10 +++++----- src/helpers/gmail/watch.rs | 8 ++++---- src/helpers/mod.rs | 23 +++++++++++++++++++++++ 4 files changed, 40 insertions(+), 9 deletions(-) create mode 100644 .changeset/sigterm-shutdown.md 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..c343e87f 100644 --- a/src/helpers/mod.rs +++ b/src/helpers/mod.rs @@ -33,6 +33,29 @@ 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. +pub(crate) async fn shutdown_signal() { + #[cfg(unix)] + { + use tokio::signal::unix::{signal, SignalKind}; + let mut sigterm = + signal(SignalKind::terminate()).expect("failed to register SIGTERM handler"); + tokio::select! { + _ = tokio::signal::ctrl_c() => {} + _ = sigterm.recv() => {} + } + } + #[cfg(not(unix))] + { + tokio::signal::ctrl_c().await.ok(); + } +} + /// A trait for service-specific CLI helpers that inject custom commands. pub trait Helper: Send + Sync { /// Injects subcommands into the service command. From c64a3f627ea8261751e4f1a2aaa1cbbc2e67a40c Mon Sep 17 00:00:00 2001 From: jpoehnelt-bot Date: Wed, 18 Mar 2026 13:09:37 -0600 Subject: [PATCH 2/5] 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. --- src/helpers/mod.rs | 47 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 33 insertions(+), 14 deletions(-) diff --git a/src/helpers/mod.rs b/src/helpers/mod.rs index c343e87f..eab5556a 100644 --- a/src/helpers/mod.rs +++ b/src/helpers/mod.rs @@ -39,21 +39,40 @@ pub(crate) const PUBSUB_API_BASE: &str = "https://pubsub.googleapis.com/v1"; /// 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() { - #[cfg(unix)] - { - use tokio::signal::unix::{signal, SignalKind}; - let mut sigterm = - signal(SignalKind::terminate()).expect("failed to register SIGTERM handler"); - tokio::select! { - _ = tokio::signal::ctrl_c() => {} - _ = sigterm.recv() => {} - } - } - #[cfg(not(unix))] - { - tokio::signal::ctrl_c().await.ok(); - } + 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}; + let mut sigterm = + signal(SignalKind::terminate()).expect("failed to register SIGTERM handler"); + tokio::select! { + _ = tokio::signal::ctrl_c() => {} + _ = sigterm.recv() => {} + } + } + #[cfg(not(unix))] + { + tokio::signal::ctrl_c().await.ok(); + } + n2.notify_waiters(); + }); + n + }); + + notify.notified().await; } /// A trait for service-specific CLI helpers that inject custom commands. From 24e548dac6885e3a3fc5206047bf3bcd4846e5e4 Mon Sep 17 00:00:00 2001 From: jpoehnelt-bot Date: Wed, 18 Mar 2026 13:16:51 -0600 Subject: [PATCH 3/5] 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. --- src/helpers/mod.rs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/helpers/mod.rs b/src/helpers/mod.rs index eab5556a..57c4de99 100644 --- a/src/helpers/mod.rs +++ b/src/helpers/mod.rs @@ -56,11 +56,20 @@ pub(crate) async fn shutdown_signal() { #[cfg(unix)] { use tokio::signal::unix::{signal, SignalKind}; - let mut sigterm = - signal(SignalKind::terminate()).expect("failed to register SIGTERM handler"); - tokio::select! { - _ = tokio::signal::ctrl_c() => {} - _ = sigterm.recv() => {} + match signal(SignalKind::terminate()) { + Ok(mut sigterm) => { + tokio::select! { + _ = tokio::signal::ctrl_c() => {} + _ = sigterm.recv() => {} + } + } + Err(e) => { + eprintln!( + "warning: could not register SIGTERM handler: {e}. \ + Listening for Ctrl+C only." + ); + tokio::signal::ctrl_c().await.ok(); + } } } #[cfg(not(unix))] From a78fc94f9ca6a745afa9f6226c88ed87987ecd5c Mon Sep 17 00:00:00 2001 From: jpoehnelt-bot Date: Wed, 18 Mar 2026 13:24:03 -0600 Subject: [PATCH 4/5] 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. --- src/helpers/mod.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/helpers/mod.rs b/src/helpers/mod.rs index 57c4de99..cb86930b 100644 --- a/src/helpers/mod.rs +++ b/src/helpers/mod.rs @@ -59,8 +59,8 @@ pub(crate) async fn shutdown_signal() { match signal(SignalKind::terminate()) { Ok(mut sigterm) => { tokio::select! { - _ = tokio::signal::ctrl_c() => {} - _ = sigterm.recv() => {} + Ok(_) = tokio::signal::ctrl_c() => {} + Some(_) = sigterm.recv() => {} } } Err(e) => { @@ -68,13 +68,17 @@ pub(crate) async fn shutdown_signal() { "warning: could not register SIGTERM handler: {e}. \ Listening for Ctrl+C only." ); - tokio::signal::ctrl_c().await.ok(); + tokio::signal::ctrl_c() + .await + .expect("failed to listen for SIGINT"); } } } #[cfg(not(unix))] { - tokio::signal::ctrl_c().await.ok(); + tokio::signal::ctrl_c() + .await + .expect("failed to listen for SIGINT"); } n2.notify_waiters(); }); From 74e7bc5b4403b8ddec9bf9bbd9a534301b6c79e7 Mon Sep 17 00:00:00 2001 From: jpoehnelt-bot Date: Wed, 18 Mar 2026 13:30:22 -0600 Subject: [PATCH 5/5] 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. --- src/helpers/mod.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/helpers/mod.rs b/src/helpers/mod.rs index cb86930b..48ae881b 100644 --- a/src/helpers/mod.rs +++ b/src/helpers/mod.rs @@ -59,7 +59,9 @@ pub(crate) async fn shutdown_signal() { match signal(SignalKind::terminate()) { Ok(mut sigterm) => { tokio::select! { - Ok(_) = tokio::signal::ctrl_c() => {} + res = tokio::signal::ctrl_c() => { + res.expect("failed to listen for SIGINT"); + } Some(_) = sigterm.recv() => {} } }