Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/sigterm-shutdown.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 5 additions & 5 deletions src/helpers/events/subscribe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(());
}
};
Expand Down Expand Up @@ -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;
}
}
Expand Down
8 changes: 4 additions & 4 deletions src/helpers/gmail/watch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(());
}
};
Expand Down Expand Up @@ -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;
}
}
Expand Down
57 changes: 57 additions & 0 deletions src/helpers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading