From 9592813216d5a1ee8a1bb05b430cb5ffa7186cd8 Mon Sep 17 00:00:00 2001 From: Romamo Date: Wed, 18 Mar 2026 19:10:50 +0200 Subject: [PATCH 1/7] feat: handle SIGTERM in +watch and +subscribe for clean shutdown Long-running pull loops in gmail +watch and events +subscribe now listen for SIGTERM alongside Ctrl+C. On receiving SIGTERM the loop acknowledges any in-flight messages and exits cleanly, enabling graceful shutdown in containers and process supervisors (Kubernetes, Cloud Run, systemd). Uses cfg'd let bindings to hoist the SIGTERM future outside tokio::select! branches, which do not support #[cfg] attributes. --- .changeset/sigterm-graceful-shutdown.md | 8 ++++++++ src/helpers/events/subscribe.rs | 27 ++++++++++++++++++++++++- src/helpers/gmail/watch.rs | 24 ++++++++++++++++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 .changeset/sigterm-graceful-shutdown.md diff --git a/.changeset/sigterm-graceful-shutdown.md b/.changeset/sigterm-graceful-shutdown.md new file mode 100644 index 00000000..e98bce63 --- /dev/null +++ b/.changeset/sigterm-graceful-shutdown.md @@ -0,0 +1,8 @@ +--- +"googleworkspace-cli": patch +--- + +Handle SIGTERM in `gws gmail +watch` and `gws events +subscribe` for clean shutdown. + +Long-running pull loops now exit gracefully on SIGTERM (in addition to Ctrl+C), +enabling clean shutdown in containers and process supervisors. diff --git a/src/helpers/events/subscribe.rs b/src/helpers/events/subscribe.rs index 00474ac7..5d989296 100644 --- a/src/helpers/events/subscribe.rs +++ b/src/helpers/events/subscribe.rs @@ -320,6 +320,11 @@ async fn pull_loop( pubsub_api_base: &str, ) -> Result<(), GwsError> { let mut file_counter: u64 = 0; + + #[cfg(unix)] + let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .context("failed to register SIGTERM handler")?; + loop { let token = token_provider .access_token() @@ -337,6 +342,13 @@ async fn pull_loop( .timeout(std::time::Duration::from_secs(config.poll_interval.max(10))) .send(); + // Hoist SIGTERM future outside select! — #[cfg] is not supported inside + // tokio::select! branches; a cfg'd let binding is the workaround. + #[cfg(unix)] + let sigterm_recv = sigterm.recv(); + #[cfg(not(unix))] + let sigterm_recv = std::future::pending::>(); + let resp = tokio::select! { result = pull_future => { match result { @@ -349,6 +361,10 @@ async fn pull_loop( eprintln!("\nReceived interrupt, stopping..."); return Ok(()); } + _ = sigterm_recv => { + eprintln!("\nReceived SIGTERM, stopping..."); + return Ok(()); + } }; if !resp.status().is_success() { @@ -411,13 +427,22 @@ async fn pull_loop( break; } - // Check for SIGINT between polls + // Check for SIGINT/SIGTERM between polls + #[cfg(unix)] + let sigterm_sleep = sigterm.recv(); + #[cfg(not(unix))] + let sigterm_sleep = std::future::pending::>(); + tokio::select! { _ = tokio::time::sleep(std::time::Duration::from_secs(config.poll_interval)) => {}, _ = tokio::signal::ctrl_c() => { eprintln!("\nReceived interrupt, stopping..."); break; } + _ = sigterm_sleep => { + eprintln!("\nReceived SIGTERM, stopping..."); + break; + } } } diff --git a/src/helpers/gmail/watch.rs b/src/helpers/gmail/watch.rs index 43707ee6..8a3df69b 100644 --- a/src/helpers/gmail/watch.rs +++ b/src/helpers/gmail/watch.rs @@ -264,6 +264,10 @@ async fn watch_pull_loop( last_history_id: &mut u64, config: WatchConfig, ) -> Result<(), GwsError> { + #[cfg(unix)] + let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .context("failed to register SIGTERM handler")?; + loop { let pubsub_token = runtime .pubsub_token_provider @@ -280,6 +284,13 @@ async fn watch_pull_loop( .timeout(std::time::Duration::from_secs(config.poll_interval.max(10))) .send(); + // Hoist SIGTERM future outside select! — #[cfg] is not supported inside + // tokio::select! branches; a cfg'd let binding is the workaround. + #[cfg(unix)] + let sigterm_recv = sigterm.recv(); + #[cfg(not(unix))] + let sigterm_recv = std::future::pending::>(); + let resp = tokio::select! { result = pull_future => { match result { @@ -292,6 +303,10 @@ async fn watch_pull_loop( eprintln!("\nReceived interrupt, stopping..."); return Ok(()); } + _ = sigterm_recv => { + eprintln!("\nReceived SIGTERM, stopping..."); + return Ok(()); + } }; if !resp.status().is_success() { @@ -346,12 +361,21 @@ async fn watch_pull_loop( break; } + #[cfg(unix)] + let sigterm_sleep = sigterm.recv(); + #[cfg(not(unix))] + let sigterm_sleep = std::future::pending::>(); + tokio::select! { _ = tokio::time::sleep(std::time::Duration::from_secs(config.poll_interval)) => {}, _ = tokio::signal::ctrl_c() => { eprintln!("\nReceived interrupt, stopping..."); break; } + _ = sigterm_sleep => { + eprintln!("\nReceived SIGTERM, stopping..."); + break; + } } } From 01a7b3a6736ea27055119bf67c66bdb1dd834dca Mon Sep 17 00:00:00 2001 From: Romamo Date: Wed, 18 Mar 2026 19:21:17 +0200 Subject: [PATCH 2/7] test: verify pull loops exit cleanly on SIGTERM Spawn each loop against a mock server returning empty pull responses, send SIGTERM from a side task after the first pull completes, and assert the loop returns Ok(()) within a 2-second timeout. Uses libc::raise(SIGTERM) (new dev-dep) + serial_test::serial to prevent cross-test signal interference. --- Cargo.lock | 1 + Cargo.toml | 1 + src/helpers/events/subscribe.rs | 70 ++++++++++++++++++++++++++ src/helpers/gmail/watch.rs | 88 +++++++++++++++++++++++++++++++++ 4 files changed, 160 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 10e12097..7122d964 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -914,6 +914,7 @@ dependencies = [ "hostname", "iana-time-zone", "keyring", + "libc", "mail-builder", "mime_guess2", "percent-encoding", diff --git a/Cargo.toml b/Cargo.toml index 1f73b8f7..fbd98448 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -84,4 +84,5 @@ inherits = "release" lto = "thin" [dev-dependencies] +libc = "0.2" serial_test = "3.4.0" diff --git a/src/helpers/events/subscribe.rs b/src/helpers/events/subscribe.rs index 5d989296..d683b86c 100644 --- a/src/helpers/events/subscribe.rs +++ b/src/helpers/events/subscribe.rs @@ -904,4 +904,74 @@ mod tests { ); assert_eq!(requests[1].1, "authorization: Bearer pubsub-token"); } + + /// Spawns a mock server that returns empty pull responses indefinitely. + async fn spawn_empty_pull_server() -> (String, tokio::task::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let handle = tokio::spawn(async move { + loop { + let Ok((mut stream, _)) = listener.accept().await else { + break; + }; + tokio::spawn(async move { + let mut buf = [0u8; 4096]; + let _ = stream.read(&mut buf).await; + let body = r#"{"receivedMessages":[]}"#; + let resp = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: application/json\r\n\r\n{}", + body.len(), + body + ); + let _ = stream.write_all(resp.as_bytes()).await; + }); + } + }); + (format!("http://{addr}"), handle) + } + + #[cfg(unix)] + #[tokio::test] + #[serial_test::serial] + async fn test_pull_loop_exits_on_sigterm() { + use std::time::Duration; + + let client = reqwest::Client::new(); + let token_provider = FakeTokenProvider::new(["tok"; 16]); + let (base, server) = spawn_empty_pull_server().await; + let config = SubscribeConfigBuilder::default() + .subscription(Some(SubscriptionName( + "projects/test/subscriptions/demo".to_string(), + ))) + .max_messages(1_u32) + .poll_interval(60_u64) // Long sleep so SIGTERM fires during the sleep select! + .once(false) + .build() + .unwrap(); + + // Send SIGTERM after the first pull completes and the loop enters the sleep select! + tokio::spawn(async { + tokio::time::sleep(Duration::from_millis(200)).await; + unsafe { libc::raise(libc::SIGTERM) }; + }); + + let result = tokio::time::timeout( + Duration::from_secs(2), + pull_loop( + &client, + &token_provider, + "projects/test/subscriptions/demo", + config, + &base, + ), + ) + .await; + + server.abort(); + assert!( + result.is_ok(), + "loop should exit on SIGTERM within 2 seconds" + ); + assert!(result.unwrap().is_ok(), "loop should return Ok(())"); + } } diff --git a/src/helpers/gmail/watch.rs b/src/helpers/gmail/watch.rs index 8a3df69b..ad4c2dae 100644 --- a/src/helpers/gmail/watch.rs +++ b/src/helpers/gmail/watch.rs @@ -1014,4 +1014,92 @@ mod tests { assert_eq!(requests[3].1, "authorization: Bearer pubsub-token"); assert_eq!(last_history_id, 2); } + + /// Spawns a mock server that returns empty pull responses indefinitely. + /// Used by the SIGTERM test so the loop blocks in the sleep select!. + async fn spawn_empty_pull_server() -> (String, tokio::task::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let handle = tokio::spawn(async move { + loop { + let Ok((mut stream, _)) = listener.accept().await else { + break; + }; + tokio::spawn(async move { + let mut buf = [0u8; 4096]; + let _ = stream.read(&mut buf).await; + let body = r#"{"receivedMessages":[]}"#; + let resp = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: application/json\r\n\r\n{}", + body.len(), + body + ); + let _ = stream.write_all(resp.as_bytes()).await; + }); + } + }); + (format!("http://{addr}"), handle) + } + + #[cfg(unix)] + #[tokio::test] + #[serial_test::serial] + async fn test_watch_pull_loop_exits_on_sigterm() { + use std::time::Duration; + + let client = reqwest::Client::new(); + // Supply enough tokens for several pull iterations + let pubsub_provider = FakeTokenProvider::new(["tok"; 16]); + let gmail_provider = FakeTokenProvider::new(["tok"; 16]); + let (base, server) = spawn_empty_pull_server().await; + let sanitize_config = crate::helpers::modelarmor::SanitizeConfig { + template: None, + mode: crate::helpers::modelarmor::SanitizeMode::Warn, + }; + let runtime = WatchRuntime { + client: &client, + pubsub_token_provider: &pubsub_provider, + gmail_token_provider: &gmail_provider, + sanitize_config: &sanitize_config, + pubsub_api_base: &base, + gmail_api_base: &base, + }; + let mut last_history_id = 0u64; + let config = WatchConfig { + project: None, + subscription: None, + topic: None, + label_ids: None, + max_messages: 1, + poll_interval: 60, // Long sleep so SIGTERM fires during the sleep select! + format: "full".to_string(), + once: false, + cleanup: false, + output_dir: None, + }; + + // Send SIGTERM after the first pull completes and the loop enters the sleep select! + tokio::spawn(async { + tokio::time::sleep(Duration::from_millis(200)).await; + unsafe { libc::raise(libc::SIGTERM) }; + }); + + let result = tokio::time::timeout( + Duration::from_secs(2), + watch_pull_loop( + &runtime, + "projects/test/subscriptions/demo", + &mut last_history_id, + config, + ), + ) + .await; + + server.abort(); + assert!( + result.is_ok(), + "loop should exit on SIGTERM within 2 seconds" + ); + assert!(result.unwrap().is_ok(), "loop should return Ok(())"); + } } From 982d17684bba54fe4d4477c6bec1243b8daefc0e Mon Sep 17 00:00:00 2001 From: Romamo Date: Wed, 18 Mar 2026 19:46:19 +0200 Subject: [PATCH 3/7] refactor: hoist SIGINT stream above loop, consistent with SIGTERM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create a persistent SignalKind::interrupt() stream once before the loop instead of calling tokio::signal::ctrl_c() on every iteration. The SIGINT future is now prepared per-iteration with .recv() — the same pattern used for SIGTERM — keeping both signal handlers consistent and avoiding repeated handler re-registration. --- src/helpers/events/subscribe.rs | 19 +++++++++++++++---- src/helpers/gmail/watch.rs | 19 +++++++++++++++---- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/src/helpers/events/subscribe.rs b/src/helpers/events/subscribe.rs index d683b86c..ae4f27c5 100644 --- a/src/helpers/events/subscribe.rs +++ b/src/helpers/events/subscribe.rs @@ -324,6 +324,9 @@ async fn pull_loop( #[cfg(unix)] let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) .context("failed to register SIGTERM handler")?; + #[cfg(unix)] + let mut sigint = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt()) + .context("failed to register SIGINT handler")?; loop { let token = token_provider @@ -342,8 +345,12 @@ async fn pull_loop( .timeout(std::time::Duration::from_secs(config.poll_interval.max(10))) .send(); - // Hoist SIGTERM future outside select! — #[cfg] is not supported inside - // tokio::select! branches; a cfg'd let binding is the workaround. + // Hoist signal futures outside select! — #[cfg] is not supported inside + // tokio::select! branches; cfg'd let bindings are the workaround. + #[cfg(unix)] + let sigint_recv = sigint.recv(); + #[cfg(not(unix))] + let sigint_recv = tokio::signal::ctrl_c(); #[cfg(unix)] let sigterm_recv = sigterm.recv(); #[cfg(not(unix))] @@ -357,7 +364,7 @@ async fn pull_loop( Err(e) => return Err(anyhow::anyhow!("Pub/Sub pull failed: {e}").into()), } } - _ = tokio::signal::ctrl_c() => { + _ = sigint_recv => { eprintln!("\nReceived interrupt, stopping..."); return Ok(()); } @@ -429,13 +436,17 @@ async fn pull_loop( // Check for SIGINT/SIGTERM between polls #[cfg(unix)] + let sigint_sleep = sigint.recv(); + #[cfg(not(unix))] + let sigint_sleep = tokio::signal::ctrl_c(); + #[cfg(unix)] let sigterm_sleep = sigterm.recv(); #[cfg(not(unix))] let sigterm_sleep = std::future::pending::>(); tokio::select! { _ = tokio::time::sleep(std::time::Duration::from_secs(config.poll_interval)) => {}, - _ = tokio::signal::ctrl_c() => { + _ = sigint_sleep => { eprintln!("\nReceived interrupt, stopping..."); break; } diff --git a/src/helpers/gmail/watch.rs b/src/helpers/gmail/watch.rs index ad4c2dae..081d912b 100644 --- a/src/helpers/gmail/watch.rs +++ b/src/helpers/gmail/watch.rs @@ -267,6 +267,9 @@ async fn watch_pull_loop( #[cfg(unix)] let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) .context("failed to register SIGTERM handler")?; + #[cfg(unix)] + let mut sigint = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt()) + .context("failed to register SIGINT handler")?; loop { let pubsub_token = runtime @@ -284,8 +287,12 @@ async fn watch_pull_loop( .timeout(std::time::Duration::from_secs(config.poll_interval.max(10))) .send(); - // Hoist SIGTERM future outside select! — #[cfg] is not supported inside - // tokio::select! branches; a cfg'd let binding is the workaround. + // Hoist signal futures outside select! — #[cfg] is not supported inside + // tokio::select! branches; cfg'd let bindings are the workaround. + #[cfg(unix)] + let sigint_recv = sigint.recv(); + #[cfg(not(unix))] + let sigint_recv = tokio::signal::ctrl_c(); #[cfg(unix)] let sigterm_recv = sigterm.recv(); #[cfg(not(unix))] @@ -299,7 +306,7 @@ async fn watch_pull_loop( Err(e) => return Err(GwsError::Other(anyhow::anyhow!("Pub/Sub pull failed: {e}"))), } } - _ = tokio::signal::ctrl_c() => { + _ = sigint_recv => { eprintln!("\nReceived interrupt, stopping..."); return Ok(()); } @@ -361,6 +368,10 @@ async fn watch_pull_loop( break; } + #[cfg(unix)] + let sigint_sleep = sigint.recv(); + #[cfg(not(unix))] + let sigint_sleep = tokio::signal::ctrl_c(); #[cfg(unix)] let sigterm_sleep = sigterm.recv(); #[cfg(not(unix))] @@ -368,7 +379,7 @@ async fn watch_pull_loop( tokio::select! { _ = tokio::time::sleep(std::time::Duration::from_secs(config.poll_interval)) => {}, - _ = tokio::signal::ctrl_c() => { + _ = sigint_sleep => { eprintln!("\nReceived interrupt, stopping..."); break; } From 73d07314074642d402d6e586b6556b3f70d47605 Mon Sep 17 00:00:00 2001 From: Romamo Date: Wed, 18 Mar 2026 19:54:28 +0200 Subject: [PATCH 4/7] test: fix assertion pattern in SIGTERM tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use expect() to unwrap the timeout result with a message, then assert on the inner result — avoids the two-step is_ok()/unwrap() pattern flagged in review 3969752091. --- src/helpers/events/subscribe.rs | 7 ++----- src/helpers/gmail/watch.rs | 7 ++----- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/src/helpers/events/subscribe.rs b/src/helpers/events/subscribe.rs index ae4f27c5..45447c71 100644 --- a/src/helpers/events/subscribe.rs +++ b/src/helpers/events/subscribe.rs @@ -979,10 +979,7 @@ mod tests { .await; server.abort(); - assert!( - result.is_ok(), - "loop should exit on SIGTERM within 2 seconds" - ); - assert!(result.unwrap().is_ok(), "loop should return Ok(())"); + let inner = result.expect("loop should exit on SIGTERM within 2 seconds"); + assert!(inner.is_ok(), "loop should return Ok(())"); } } diff --git a/src/helpers/gmail/watch.rs b/src/helpers/gmail/watch.rs index 081d912b..30ff2ba6 100644 --- a/src/helpers/gmail/watch.rs +++ b/src/helpers/gmail/watch.rs @@ -1107,10 +1107,7 @@ mod tests { .await; server.abort(); - assert!( - result.is_ok(), - "loop should exit on SIGTERM within 2 seconds" - ); - assert!(result.unwrap().is_ok(), "loop should return Ok(())"); + let inner = result.expect("loop should exit on SIGTERM within 2 seconds"); + assert!(inner.is_ok(), "loop should return Ok(())"); } } From cd81f3ea1c393abcd0a023157bad373a6acf6257 Mon Sep 17 00:00:00 2001 From: Romamo Date: Wed, 18 Mar 2026 21:06:07 +0200 Subject: [PATCH 5/7] refactor: deduplicate signal hoisting via macro, share empty-pull server Extract `hoist_signals!` macro in both `watch.rs` and `subscribe.rs` to replace the repeated 4-line `#[cfg]` let-binding block that appeared twice per loop function. Output variable names are passed as macro arguments so they remain visible after the macro invocation (Rust macro hygiene). Move the identical `spawn_empty_pull_server` test helper from both files into `src/helpers/mod.rs` under `#[cfg(test)]` as a shared utility, and update both SIGTERM tests to reference `crate::helpers::test_helpers::spawn_empty_pull_server`. --- src/helpers/events/subscribe.rs | 70 +++++++++++--------------------- src/helpers/gmail/watch.rs | 71 +++++++++++---------------------- src/helpers/mod.rs | 33 +++++++++++++++ 3 files changed, 81 insertions(+), 93 deletions(-) diff --git a/src/helpers/events/subscribe.rs b/src/helpers/events/subscribe.rs index 45447c71..e2084252 100644 --- a/src/helpers/events/subscribe.rs +++ b/src/helpers/events/subscribe.rs @@ -4,6 +4,23 @@ use crate::helpers::PUBSUB_API_BASE; use crate::output::sanitize_for_terminal; use std::path::PathBuf; +/// Hoists cfg-gated signal futures into caller-named bindings for use inside +/// `tokio::select!`, which does not support `#[cfg]` inside its branches. +/// Output variable names are passed as arguments so they are visible after the +/// macro invocation (Rust macro hygiene: only call-site identifiers leak out). +macro_rules! hoist_signals { + ($sigint:ident, $sigterm:ident, $sigint_fut:ident, $sigterm_fut:ident) => { + #[cfg(unix)] + let $sigint_fut = $sigint.recv(); + #[cfg(not(unix))] + let $sigint_fut = tokio::signal::ctrl_c(); + #[cfg(unix)] + let $sigterm_fut = $sigterm.recv(); + #[cfg(not(unix))] + let $sigterm_fut = std::future::pending::>(); + }; +} + #[derive(Debug, Clone, Default, Builder)] #[builder(setter(into))] pub struct SubscribeConfig { @@ -347,14 +364,7 @@ async fn pull_loop( // Hoist signal futures outside select! — #[cfg] is not supported inside // tokio::select! branches; cfg'd let bindings are the workaround. - #[cfg(unix)] - let sigint_recv = sigint.recv(); - #[cfg(not(unix))] - let sigint_recv = tokio::signal::ctrl_c(); - #[cfg(unix)] - let sigterm_recv = sigterm.recv(); - #[cfg(not(unix))] - let sigterm_recv = std::future::pending::>(); + hoist_signals!(sigint, sigterm, sigint_fut, sigterm_fut); let resp = tokio::select! { result = pull_future => { @@ -364,11 +374,11 @@ async fn pull_loop( Err(e) => return Err(anyhow::anyhow!("Pub/Sub pull failed: {e}").into()), } } - _ = sigint_recv => { + _ = sigint_fut => { eprintln!("\nReceived interrupt, stopping..."); return Ok(()); } - _ = sigterm_recv => { + _ = sigterm_fut => { eprintln!("\nReceived SIGTERM, stopping..."); return Ok(()); } @@ -435,22 +445,15 @@ async fn pull_loop( } // Check for SIGINT/SIGTERM between polls - #[cfg(unix)] - let sigint_sleep = sigint.recv(); - #[cfg(not(unix))] - let sigint_sleep = tokio::signal::ctrl_c(); - #[cfg(unix)] - let sigterm_sleep = sigterm.recv(); - #[cfg(not(unix))] - let sigterm_sleep = std::future::pending::>(); + hoist_signals!(sigint, sigterm, sigint_fut, sigterm_fut); tokio::select! { _ = tokio::time::sleep(std::time::Duration::from_secs(config.poll_interval)) => {}, - _ = sigint_sleep => { + _ = sigint_fut => { eprintln!("\nReceived interrupt, stopping..."); break; } - _ = sigterm_sleep => { + _ = sigterm_fut => { eprintln!("\nReceived SIGTERM, stopping..."); break; } @@ -916,31 +919,6 @@ mod tests { assert_eq!(requests[1].1, "authorization: Bearer pubsub-token"); } - /// Spawns a mock server that returns empty pull responses indefinitely. - async fn spawn_empty_pull_server() -> (String, tokio::task::JoinHandle<()>) { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let handle = tokio::spawn(async move { - loop { - let Ok((mut stream, _)) = listener.accept().await else { - break; - }; - tokio::spawn(async move { - let mut buf = [0u8; 4096]; - let _ = stream.read(&mut buf).await; - let body = r#"{"receivedMessages":[]}"#; - let resp = format!( - "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: application/json\r\n\r\n{}", - body.len(), - body - ); - let _ = stream.write_all(resp.as_bytes()).await; - }); - } - }); - (format!("http://{addr}"), handle) - } - #[cfg(unix)] #[tokio::test] #[serial_test::serial] @@ -949,7 +927,7 @@ mod tests { let client = reqwest::Client::new(); let token_provider = FakeTokenProvider::new(["tok"; 16]); - let (base, server) = spawn_empty_pull_server().await; + let (base, server) = crate::helpers::test_helpers::spawn_empty_pull_server().await; let config = SubscribeConfigBuilder::default() .subscription(Some(SubscriptionName( "projects/test/subscriptions/demo".to_string(), diff --git a/src/helpers/gmail/watch.rs b/src/helpers/gmail/watch.rs index 30ff2ba6..20cf956c 100644 --- a/src/helpers/gmail/watch.rs +++ b/src/helpers/gmail/watch.rs @@ -6,6 +6,23 @@ use crate::output::sanitize_for_terminal; const GMAIL_API_BASE: &str = "https://gmail.googleapis.com/gmail/v1"; +/// Hoists cfg-gated signal futures into caller-named bindings for use inside +/// `tokio::select!`, which does not support `#[cfg]` inside its branches. +/// Output variable names are passed as arguments so they are visible after the +/// macro invocation (Rust macro hygiene: only call-site identifiers leak out). +macro_rules! hoist_signals { + ($sigint:ident, $sigterm:ident, $sigint_fut:ident, $sigterm_fut:ident) => { + #[cfg(unix)] + let $sigint_fut = $sigint.recv(); + #[cfg(not(unix))] + let $sigint_fut = tokio::signal::ctrl_c(); + #[cfg(unix)] + let $sigterm_fut = $sigterm.recv(); + #[cfg(not(unix))] + let $sigterm_fut = std::future::pending::>(); + }; +} + /// Handles the `+watch` command — Gmail push notifications via Pub/Sub. pub(super) async fn handle_watch( matches: &ArgMatches, @@ -289,14 +306,7 @@ async fn watch_pull_loop( // Hoist signal futures outside select! — #[cfg] is not supported inside // tokio::select! branches; cfg'd let bindings are the workaround. - #[cfg(unix)] - let sigint_recv = sigint.recv(); - #[cfg(not(unix))] - let sigint_recv = tokio::signal::ctrl_c(); - #[cfg(unix)] - let sigterm_recv = sigterm.recv(); - #[cfg(not(unix))] - let sigterm_recv = std::future::pending::>(); + hoist_signals!(sigint, sigterm, sigint_fut, sigterm_fut); let resp = tokio::select! { result = pull_future => { @@ -306,11 +316,11 @@ async fn watch_pull_loop( Err(e) => return Err(GwsError::Other(anyhow::anyhow!("Pub/Sub pull failed: {e}"))), } } - _ = sigint_recv => { + _ = sigint_fut => { eprintln!("\nReceived interrupt, stopping..."); return Ok(()); } - _ = sigterm_recv => { + _ = sigterm_fut => { eprintln!("\nReceived SIGTERM, stopping..."); return Ok(()); } @@ -368,22 +378,15 @@ async fn watch_pull_loop( break; } - #[cfg(unix)] - let sigint_sleep = sigint.recv(); - #[cfg(not(unix))] - let sigint_sleep = tokio::signal::ctrl_c(); - #[cfg(unix)] - let sigterm_sleep = sigterm.recv(); - #[cfg(not(unix))] - let sigterm_sleep = std::future::pending::>(); + hoist_signals!(sigint, sigterm, sigint_fut, sigterm_fut); tokio::select! { _ = tokio::time::sleep(std::time::Duration::from_secs(config.poll_interval)) => {}, - _ = sigint_sleep => { + _ = sigint_fut => { eprintln!("\nReceived interrupt, stopping..."); break; } - _ = sigterm_sleep => { + _ = sigterm_fut => { eprintln!("\nReceived SIGTERM, stopping..."); break; } @@ -1026,32 +1029,6 @@ mod tests { assert_eq!(last_history_id, 2); } - /// Spawns a mock server that returns empty pull responses indefinitely. - /// Used by the SIGTERM test so the loop blocks in the sleep select!. - async fn spawn_empty_pull_server() -> (String, tokio::task::JoinHandle<()>) { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let handle = tokio::spawn(async move { - loop { - let Ok((mut stream, _)) = listener.accept().await else { - break; - }; - tokio::spawn(async move { - let mut buf = [0u8; 4096]; - let _ = stream.read(&mut buf).await; - let body = r#"{"receivedMessages":[]}"#; - let resp = format!( - "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: application/json\r\n\r\n{}", - body.len(), - body - ); - let _ = stream.write_all(resp.as_bytes()).await; - }); - } - }); - (format!("http://{addr}"), handle) - } - #[cfg(unix)] #[tokio::test] #[serial_test::serial] @@ -1062,7 +1039,7 @@ mod tests { // Supply enough tokens for several pull iterations let pubsub_provider = FakeTokenProvider::new(["tok"; 16]); let gmail_provider = FakeTokenProvider::new(["tok"; 16]); - let (base, server) = spawn_empty_pull_server().await; + let (base, server) = crate::helpers::test_helpers::spawn_empty_pull_server().await; let sanitize_config = crate::helpers::modelarmor::SanitizeConfig { template: None, mode: crate::helpers::modelarmor::SanitizeMode::Warn, diff --git a/src/helpers/mod.rs b/src/helpers/mod.rs index 72d31272..6fc395eb 100644 --- a/src/helpers/mod.rs +++ b/src/helpers/mod.rs @@ -54,6 +54,39 @@ pub trait Helper: Send + Sync { } } +#[cfg(test)] +pub(crate) mod test_helpers { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + /// Spawns a mock Pub/Sub server that returns empty pull responses + /// indefinitely. Shared by the SIGTERM tests in `gmail::watch` and + /// `events::subscribe` so the loop blocks in the sleep `select!`. + pub(crate) async fn spawn_empty_pull_server() -> (String, tokio::task::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let handle = tokio::spawn(async move { + loop { + let Ok((mut stream, _)) = listener.accept().await else { + break; + }; + tokio::spawn(async move { + let mut buf = [0u8; 4096]; + let _ = stream.read(&mut buf).await; + let body = r#"{"receivedMessages":[]}"#; + let resp = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: application/json\r\n\r\n{}", + body.len(), + body + ); + let _ = stream.write_all(resp.as_bytes()).await; + }); + } + }); + (format!("http://{addr}"), handle) + } +} + pub fn get_helper(service: &str) -> Option> { match service { "gmail" => Some(Box::new(gmail::GmailHelper)), From c9ea5a51833288c249bba4ea407014bd763fbb77 Mon Sep 17 00:00:00 2001 From: Romamo Date: Wed, 18 Mar 2026 21:21:28 +0200 Subject: [PATCH 6/7] refactor: move hoist_signals! macro to helpers/mod.rs Define the macro once in the parent module before the `pub mod` declarations so Rust's textual scoping makes it available in all helper submodules (gmail::watch, events::subscribe) without an explicit import. Remove the per-file duplicate definitions. --- src/helpers/events/subscribe.rs | 17 ----------------- src/helpers/gmail/watch.rs | 17 ----------------- src/helpers/mod.rs | 20 ++++++++++++++++++++ 3 files changed, 20 insertions(+), 34 deletions(-) diff --git a/src/helpers/events/subscribe.rs b/src/helpers/events/subscribe.rs index e2084252..84b3e306 100644 --- a/src/helpers/events/subscribe.rs +++ b/src/helpers/events/subscribe.rs @@ -4,23 +4,6 @@ use crate::helpers::PUBSUB_API_BASE; use crate::output::sanitize_for_terminal; use std::path::PathBuf; -/// Hoists cfg-gated signal futures into caller-named bindings for use inside -/// `tokio::select!`, which does not support `#[cfg]` inside its branches. -/// Output variable names are passed as arguments so they are visible after the -/// macro invocation (Rust macro hygiene: only call-site identifiers leak out). -macro_rules! hoist_signals { - ($sigint:ident, $sigterm:ident, $sigint_fut:ident, $sigterm_fut:ident) => { - #[cfg(unix)] - let $sigint_fut = $sigint.recv(); - #[cfg(not(unix))] - let $sigint_fut = tokio::signal::ctrl_c(); - #[cfg(unix)] - let $sigterm_fut = $sigterm.recv(); - #[cfg(not(unix))] - let $sigterm_fut = std::future::pending::>(); - }; -} - #[derive(Debug, Clone, Default, Builder)] #[builder(setter(into))] pub struct SubscribeConfig { diff --git a/src/helpers/gmail/watch.rs b/src/helpers/gmail/watch.rs index 20cf956c..56cc20be 100644 --- a/src/helpers/gmail/watch.rs +++ b/src/helpers/gmail/watch.rs @@ -6,23 +6,6 @@ use crate::output::sanitize_for_terminal; const GMAIL_API_BASE: &str = "https://gmail.googleapis.com/gmail/v1"; -/// Hoists cfg-gated signal futures into caller-named bindings for use inside -/// `tokio::select!`, which does not support `#[cfg]` inside its branches. -/// Output variable names are passed as arguments so they are visible after the -/// macro invocation (Rust macro hygiene: only call-site identifiers leak out). -macro_rules! hoist_signals { - ($sigint:ident, $sigterm:ident, $sigint_fut:ident, $sigterm_fut:ident) => { - #[cfg(unix)] - let $sigint_fut = $sigint.recv(); - #[cfg(not(unix))] - let $sigint_fut = tokio::signal::ctrl_c(); - #[cfg(unix)] - let $sigterm_fut = $sigterm.recv(); - #[cfg(not(unix))] - let $sigterm_fut = std::future::pending::>(); - }; -} - /// Handles the `+watch` command — Gmail push notifications via Pub/Sub. pub(super) async fn handle_watch( matches: &ArgMatches, diff --git a/src/helpers/mod.rs b/src/helpers/mod.rs index 6fc395eb..6eeb76af 100644 --- a/src/helpers/mod.rs +++ b/src/helpers/mod.rs @@ -16,6 +16,26 @@ use crate::error::GwsError; use clap::{ArgMatches, Command}; use std::future::Future; use std::pin::Pin; + +/// Hoists cfg-gated signal futures into caller-named bindings for use inside +/// `tokio::select!`, which does not support `#[cfg]` inside its branches. +/// Output variable names are passed as arguments so they are visible after the +/// macro invocation (Rust macro hygiene: only call-site identifiers leak out). +/// +/// Defined here so all helper submodules can use it without an explicit import. +macro_rules! hoist_signals { + ($sigint:ident, $sigterm:ident, $sigint_fut:ident, $sigterm_fut:ident) => { + #[cfg(unix)] + let $sigint_fut = $sigint.recv(); + #[cfg(not(unix))] + let $sigint_fut = tokio::signal::ctrl_c(); + #[cfg(unix)] + let $sigterm_fut = $sigterm.recv(); + #[cfg(not(unix))] + let $sigterm_fut = std::future::pending::>(); + }; +} + pub mod calendar; pub mod chat; pub mod docs; From dc9b223b1bb807d0c7f323e247bf2236b962da8d Mon Sep 17 00:00:00 2001 From: Romamo Date: Wed, 18 Mar 2026 21:40:07 +0200 Subject: [PATCH 7/7] fix: gate hoist_signals! on unix, add explicit non-unix fallback at call sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The macro referenced `sigint`/`sigterm` identifiers that only exist on Unix, so it would fail to compile on non-Unix targets. Gate the macro definition with `#[cfg(unix)]` (removing the internal cfg branches — the macro is now unix-only by contract) and pair every call site with a `#[cfg(not(unix))]` two-liner that creates the same `sigint_fut`/`sigterm_fut` bindings via `ctrl_c()` and `pending()`. --- src/helpers/events/subscribe.rs | 12 ++++++++++++ src/helpers/gmail/watch.rs | 12 ++++++++++++ src/helpers/mod.rs | 11 ++++------- 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/src/helpers/events/subscribe.rs b/src/helpers/events/subscribe.rs index 84b3e306..3b22eeaf 100644 --- a/src/helpers/events/subscribe.rs +++ b/src/helpers/events/subscribe.rs @@ -347,7 +347,13 @@ async fn pull_loop( // Hoist signal futures outside select! — #[cfg] is not supported inside // tokio::select! branches; cfg'd let bindings are the workaround. + #[cfg(unix)] hoist_signals!(sigint, sigterm, sigint_fut, sigterm_fut); + #[cfg(not(unix))] + let (sigint_fut, sigterm_fut) = ( + tokio::signal::ctrl_c(), + std::future::pending::>(), + ); let resp = tokio::select! { result = pull_future => { @@ -428,7 +434,13 @@ async fn pull_loop( } // Check for SIGINT/SIGTERM between polls + #[cfg(unix)] hoist_signals!(sigint, sigterm, sigint_fut, sigterm_fut); + #[cfg(not(unix))] + let (sigint_fut, sigterm_fut) = ( + tokio::signal::ctrl_c(), + std::future::pending::>(), + ); tokio::select! { _ = tokio::time::sleep(std::time::Duration::from_secs(config.poll_interval)) => {}, diff --git a/src/helpers/gmail/watch.rs b/src/helpers/gmail/watch.rs index 56cc20be..525c1a2e 100644 --- a/src/helpers/gmail/watch.rs +++ b/src/helpers/gmail/watch.rs @@ -289,7 +289,13 @@ async fn watch_pull_loop( // Hoist signal futures outside select! — #[cfg] is not supported inside // tokio::select! branches; cfg'd let bindings are the workaround. + #[cfg(unix)] hoist_signals!(sigint, sigterm, sigint_fut, sigterm_fut); + #[cfg(not(unix))] + let (sigint_fut, sigterm_fut) = ( + tokio::signal::ctrl_c(), + std::future::pending::>(), + ); let resp = tokio::select! { result = pull_future => { @@ -361,7 +367,13 @@ async fn watch_pull_loop( break; } + #[cfg(unix)] hoist_signals!(sigint, sigterm, sigint_fut, sigterm_fut); + #[cfg(not(unix))] + let (sigint_fut, sigterm_fut) = ( + tokio::signal::ctrl_c(), + std::future::pending::>(), + ); tokio::select! { _ = tokio::time::sleep(std::time::Duration::from_secs(config.poll_interval)) => {}, diff --git a/src/helpers/mod.rs b/src/helpers/mod.rs index 6eeb76af..352957ee 100644 --- a/src/helpers/mod.rs +++ b/src/helpers/mod.rs @@ -17,22 +17,19 @@ use clap::{ArgMatches, Command}; use std::future::Future; use std::pin::Pin; -/// Hoists cfg-gated signal futures into caller-named bindings for use inside +/// Hoists Unix signal futures into caller-named bindings for use inside /// `tokio::select!`, which does not support `#[cfg]` inside its branches. /// Output variable names are passed as arguments so they are visible after the /// macro invocation (Rust macro hygiene: only call-site identifiers leak out). /// +/// Only defined on Unix — call sites must pair this with a `#[cfg(not(unix))]` +/// fallback that creates the same two bindings via `ctrl_c()` / `pending()`. /// Defined here so all helper submodules can use it without an explicit import. +#[cfg(unix)] macro_rules! hoist_signals { ($sigint:ident, $sigterm:ident, $sigint_fut:ident, $sigterm_fut:ident) => { - #[cfg(unix)] let $sigint_fut = $sigint.recv(); - #[cfg(not(unix))] - let $sigint_fut = tokio::signal::ctrl_c(); - #[cfg(unix)] let $sigterm_fut = $sigterm.recv(); - #[cfg(not(unix))] - let $sigterm_fut = std::future::pending::>(); }; }