From 746fa1d2794b95484e91369583f156801d841eaf Mon Sep 17 00:00:00 2001 From: Josh Rotenberg Date: Mon, 5 Jan 2026 16:01:02 -0800 Subject: [PATCH] feat: add configurable stop timeout for ContainerGuard cleanup Adds stop_timeout option to ContainerGuard for configuring how long Docker waits before sending SIGKILL during cleanup. Changes: - Add stop_timeout field to GuardOptions (Option) - Add stop_timeout() builder method to ContainerGuardBuilder - Use timeout in Drop implementation for both runtime contexts Use cases: - Fast test cleanup with short timeout (e.g., 1 second) - Longer timeout for containers needing graceful shutdown - Duration::ZERO for immediate SIGKILL Example: let guard = ContainerGuard::new(template) .stop_timeout(Duration::from_secs(1)) .start() .await?; Closes #221 --- src/testing.rs | 50 ++++++++++++++++++++++++++-- tests/container_guard_integration.rs | 38 +++++++++++++++++++++ 2 files changed, 86 insertions(+), 2 deletions(-) diff --git a/src/testing.rs b/src/testing.rs index b84fb9d..f678977 100644 --- a/src/testing.rs +++ b/src/testing.rs @@ -32,6 +32,7 @@ use crate::{ use std::collections::HashMap; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; +use std::time::Duration; /// Options for controlling container lifecycle behavior. #[derive(Debug, Clone)] @@ -55,6 +56,8 @@ pub struct GuardOptions { pub create_network: bool, /// Remove the network on drop (default: false) pub remove_network_on_drop: bool, + /// Timeout for stop operations during cleanup (default: None, uses Docker default) + pub stop_timeout: Option, } impl Default for GuardOptions { @@ -69,6 +72,7 @@ impl Default for GuardOptions { network: None, create_network: true, remove_network_on_drop: false, + stop_timeout: None, } } } @@ -206,6 +210,38 @@ impl ContainerGuardBuilder { self } + /// Set the timeout for stop operations during cleanup (default: Docker default). + /// + /// This controls how long Docker waits for the container to stop gracefully + /// before sending SIGKILL. + /// + /// # Example + /// + /// ```rust,no_run + /// # use docker_wrapper::testing::ContainerGuard; + /// # use docker_wrapper::RedisTemplate; + /// # use std::time::Duration; + /// # async fn example() -> Result<(), Box> { + /// // Fast cleanup with 1 second timeout + /// let guard = ContainerGuard::new(RedisTemplate::new("redis")) + /// .stop_timeout(Duration::from_secs(1)) + /// .start() + /// .await?; + /// + /// // Immediate SIGKILL with zero timeout + /// let guard = ContainerGuard::new(RedisTemplate::new("redis2")) + /// .stop_timeout(Duration::ZERO) + /// .start() + /// .await?; + /// # Ok(()) + /// # } + /// ``` + #[must_use] + pub fn stop_timeout(mut self, timeout: Duration) -> Self { + self.options.stop_timeout = Some(timeout); + self + } + /// Start the container and return a guard that manages its lifecycle. /// /// If `reuse_if_running` is enabled and a container is already running, @@ -530,6 +566,7 @@ impl Drop for ContainerGuard { let should_remove_network = self.options.remove_network_on_drop && self.network_created; let container_name = self.template.config().name.clone(); let network_name = self.options.network.clone(); + let stop_timeout = self.options.stop_timeout; if !should_stop && !should_remove && !should_remove_network { return; @@ -548,7 +585,11 @@ impl Drop for ContainerGuard { .expect("Failed to create runtime for cleanup"); rt.block_on(async { if should_stop { - let _ = StopCommand::new(&container_name_clone).execute().await; + let mut cmd = StopCommand::new(&container_name_clone); + if let Some(timeout) = stop_timeout { + cmd = cmd.timeout_duration(timeout); + } + let _ = cmd.execute().await; } if should_remove { let _ = RmCommand::new(&container_name_clone).force().run().await; @@ -570,7 +611,11 @@ impl Drop for ContainerGuard { { rt.block_on(async { if should_stop { - let _ = StopCommand::new(&container_name).execute().await; + let mut cmd = StopCommand::new(&container_name); + if let Some(timeout) = stop_timeout { + cmd = cmd.timeout_duration(timeout); + } + let _ = cmd.execute().await; } if should_remove { let _ = RmCommand::new(&container_name).force().run().await; @@ -1000,6 +1045,7 @@ mod tests { assert!(opts.network.is_none()); assert!(opts.create_network); assert!(!opts.remove_network_on_drop); + assert!(opts.stop_timeout.is_none()); } #[test] diff --git a/tests/container_guard_integration.rs b/tests/container_guard_integration.rs index 6694baf..3fa275b 100644 --- a/tests/container_guard_integration.rs +++ b/tests/container_guard_integration.rs @@ -409,3 +409,41 @@ async fn test_container_guard_set_wait_for_ready_disabled() { // Container cleanup happens on drop } + +#[tokio::test] +async fn test_container_guard_stop_timeout() { + let name = unique_name("guard-stop-timeout"); + + // Use a short stop timeout for fast cleanup + let guard = ContainerGuard::new(RedisTemplate::new(&name).port(next_port())) + .stop_timeout(Duration::from_secs(1)) + .start() + .await + .expect("Failed to start container"); + + assert!( + guard.is_running().await.expect("Failed to check running"), + "Container should be running" + ); + + // Container cleanup with 1 second timeout happens on drop +} + +#[tokio::test] +async fn test_container_guard_stop_timeout_zero() { + let name = unique_name("guard-stop-timeout-zero"); + + // Zero timeout means immediate SIGKILL + let guard = ContainerGuard::new(RedisTemplate::new(&name).port(next_port())) + .stop_timeout(Duration::ZERO) + .start() + .await + .expect("Failed to start container"); + + assert!( + guard.is_running().await.expect("Failed to check running"), + "Container should be running" + ); + + // Container cleanup with immediate SIGKILL happens on drop +}