From 42f3b2777b8fb7bd04b93ae55e9998cb34e193d3 Mon Sep 17 00:00:00 2001 From: Mateusz Charytoniuk Date: Wed, 20 May 2026 21:21:49 +0200 Subject: [PATCH 1/2] split shutdown deadline into cooperative and abort phases via ServiceShutdownOptions --- Makefile | 2 +- README.md | 10 ++-- trzcina/src/lib.rs | 2 + trzcina/src/running_service_collection.rs | 10 +++- trzcina/src/service_shutdown_error.rs | 12 ++-- trzcina/src/service_shutdown_options.rs | 15 +++++ ..._via_shared_holder_between_two_services.rs | 9 +-- ...le_runs_all_services_returned_by_bundle.rs | 9 +-- ...egister_service_runs_registered_service.rs | 7 ++- ...ts_hung_service_after_shutdown_deadline.rs | 12 ++-- ...aborts_hung_services_on_external_cancel.rs | 12 ++-- ...ative_and_abort_deadlines_independently.rs | 57 +++++++++++++++++++ ..._services_when_external_token_cancelled.rs | 9 +-- ...iblings_when_one_service_finishes_first.rs | 9 +-- ...immediately_when_no_services_registered.rs | 5 +- ...when_all_services_finish_simultaneously.rs | 7 ++- ...l_failures_when_multiple_services_error.rs | 9 +-- .../run_records_non_string_panic_payload.rs | 7 ++- ...ords_service_error_and_cancels_siblings.rs | 9 +-- ...ords_service_panic_and_cancels_siblings.rs | 9 +-- ...un_records_string_literal_panic_payload.rs | 7 ++- .../tests/run_records_string_panic_payload.rs | 46 +++++++++++++++ ...ort_deadline_when_service_ignores_abort.rs | 10 +++- ..._display_propagates_header_writer_error.rs | 27 +++++++++ ...display_propagates_outcome_writer_error.rs | 37 ++++++++++++ ...tions_default_uses_ten_second_deadlines.rs | 14 +++++ ...rts_actix_style_shutdown_signal_pattern.rs | 9 +-- .../supports_internal_retry_loop_pattern.rs | 9 +-- ..._interval_ticker_reconciliation_pattern.rs | 9 +-- ...ports_multi_channel_select_pump_pattern.rs | 9 +-- ...utable_internal_state_across_iterations.rs | 9 +-- ...pports_notify_driven_event_loop_pattern.rs | 9 +-- 32 files changed, 324 insertions(+), 92 deletions(-) create mode 100644 trzcina/src/service_shutdown_options.rs create mode 100644 trzcina/tests/run_applies_cooperative_and_abort_deadlines_independently.rs create mode 100644 trzcina/tests/run_records_string_panic_payload.rs create mode 100644 trzcina/tests/service_shutdown_error_display_propagates_header_writer_error.rs create mode 100644 trzcina/tests/service_shutdown_error_display_propagates_outcome_writer_error.rs create mode 100644 trzcina/tests/service_shutdown_options_default_uses_ten_second_deadlines.rs diff --git a/Makefile b/Makefile index 29bfcd6..237a0a9 100644 --- a/Makefile +++ b/Makefile @@ -34,7 +34,7 @@ coverage: node_modules cargo llvm-cov report npx @intentee/rust-coverage-check target/llvm-cov.json \ --workspace-root $(CURDIR) \ - --gated trzcina=97 + --gated trzcina=100 .PHONY: coverage-clean coverage-clean: diff --git a/README.md b/README.md index a424374..72f45ae 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,14 @@ # trzcina -Async service lifecycle orchestration for Rust. Run a set of long-lived async services concurrently, cancel siblings when one finishes, surface errors and panics through a typed outcome collection, and enforce an absolute shutdown deadline. +Async service lifecycle orchestration for Rust. Run a set of long-lived async services concurrently, cancel siblings when one finishes, surface errors and panics through a typed outcome collection, and enforce per-phase shutdown deadlines. ## Usage ```rust -use std::time::Duration; - use anyhow::Result; use async_trait::async_trait; use tokio_util::sync::CancellationToken; -use trzcina::{Service, ServiceManager}; +use trzcina::{Service, ServiceManager, ServiceShutdownOptions}; struct EchoService; @@ -29,9 +27,11 @@ async fn main() -> Result<()> { let running = service_manager.start(CancellationToken::new()); running - .run_to_completion(Duration::from_secs(10)) + .run_to_completion(ServiceShutdownOptions::default()) .await .into_result()?; Ok(()) } ``` + +`ServiceShutdownOptions` exposes two independently tunable deadlines that apply after the cancellation token fires: `cooperative_deadline` (how long services have to exit on their own) and `abort_deadline` (how long the tokio abort has to drain). Both default to 10 seconds. diff --git a/trzcina/src/lib.rs b/trzcina/src/lib.rs index 5f0f6f2..d35919b 100644 --- a/trzcina/src/lib.rs +++ b/trzcina/src/lib.rs @@ -5,6 +5,7 @@ mod service; mod service_bundle; mod service_manager; mod service_shutdown_error; +mod service_shutdown_options; mod service_shutdown_outcome; mod service_shutdown_outcome_collection; mod service_shutdown_outcome_with_service_name; @@ -15,6 +16,7 @@ pub use crate::service::Service; pub use crate::service_bundle::ServiceBundle; pub use crate::service_manager::ServiceManager; pub use crate::service_shutdown_error::ServiceShutdownError; +pub use crate::service_shutdown_options::ServiceShutdownOptions; pub use crate::service_shutdown_outcome::ServiceShutdownOutcome; pub use crate::service_shutdown_outcome_collection::ServiceShutdownOutcomeCollection; pub use crate::service_shutdown_outcome_with_service_name::ServiceShutdownOutcomeWithServiceName; diff --git a/trzcina/src/running_service_collection.rs b/trzcina/src/running_service_collection.rs index 6a3f915..ff0c754 100644 --- a/trzcina/src/running_service_collection.rs +++ b/trzcina/src/running_service_collection.rs @@ -13,6 +13,7 @@ use tokio_util::sync::CancellationToken; use crate::registered_service::RegisteredService; use crate::running_service::RunningService; use crate::service::Service; +use crate::service_shutdown_options::ServiceShutdownOptions; use crate::service_shutdown_outcome::ServiceShutdownOutcome; use crate::service_shutdown_outcome_collection::ServiceShutdownOutcomeCollection; use crate::service_shutdown_outcome_with_service_name::ServiceShutdownOutcomeWithServiceName; @@ -104,12 +105,15 @@ impl RunningServiceCollection { pub async fn run_to_completion( mut self, - shutdown_deadline: Duration, + ServiceShutdownOptions { + cooperative_deadline, + abort_deadline, + }: ServiceShutdownOptions, ) -> ServiceShutdownOutcomeCollection { self.wait_for_shutdown_signal().await; - if !self.drain_within_deadline(shutdown_deadline).await { - self.abort_and_drain(shutdown_deadline).await; + if !self.drain_within_deadline(cooperative_deadline).await { + self.abort_and_drain(abort_deadline).await; } let outcomes: Vec = diff --git a/trzcina/src/service_shutdown_error.rs b/trzcina/src/service_shutdown_error.rs index fedc81a..e8b3edf 100644 --- a/trzcina/src/service_shutdown_error.rs +++ b/trzcina/src/service_shutdown_error.rs @@ -27,20 +27,20 @@ impl fmt::Display for ServiceShutdownError { for ServiceShutdownOutcomeWithServiceName { name, outcome } in &self.failed_outcomes { match outcome { - ServiceShutdownOutcome::Completed => {} + ServiceShutdownOutcome::Completed => Ok(()), ServiceShutdownOutcome::Errored(service_error) => { - writeln!(f, " service {name:?} errored: {service_error:#}")?; + writeln!(f, " service {name:?} errored: {service_error:#}") } ServiceShutdownOutcome::Panicked(panic_message) => { - writeln!(f, " service {name:?} panicked: {panic_message}")?; + writeln!(f, " service {name:?} panicked: {panic_message}") } ServiceShutdownOutcome::AbortedByShutdownDeadline => { - writeln!(f, " service {name:?} aborted after shutdown deadline")?; + writeln!(f, " service {name:?} aborted after shutdown deadline") } ServiceShutdownOutcome::LeakedBeyondAbortDeadline => { - writeln!(f, " service {name:?} leaked beyond shutdown deadline")?; + writeln!(f, " service {name:?} leaked beyond shutdown deadline") } - } + }?; } Ok(()) diff --git a/trzcina/src/service_shutdown_options.rs b/trzcina/src/service_shutdown_options.rs new file mode 100644 index 0000000..1562d7f --- /dev/null +++ b/trzcina/src/service_shutdown_options.rs @@ -0,0 +1,15 @@ +use std::time::Duration; + +pub struct ServiceShutdownOptions { + pub cooperative_deadline: Duration, + pub abort_deadline: Duration, +} + +impl Default for ServiceShutdownOptions { + fn default() -> Self { + Self { + cooperative_deadline: Duration::from_secs(10), + abort_deadline: Duration::from_secs(10), + } + } +} diff --git a/trzcina/tests/coordinates_via_shared_holder_between_two_services.rs b/trzcina/tests/coordinates_via_shared_holder_between_two_services.rs index d7b0265..9789ac1 100644 --- a/trzcina/tests/coordinates_via_shared_holder_between_two_services.rs +++ b/trzcina/tests/coordinates_via_shared_holder_between_two_services.rs @@ -4,13 +4,14 @@ use std::time::Duration; use anyhow::Result; use async_trait::async_trait; -use trzcina::Service; -use trzcina::ServiceManager; -use trzcina::ServiceShutdownOutcome; use tokio::sync::Notify; use tokio::sync::oneshot; use tokio::time::timeout; use tokio_util::sync::CancellationToken; +use trzcina::Service; +use trzcina::ServiceManager; +use trzcina::ServiceShutdownOptions; +use trzcina::ServiceShutdownOutcome; const PRODUCED_VALUE: u32 = 42; @@ -74,7 +75,7 @@ async fn coordinates_via_shared_holder_between_two_services() { let run_task = tokio::spawn(async move { manager .start(cancellation_token_for_run) - .run_to_completion(Duration::from_secs(1)) + .run_to_completion(ServiceShutdownOptions::default()) .await }); diff --git a/trzcina/tests/register_bundle_runs_all_services_returned_by_bundle.rs b/trzcina/tests/register_bundle_runs_all_services_returned_by_bundle.rs index 45a3f6d..047f3a8 100644 --- a/trzcina/tests/register_bundle_runs_all_services_returned_by_bundle.rs +++ b/trzcina/tests/register_bundle_runs_all_services_returned_by_bundle.rs @@ -2,12 +2,13 @@ use std::time::Duration; use anyhow::Result; use async_trait::async_trait; -use trzcina::Service; -use trzcina::ServiceBundle; -use trzcina::ServiceManager; use tokio::sync::oneshot; use tokio::time::timeout; use tokio_util::sync::CancellationToken; +use trzcina::Service; +use trzcina::ServiceBundle; +use trzcina::ServiceManager; +use trzcina::ServiceShutdownOptions; struct BundleAndService { observation_tx: Option>, @@ -58,7 +59,7 @@ async fn runs_all_services_returned_by_bundle() { Duration::from_secs(5), manager .start(CancellationToken::new()) - .run_to_completion(Duration::from_secs(1)), + .run_to_completion(ServiceShutdownOptions::default()), ) .await .unwrap() diff --git a/trzcina/tests/register_service_runs_registered_service.rs b/trzcina/tests/register_service_runs_registered_service.rs index 688b127..ac7bd13 100644 --- a/trzcina/tests/register_service_runs_registered_service.rs +++ b/trzcina/tests/register_service_runs_registered_service.rs @@ -2,11 +2,12 @@ use std::time::Duration; use anyhow::Result; use async_trait::async_trait; -use trzcina::Service; -use trzcina::ServiceManager; use tokio::sync::oneshot; use tokio::time::timeout; use tokio_util::sync::CancellationToken; +use trzcina::Service; +use trzcina::ServiceManager; +use trzcina::ServiceShutdownOptions; struct ObservableService { observation_tx: Option>, @@ -35,7 +36,7 @@ async fn runs_registered_service() { Duration::from_secs(5), manager .start(CancellationToken::new()) - .run_to_completion(Duration::from_secs(1)), + .run_to_completion(ServiceShutdownOptions::default()), ) .await .unwrap() diff --git a/trzcina/tests/run_aborts_hung_service_after_shutdown_deadline.rs b/trzcina/tests/run_aborts_hung_service_after_shutdown_deadline.rs index 619b9d0..bd3d412 100644 --- a/trzcina/tests/run_aborts_hung_service_after_shutdown_deadline.rs +++ b/trzcina/tests/run_aborts_hung_service_after_shutdown_deadline.rs @@ -2,12 +2,13 @@ use std::time::Duration; use anyhow::Result; use async_trait::async_trait; -use trzcina::Service; -use trzcina::ServiceManager; -use trzcina::ServiceShutdownOutcome; use tokio::task::yield_now; use tokio::time::timeout; use tokio_util::sync::CancellationToken; +use trzcina::Service; +use trzcina::ServiceManager; +use trzcina::ServiceShutdownOptions; +use trzcina::ServiceShutdownOutcome; struct ConfiguredService { hang_ignoring_cancellation: bool, @@ -39,7 +40,10 @@ async fn aborts_hung_service_after_shutdown_deadline() { Duration::from_secs(5), manager .start(CancellationToken::new()) - .run_to_completion(Duration::from_millis(50)), + .run_to_completion(ServiceShutdownOptions { + cooperative_deadline: Duration::from_millis(50), + abort_deadline: Duration::from_millis(50), + }), ) .await .unwrap(); diff --git a/trzcina/tests/run_aborts_hung_services_on_external_cancel.rs b/trzcina/tests/run_aborts_hung_services_on_external_cancel.rs index ff27bb5..b99259a 100644 --- a/trzcina/tests/run_aborts_hung_services_on_external_cancel.rs +++ b/trzcina/tests/run_aborts_hung_services_on_external_cancel.rs @@ -2,12 +2,13 @@ use std::time::Duration; use anyhow::Result; use async_trait::async_trait; -use trzcina::Service; -use trzcina::ServiceManager; -use trzcina::ServiceShutdownOutcome; use tokio::task::yield_now; use tokio::time::timeout; use tokio_util::sync::CancellationToken; +use trzcina::Service; +use trzcina::ServiceManager; +use trzcina::ServiceShutdownOptions; +use trzcina::ServiceShutdownOutcome; struct CancellationIgnoringService; @@ -32,7 +33,10 @@ async fn aborts_hung_services_on_external_cancel() { let run_task = tokio::spawn(async move { manager .start(cancellation_token_for_run) - .run_to_completion(Duration::from_millis(50)) + .run_to_completion(ServiceShutdownOptions { + cooperative_deadline: Duration::from_millis(50), + abort_deadline: Duration::from_millis(50), + }) .await }); diff --git a/trzcina/tests/run_applies_cooperative_and_abort_deadlines_independently.rs b/trzcina/tests/run_applies_cooperative_and_abort_deadlines_independently.rs new file mode 100644 index 0000000..6166592 --- /dev/null +++ b/trzcina/tests/run_applies_cooperative_and_abort_deadlines_independently.rs @@ -0,0 +1,57 @@ +use std::time::Duration; + +use anyhow::Result; +use async_trait::async_trait; +use tokio::time::timeout; +use tokio_util::sync::CancellationToken; +use trzcina::Service; +use trzcina::ServiceManager; +use trzcina::ServiceShutdownOptions; +use trzcina::ServiceShutdownOutcome; + +struct ThreadBlockingService { + block_duration: Duration, +} + +#[async_trait] +impl Service for ThreadBlockingService { + async fn run(&mut self, _cancellation_token: CancellationToken) -> Result<()> { + std::thread::sleep(self.block_duration); + Ok(()) + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn applies_cooperative_and_abort_deadlines_independently() { + let cancellation_token = CancellationToken::new(); + let cancellation_token_for_run = cancellation_token.clone(); + + let mut manager = ServiceManager::default(); + manager.register_service(ThreadBlockingService { + block_duration: Duration::from_millis(500), + }); + + let run_task = tokio::spawn(async move { + manager + .start(cancellation_token_for_run) + .run_to_completion(ServiceShutdownOptions { + cooperative_deadline: Duration::from_millis(50), + abort_deadline: Duration::from_millis(2000), + }) + .await + }); + + cancellation_token.cancel(); + + let report = timeout(Duration::from_secs(5), run_task) + .await + .expect("manager must return within outer bound") + .unwrap(); + + assert_eq!(report.outcomes().len(), 1); + assert!(matches!( + report.outcomes()[0].outcome, + ServiceShutdownOutcome::Completed, + )); + assert!(report.into_result().is_ok()); +} diff --git a/trzcina/tests/run_cancels_all_services_when_external_token_cancelled.rs b/trzcina/tests/run_cancels_all_services_when_external_token_cancelled.rs index 2917849..271d932 100644 --- a/trzcina/tests/run_cancels_all_services_when_external_token_cancelled.rs +++ b/trzcina/tests/run_cancels_all_services_when_external_token_cancelled.rs @@ -2,12 +2,13 @@ use std::time::Duration; use anyhow::Result; use async_trait::async_trait; -use trzcina::Service; -use trzcina::ServiceManager; -use trzcina::ServiceShutdownOutcome; use tokio::sync::oneshot; use tokio::time::timeout; use tokio_util::sync::CancellationToken; +use trzcina::Service; +use trzcina::ServiceManager; +use trzcina::ServiceShutdownOptions; +use trzcina::ServiceShutdownOutcome; struct AwaitingService { observation_tx: Option>, @@ -42,7 +43,7 @@ async fn cancels_all_services_when_external_token_cancelled() { let run_task = tokio::spawn(async move { manager .start(cancellation_token_for_run) - .run_to_completion(Duration::from_secs(1)) + .run_to_completion(ServiceShutdownOptions::default()) .await }); diff --git a/trzcina/tests/run_cancels_siblings_when_one_service_finishes_first.rs b/trzcina/tests/run_cancels_siblings_when_one_service_finishes_first.rs index b4405bd..8b5566d 100644 --- a/trzcina/tests/run_cancels_siblings_when_one_service_finishes_first.rs +++ b/trzcina/tests/run_cancels_siblings_when_one_service_finishes_first.rs @@ -2,12 +2,13 @@ use std::time::Duration; use anyhow::Result; use async_trait::async_trait; -use trzcina::Service; -use trzcina::ServiceManager; -use trzcina::ServiceShutdownOutcome; use tokio::sync::oneshot; use tokio::time::timeout; use tokio_util::sync::CancellationToken; +use trzcina::Service; +use trzcina::ServiceManager; +use trzcina::ServiceShutdownOptions; +use trzcina::ServiceShutdownOutcome; struct ConfiguredService { finish_immediately: bool, @@ -50,7 +51,7 @@ async fn cancels_siblings_when_one_service_finishes_first() { Duration::from_secs(5), manager .start(CancellationToken::new()) - .run_to_completion(Duration::from_secs(1)), + .run_to_completion(ServiceShutdownOptions::default()), ) .await .unwrap(); diff --git a/trzcina/tests/run_completes_immediately_when_no_services_registered.rs b/trzcina/tests/run_completes_immediately_when_no_services_registered.rs index 45c0aea..8a59837 100644 --- a/trzcina/tests/run_completes_immediately_when_no_services_registered.rs +++ b/trzcina/tests/run_completes_immediately_when_no_services_registered.rs @@ -1,8 +1,9 @@ use std::time::Duration; -use trzcina::ServiceManager; use tokio::time::timeout; use tokio_util::sync::CancellationToken; +use trzcina::ServiceManager; +use trzcina::ServiceShutdownOptions; #[tokio::test] async fn completes_immediately_when_no_services_registered() { @@ -11,7 +12,7 @@ async fn completes_immediately_when_no_services_registered() { Duration::from_secs(5), manager .start(CancellationToken::new()) - .run_to_completion(Duration::from_secs(1)), + .run_to_completion(ServiceShutdownOptions::default()), ) .await .unwrap() diff --git a/trzcina/tests/run_completes_when_all_services_finish_simultaneously.rs b/trzcina/tests/run_completes_when_all_services_finish_simultaneously.rs index 7039f04..f8835fa 100644 --- a/trzcina/tests/run_completes_when_all_services_finish_simultaneously.rs +++ b/trzcina/tests/run_completes_when_all_services_finish_simultaneously.rs @@ -2,11 +2,12 @@ use std::time::Duration; use anyhow::Result; use async_trait::async_trait; +use tokio::time::timeout; +use tokio_util::sync::CancellationToken; use trzcina::Service; use trzcina::ServiceManager; +use trzcina::ServiceShutdownOptions; use trzcina::ServiceShutdownOutcome; -use tokio::time::timeout; -use tokio_util::sync::CancellationToken; struct InstantOkService; @@ -28,7 +29,7 @@ async fn completes_when_all_services_finish_simultaneously() { Duration::from_secs(5), manager .start(CancellationToken::new()) - .run_to_completion(Duration::from_secs(1)), + .run_to_completion(ServiceShutdownOptions::default()), ) .await .unwrap(); diff --git a/trzcina/tests/run_records_all_failures_when_multiple_services_error.rs b/trzcina/tests/run_records_all_failures_when_multiple_services_error.rs index c966b92..fbbca18 100644 --- a/trzcina/tests/run_records_all_failures_when_multiple_services_error.rs +++ b/trzcina/tests/run_records_all_failures_when_multiple_services_error.rs @@ -3,12 +3,13 @@ use std::time::Duration; use anyhow::Result; use anyhow::anyhow; use async_trait::async_trait; -use trzcina::Service; -use trzcina::ServiceManager; -use trzcina::ServiceShutdownOutcome; use tokio::sync::oneshot; use tokio::time::timeout; use tokio_util::sync::CancellationToken; +use trzcina::Service; +use trzcina::ServiceManager; +use trzcina::ServiceShutdownOptions; +use trzcina::ServiceShutdownOutcome; struct ConfiguredService { return_err: bool, @@ -53,7 +54,7 @@ async fn records_all_failures_when_multiple_services_error() { Duration::from_secs(5), manager .start(CancellationToken::new()) - .run_to_completion(Duration::from_secs(1)), + .run_to_completion(ServiceShutdownOptions::default()), ) .await .unwrap(); diff --git a/trzcina/tests/run_records_non_string_panic_payload.rs b/trzcina/tests/run_records_non_string_panic_payload.rs index 3f3c77b..1ef4d48 100644 --- a/trzcina/tests/run_records_non_string_panic_payload.rs +++ b/trzcina/tests/run_records_non_string_panic_payload.rs @@ -3,11 +3,12 @@ use std::time::Duration; use anyhow::Result; use async_trait::async_trait; +use tokio::time::timeout; +use tokio_util::sync::CancellationToken; use trzcina::Service; use trzcina::ServiceManager; +use trzcina::ServiceShutdownOptions; use trzcina::ServiceShutdownOutcome; -use tokio::time::timeout; -use tokio_util::sync::CancellationToken; struct NonStringPanickingService; @@ -27,7 +28,7 @@ async fn records_non_string_panic_payload_as_generic_message() { Duration::from_secs(5), manager .start(CancellationToken::new()) - .run_to_completion(Duration::from_secs(1)), + .run_to_completion(ServiceShutdownOptions::default()), ) .await .unwrap(); diff --git a/trzcina/tests/run_records_service_error_and_cancels_siblings.rs b/trzcina/tests/run_records_service_error_and_cancels_siblings.rs index 18f82d5..f7bc042 100644 --- a/trzcina/tests/run_records_service_error_and_cancels_siblings.rs +++ b/trzcina/tests/run_records_service_error_and_cancels_siblings.rs @@ -3,12 +3,13 @@ use std::time::Duration; use anyhow::Result; use anyhow::anyhow; use async_trait::async_trait; -use trzcina::Service; -use trzcina::ServiceManager; -use trzcina::ServiceShutdownOutcome; use tokio::sync::oneshot; use tokio::time::timeout; use tokio_util::sync::CancellationToken; +use trzcina::Service; +use trzcina::ServiceManager; +use trzcina::ServiceShutdownOptions; +use trzcina::ServiceShutdownOutcome; struct ConfiguredService { return_err: bool, @@ -51,7 +52,7 @@ async fn records_service_error_and_cancels_siblings() { Duration::from_secs(5), manager .start(CancellationToken::new()) - .run_to_completion(Duration::from_secs(1)), + .run_to_completion(ServiceShutdownOptions::default()), ) .await .unwrap(); diff --git a/trzcina/tests/run_records_service_panic_and_cancels_siblings.rs b/trzcina/tests/run_records_service_panic_and_cancels_siblings.rs index 4893792..2d8bfcb 100644 --- a/trzcina/tests/run_records_service_panic_and_cancels_siblings.rs +++ b/trzcina/tests/run_records_service_panic_and_cancels_siblings.rs @@ -2,12 +2,13 @@ use std::time::Duration; use anyhow::Result; use async_trait::async_trait; -use trzcina::Service; -use trzcina::ServiceManager; -use trzcina::ServiceShutdownOutcome; use tokio::sync::oneshot; use tokio::time::timeout; use tokio_util::sync::CancellationToken; +use trzcina::Service; +use trzcina::ServiceManager; +use trzcina::ServiceShutdownOptions; +use trzcina::ServiceShutdownOutcome; const PANIC_MARKER: &str = "deliberately panicking for cascade test"; @@ -52,7 +53,7 @@ async fn records_service_panic_and_cancels_siblings() { Duration::from_secs(5), manager .start(CancellationToken::new()) - .run_to_completion(Duration::from_secs(1)), + .run_to_completion(ServiceShutdownOptions::default()), ) .await .unwrap(); diff --git a/trzcina/tests/run_records_string_literal_panic_payload.rs b/trzcina/tests/run_records_string_literal_panic_payload.rs index 56ce31a..71c4edb 100644 --- a/trzcina/tests/run_records_string_literal_panic_payload.rs +++ b/trzcina/tests/run_records_string_literal_panic_payload.rs @@ -2,11 +2,12 @@ use std::time::Duration; use anyhow::Result; use async_trait::async_trait; +use tokio::time::timeout; +use tokio_util::sync::CancellationToken; use trzcina::Service; use trzcina::ServiceManager; +use trzcina::ServiceShutdownOptions; use trzcina::ServiceShutdownOutcome; -use tokio::time::timeout; -use tokio_util::sync::CancellationToken; const PANIC_LITERAL: &str = "deliberately panicking with a string literal"; @@ -28,7 +29,7 @@ async fn records_string_literal_panic_payload() { Duration::from_secs(5), manager .start(CancellationToken::new()) - .run_to_completion(Duration::from_secs(1)), + .run_to_completion(ServiceShutdownOptions::default()), ) .await .unwrap(); diff --git a/trzcina/tests/run_records_string_panic_payload.rs b/trzcina/tests/run_records_string_panic_payload.rs new file mode 100644 index 0000000..119287e --- /dev/null +++ b/trzcina/tests/run_records_string_panic_payload.rs @@ -0,0 +1,46 @@ +use std::time::Duration; + +use anyhow::Result; +use async_trait::async_trait; +use tokio::time::timeout; +use tokio_util::sync::CancellationToken; +use trzcina::Service; +use trzcina::ServiceManager; +use trzcina::ServiceShutdownOptions; +use trzcina::ServiceShutdownOutcome; + +struct StringPanickingService { + panic_payload: String, +} + +#[async_trait] +impl Service for StringPanickingService { + async fn run(&mut self, _cancellation_token: CancellationToken) -> Result<()> { + panic!("dynamic message: {}", self.panic_payload); + } +} + +#[tokio::test] +async fn records_string_panic_payload() { + let mut manager = ServiceManager::default(); + manager.register_service(StringPanickingService { + panic_payload: "owned-string-panic-payload".to_owned(), + }); + + let report = timeout( + Duration::from_secs(5), + manager + .start(CancellationToken::new()) + .run_to_completion(ServiceShutdownOptions::default()), + ) + .await + .unwrap(); + + assert_eq!(report.outcomes().len(), 1); + match &report.outcomes()[0].outcome { + ServiceShutdownOutcome::Panicked(panic_message) => { + assert!(panic_message.contains("owned-string-panic-payload")); + } + other_outcome => panic!("expected ServiceShutdownOutcome::Panicked, got {other_outcome:?}"), + } +} diff --git a/trzcina/tests/run_reports_leaked_beyond_abort_deadline_when_service_ignores_abort.rs b/trzcina/tests/run_reports_leaked_beyond_abort_deadline_when_service_ignores_abort.rs index 296a8fc..c1737d1 100644 --- a/trzcina/tests/run_reports_leaked_beyond_abort_deadline_when_service_ignores_abort.rs +++ b/trzcina/tests/run_reports_leaked_beyond_abort_deadline_when_service_ignores_abort.rs @@ -2,11 +2,12 @@ use std::time::Duration; use anyhow::Result; use async_trait::async_trait; +use tokio::time::timeout; +use tokio_util::sync::CancellationToken; use trzcina::Service; use trzcina::ServiceManager; +use trzcina::ServiceShutdownOptions; use trzcina::ServiceShutdownOutcome; -use tokio::time::timeout; -use tokio_util::sync::CancellationToken; struct ThreadBlockingService { block_duration: Duration, @@ -33,7 +34,10 @@ async fn reports_leaked_beyond_abort_deadline_when_service_ignores_abort() { let run_task = tokio::spawn(async move { manager .start(cancellation_token_for_run) - .run_to_completion(Duration::from_millis(50)) + .run_to_completion(ServiceShutdownOptions { + cooperative_deadline: Duration::from_millis(50), + abort_deadline: Duration::from_millis(50), + }) .await }); diff --git a/trzcina/tests/service_shutdown_error_display_propagates_header_writer_error.rs b/trzcina/tests/service_shutdown_error_display_propagates_header_writer_error.rs new file mode 100644 index 0000000..2fcf7b9 --- /dev/null +++ b/trzcina/tests/service_shutdown_error_display_propagates_header_writer_error.rs @@ -0,0 +1,27 @@ +use std::fmt; +use std::fmt::Write; + +use trzcina::ServiceShutdownError; +use trzcina::ServiceShutdownOutcome; +use trzcina::ServiceShutdownOutcomeWithServiceName; + +struct AlwaysFailingWriter; + +impl fmt::Write for AlwaysFailingWriter { + fn write_str(&mut self, _written: &str) -> fmt::Result { + Err(fmt::Error) + } +} + +#[test] +fn display_propagates_writer_error_from_header_line() { + let shutdown_error = ServiceShutdownError::new(vec![ServiceShutdownOutcomeWithServiceName { + name: "test_service", + outcome: ServiceShutdownOutcome::AbortedByShutdownDeadline, + }]); + + let mut writer = AlwaysFailingWriter; + let write_result = write!(writer, "{shutdown_error}"); + + assert!(write_result.is_err()); +} diff --git a/trzcina/tests/service_shutdown_error_display_propagates_outcome_writer_error.rs b/trzcina/tests/service_shutdown_error_display_propagates_outcome_writer_error.rs new file mode 100644 index 0000000..c66fd87 --- /dev/null +++ b/trzcina/tests/service_shutdown_error_display_propagates_outcome_writer_error.rs @@ -0,0 +1,37 @@ +use std::fmt; +use std::fmt::Write; + +use trzcina::ServiceShutdownError; +use trzcina::ServiceShutdownOutcome; +use trzcina::ServiceShutdownOutcomeWithServiceName; + +struct WriterThatFailsAfterFirstLine { + saw_newline: bool, +} + +impl fmt::Write for WriterThatFailsAfterFirstLine { + fn write_str(&mut self, written: &str) -> fmt::Result { + if self.saw_newline { + return Err(fmt::Error); + } + + if written.contains('\n') { + self.saw_newline = true; + } + + Ok(()) + } +} + +#[test] +fn display_propagates_writer_error_from_outcome_line() { + let shutdown_error = ServiceShutdownError::new(vec![ServiceShutdownOutcomeWithServiceName { + name: "test_service", + outcome: ServiceShutdownOutcome::AbortedByShutdownDeadline, + }]); + + let mut writer = WriterThatFailsAfterFirstLine { saw_newline: false }; + let write_result = write!(writer, "{shutdown_error}"); + + assert!(write_result.is_err()); +} diff --git a/trzcina/tests/service_shutdown_options_default_uses_ten_second_deadlines.rs b/trzcina/tests/service_shutdown_options_default_uses_ten_second_deadlines.rs new file mode 100644 index 0000000..6202df3 --- /dev/null +++ b/trzcina/tests/service_shutdown_options_default_uses_ten_second_deadlines.rs @@ -0,0 +1,14 @@ +use std::time::Duration; + +use trzcina::ServiceShutdownOptions; + +#[test] +fn default_uses_ten_second_deadlines_for_both_phases() { + let ServiceShutdownOptions { + cooperative_deadline, + abort_deadline, + } = ServiceShutdownOptions::default(); + + assert_eq!(cooperative_deadline, Duration::from_secs(10)); + assert_eq!(abort_deadline, Duration::from_secs(10)); +} diff --git a/trzcina/tests/supports_actix_style_shutdown_signal_pattern.rs b/trzcina/tests/supports_actix_style_shutdown_signal_pattern.rs index 388a545..4c74adf 100644 --- a/trzcina/tests/supports_actix_style_shutdown_signal_pattern.rs +++ b/trzcina/tests/supports_actix_style_shutdown_signal_pattern.rs @@ -2,12 +2,13 @@ use std::time::Duration; use anyhow::Result; use async_trait::async_trait; -use trzcina::Service; -use trzcina::ServiceManager; -use trzcina::ServiceShutdownOutcome; use tokio::sync::oneshot; use tokio::time::timeout; use tokio_util::sync::CancellationToken; +use trzcina::Service; +use trzcina::ServiceManager; +use trzcina::ServiceShutdownOptions; +use trzcina::ServiceShutdownOutcome; struct ActixStyleService { started_tx: Option>, @@ -43,7 +44,7 @@ async fn supports_actix_style_shutdown_signal_pattern() { let run_task = tokio::spawn(async move { manager .start(cancellation_token_for_run) - .run_to_completion(Duration::from_secs(1)) + .run_to_completion(ServiceShutdownOptions::default()) .await }); diff --git a/trzcina/tests/supports_internal_retry_loop_pattern.rs b/trzcina/tests/supports_internal_retry_loop_pattern.rs index d5ebbce..fe8458a 100644 --- a/trzcina/tests/supports_internal_retry_loop_pattern.rs +++ b/trzcina/tests/supports_internal_retry_loop_pattern.rs @@ -2,13 +2,14 @@ use std::time::Duration; use anyhow::Result; use async_trait::async_trait; -use trzcina::Service; -use trzcina::ServiceManager; -use trzcina::ServiceShutdownOutcome; use tokio::sync::oneshot; use tokio::time::sleep; use tokio::time::timeout; use tokio_util::sync::CancellationToken; +use trzcina::Service; +use trzcina::ServiceManager; +use trzcina::ServiceShutdownOptions; +use trzcina::ServiceShutdownOutcome; struct RetryLoopService { backoff_started_tx: Option>, @@ -43,7 +44,7 @@ async fn supports_internal_retry_loop_pattern() { let run_task = tokio::spawn(async move { manager .start(cancellation_token_for_run) - .run_to_completion(Duration::from_secs(1)) + .run_to_completion(ServiceShutdownOptions::default()) .await }); diff --git a/trzcina/tests/supports_interval_ticker_reconciliation_pattern.rs b/trzcina/tests/supports_interval_ticker_reconciliation_pattern.rs index cc18af0..9661ae1 100644 --- a/trzcina/tests/supports_interval_ticker_reconciliation_pattern.rs +++ b/trzcina/tests/supports_interval_ticker_reconciliation_pattern.rs @@ -5,13 +5,14 @@ use std::time::Duration; use anyhow::Result; use async_trait::async_trait; -use trzcina::Service; -use trzcina::ServiceManager; -use trzcina::ServiceShutdownOutcome; use tokio::sync::oneshot; use tokio::time::interval; use tokio::time::timeout; use tokio_util::sync::CancellationToken; +use trzcina::Service; +use trzcina::ServiceManager; +use trzcina::ServiceShutdownOptions; +use trzcina::ServiceShutdownOutcome; struct ReconciliationService { first_tick_tx: Option>, @@ -54,7 +55,7 @@ async fn supports_interval_ticker_reconciliation_pattern() { let run_task = tokio::spawn(async move { manager .start(cancellation_token_for_run) - .run_to_completion(Duration::from_secs(1)) + .run_to_completion(ServiceShutdownOptions::default()) .await }); diff --git a/trzcina/tests/supports_multi_channel_select_pump_pattern.rs b/trzcina/tests/supports_multi_channel_select_pump_pattern.rs index 13150e5..a29983e 100644 --- a/trzcina/tests/supports_multi_channel_select_pump_pattern.rs +++ b/trzcina/tests/supports_multi_channel_select_pump_pattern.rs @@ -2,13 +2,14 @@ use std::time::Duration; use anyhow::Result; use async_trait::async_trait; -use trzcina::Service; -use trzcina::ServiceManager; -use trzcina::ServiceShutdownOutcome; use tokio::sync::mpsc; use tokio::sync::oneshot; use tokio::time::timeout; use tokio_util::sync::CancellationToken; +use trzcina::Service; +use trzcina::ServiceManager; +use trzcina::ServiceShutdownOptions; +use trzcina::ServiceShutdownOutcome; struct MultiChannelPumpService { primary_observed_tx: Option>, @@ -58,7 +59,7 @@ async fn supports_multi_channel_select_pump_pattern() { let run_task = tokio::spawn(async move { manager .start(cancellation_token_for_run) - .run_to_completion(Duration::from_secs(1)) + .run_to_completion(ServiceShutdownOptions::default()) .await }); diff --git a/trzcina/tests/supports_mutable_internal_state_across_iterations.rs b/trzcina/tests/supports_mutable_internal_state_across_iterations.rs index bdc87ac..a4d784b 100644 --- a/trzcina/tests/supports_mutable_internal_state_across_iterations.rs +++ b/trzcina/tests/supports_mutable_internal_state_across_iterations.rs @@ -4,13 +4,14 @@ use std::time::Duration; use anyhow::Result; use async_trait::async_trait; -use trzcina::Service; -use trzcina::ServiceManager; -use trzcina::ServiceShutdownOutcome; use tokio::sync::Notify; use tokio::sync::oneshot; use tokio::time::timeout; use tokio_util::sync::CancellationToken; +use trzcina::Service; +use trzcina::ServiceManager; +use trzcina::ServiceShutdownOptions; +use trzcina::ServiceShutdownOutcome; struct StatefulService { iteration_count: usize, @@ -53,7 +54,7 @@ async fn supports_mutable_internal_state_across_iterations() { let run_task = tokio::spawn(async move { manager .start(cancellation_token_for_run) - .run_to_completion(Duration::from_secs(1)) + .run_to_completion(ServiceShutdownOptions::default()) .await }); diff --git a/trzcina/tests/supports_notify_driven_event_loop_pattern.rs b/trzcina/tests/supports_notify_driven_event_loop_pattern.rs index b9a4f8a..a8a0e88 100644 --- a/trzcina/tests/supports_notify_driven_event_loop_pattern.rs +++ b/trzcina/tests/supports_notify_driven_event_loop_pattern.rs @@ -4,13 +4,14 @@ use std::time::Duration; use anyhow::Result; use async_trait::async_trait; -use trzcina::Service; -use trzcina::ServiceManager; -use trzcina::ServiceShutdownOutcome; use tokio::sync::Notify; use tokio::sync::oneshot; use tokio::time::timeout; use tokio_util::sync::CancellationToken; +use trzcina::Service; +use trzcina::ServiceManager; +use trzcina::ServiceShutdownOptions; +use trzcina::ServiceShutdownOutcome; struct NotifyDrivenService { notify: Arc, @@ -50,7 +51,7 @@ async fn supports_notify_driven_event_loop_pattern() { let run_task = tokio::spawn(async move { manager .start(cancellation_token_for_run) - .run_to_completion(Duration::from_secs(1)) + .run_to_completion(ServiceShutdownOptions::default()) .await }); From 71d46af394ac1b138dffd875ff1203f04218dd8f Mon Sep 17 00:00:00 2001 From: Mateusz Charytoniuk Date: Thu, 21 May 2026 19:15:30 +0200 Subject: [PATCH 2/2] rename LeakedBeyondAbortDeadline variant to LeakedBeyondShutdownDeadline --- trzcina/src/service_shutdown_error.rs | 2 +- trzcina/src/service_shutdown_outcome.rs | 2 +- trzcina/src/service_shutdown_outcome_with_service_name.rs | 2 +- ...aked_beyond_shutdown_deadline_when_service_ignores_abort.rs} | 2 +- ...rvice_shutdown_error_display_formats_all_failure_variants.rs | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) rename trzcina/tests/{run_reports_leaked_beyond_abort_deadline_when_service_ignores_abort.rs => run_reports_leaked_beyond_shutdown_deadline_when_service_ignores_abort.rs} (96%) diff --git a/trzcina/src/service_shutdown_error.rs b/trzcina/src/service_shutdown_error.rs index e8b3edf..696068a 100644 --- a/trzcina/src/service_shutdown_error.rs +++ b/trzcina/src/service_shutdown_error.rs @@ -37,7 +37,7 @@ impl fmt::Display for ServiceShutdownError { ServiceShutdownOutcome::AbortedByShutdownDeadline => { writeln!(f, " service {name:?} aborted after shutdown deadline") } - ServiceShutdownOutcome::LeakedBeyondAbortDeadline => { + ServiceShutdownOutcome::LeakedBeyondShutdownDeadline => { writeln!(f, " service {name:?} leaked beyond shutdown deadline") } }?; diff --git a/trzcina/src/service_shutdown_outcome.rs b/trzcina/src/service_shutdown_outcome.rs index a3bb9d3..80899b8 100644 --- a/trzcina/src/service_shutdown_outcome.rs +++ b/trzcina/src/service_shutdown_outcome.rs @@ -6,5 +6,5 @@ pub enum ServiceShutdownOutcome { Errored(Error), Panicked(String), AbortedByShutdownDeadline, - LeakedBeyondAbortDeadline, + LeakedBeyondShutdownDeadline, } diff --git a/trzcina/src/service_shutdown_outcome_with_service_name.rs b/trzcina/src/service_shutdown_outcome_with_service_name.rs index f360bbe..5c1aea2 100644 --- a/trzcina/src/service_shutdown_outcome_with_service_name.rs +++ b/trzcina/src/service_shutdown_outcome_with_service_name.rs @@ -14,7 +14,7 @@ impl From for ServiceShutdownOutcomeWithServiceName { let outcome = match running_service.outcome_receiver.try_recv() { Ok(outcome) => outcome, Err(TryRecvError::Closed) => ServiceShutdownOutcome::AbortedByShutdownDeadline, - Err(TryRecvError::Empty) => ServiceShutdownOutcome::LeakedBeyondAbortDeadline, + Err(TryRecvError::Empty) => ServiceShutdownOutcome::LeakedBeyondShutdownDeadline, }; Self { diff --git a/trzcina/tests/run_reports_leaked_beyond_abort_deadline_when_service_ignores_abort.rs b/trzcina/tests/run_reports_leaked_beyond_shutdown_deadline_when_service_ignores_abort.rs similarity index 96% rename from trzcina/tests/run_reports_leaked_beyond_abort_deadline_when_service_ignores_abort.rs rename to trzcina/tests/run_reports_leaked_beyond_shutdown_deadline_when_service_ignores_abort.rs index c1737d1..022a7c4 100644 --- a/trzcina/tests/run_reports_leaked_beyond_abort_deadline_when_service_ignores_abort.rs +++ b/trzcina/tests/run_reports_leaked_beyond_shutdown_deadline_when_service_ignores_abort.rs @@ -51,7 +51,7 @@ async fn reports_leaked_beyond_abort_deadline_when_service_ignores_abort() { assert_eq!(report.outcomes().len(), 1); assert!(matches!( report.outcomes()[0].outcome, - ServiceShutdownOutcome::LeakedBeyondAbortDeadline, + ServiceShutdownOutcome::LeakedBeyondShutdownDeadline, )); assert!(report.into_result().is_err()); } diff --git a/trzcina/tests/service_shutdown_error_display_formats_all_failure_variants.rs b/trzcina/tests/service_shutdown_error_display_formats_all_failure_variants.rs index 008012c..68d89cc 100644 --- a/trzcina/tests/service_shutdown_error_display_formats_all_failure_variants.rs +++ b/trzcina/tests/service_shutdown_error_display_formats_all_failure_variants.rs @@ -24,7 +24,7 @@ fn display_formats_all_failure_variants() { }, ServiceShutdownOutcomeWithServiceName { name: "leaked_service", - outcome: ServiceShutdownOutcome::LeakedBeyondAbortDeadline, + outcome: ServiceShutdownOutcome::LeakedBeyondShutdownDeadline, }, ];