lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 234824a8aa2d4ccd7c7d158d48afefa5c6dab89b
parent a5d132a3213b40c44f1ee9ec62955f2b6199f77a
Author: triesap <tyson@radroots.org>
Date:   Tue, 11 Aug 2026 01:41:30 +0000

service-host: add cancellation contract

- wrap a proven cancel-safe cooperative token
- preserve directional parent and child cancellation
- expose idempotent state and observable waiters
- prove races and drops without installing signals

Diffstat:
MCargo.lock | 2++
MCargo.toml | 1+
Mcrates/service_host/Cargo.toml | 1+
Mcrates/service_host/src/lib.rs | 4++--
Acrates/service_host/src/lifecycle/cancel.rs | 120+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/service_host/src/lifecycle/mod.rs | 2++
Mcrates/service_host/tests/package_boundary.rs | 9+++++++++
7 files changed, 137 insertions(+), 2 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -3884,6 +3884,7 @@ dependencies = [ "serde", "serde_json", "tokio", + "tokio-util", ] [[package]] @@ -5532,6 +5533,7 @@ dependencies = [ "bytes", "futures-core", "futures-sink", + "futures-util", "pin-project-lite", "tokio", ] diff --git a/Cargo.toml b/Cargo.toml @@ -271,6 +271,7 @@ tempfile = { version = "3" } tar = { version = "0.4" } thiserror = { version = "1" } tokio = { version = "1" } +tokio-util = { version = "0.7", features = ["rt"] } tokio-tungstenite = { version = "0.26.2", default-features = false, features = [ "connect", "rustls-tls-webpki-roots", diff --git a/crates/service_host/Cargo.toml b/crates/service_host/Cargo.toml @@ -17,6 +17,7 @@ radroots_runtime_paths = { workspace = true } serde = { workspace = true, features = ["derive", "std"] } serde_json = { workspace = true, features = ["std"] } tokio = { workspace = true, features = ["sync"] } +tokio-util = { workspace = true } [dev-dependencies] tokio = { workspace = true, features = ["macros", "rt", "sync"] } diff --git a/crates/service_host/src/lib.rs b/crates/service_host/src/lib.rs @@ -16,8 +16,8 @@ pub use build_info::{ pub use entropy::{EntropyError, EntropySource, SystemEntropy}; pub use error::{HostError, HostErrorCode, HostErrorKind, SafeHostError}; pub use lifecycle::{ - ShutdownPhase, TaskClassification, TaskCompletionExpectation, TaskMetadata, TaskMetadataError, - TaskName, + CancellationToken, ShutdownPhase, TaskClassification, TaskCompletionExpectation, TaskMetadata, + TaskMetadataError, TaskName, }; pub use status::{ CONFIGURATION_SCHEMA_VERSION, CachedServiceState, CachedServiceStatePublisher, diff --git a/crates/service_host/src/lifecycle/cancel.rs b/crates/service_host/src/lifecycle/cancel.rs @@ -0,0 +1,120 @@ +//! Composable cooperative cancellation without process-global signal handling. + +use core::fmt; + +/// Cloneable cooperative cancellation shared by supervisor-owned tasks. +/// +/// Child cancellation propagates to descendants but never back to its parent +/// or sideways to siblings. This type does not install or interpret operating +/// system signals. +#[derive(Clone, Default)] +pub struct CancellationToken { + inner: tokio_util::sync::CancellationToken, +} + +impl CancellationToken { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Creates a child that is cancelled with this token while retaining its own authority. + #[must_use] + pub fn child_token(&self) -> Self { + Self { + inner: self.inner.child_token(), + } + } + + /// Requests cancellation. Repeated requests have no additional effect. + pub fn cancel(&self) { + self.inner.cancel(); + } + + #[must_use] + pub fn is_cancelled(&self) -> bool { + self.inner.is_cancelled() + } + + /// Completes after cancellation and remains immediately ready thereafter. + pub async fn cancelled(&self) { + self.inner.cancelled().await; + } +} + +impl fmt::Debug for CancellationToken { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("CancellationToken") + .field("cancelled", &self.is_cancelled()) + .finish() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn parent_child_propagation_is_directional() { + let parent = CancellationToken::new(); + let child = parent.child_token(); + let grandchild = child.child_token(); + let sibling = parent.child_token(); + + child.cancel(); + child.cancelled().await; + grandchild.cancelled().await; + assert!(!parent.is_cancelled()); + assert!(!sibling.is_cancelled()); + + parent.cancel(); + parent.cancelled().await; + sibling.cancelled().await; + assert!(parent.child_token().is_cancelled()); + } + + #[tokio::test] + async fn cancellation_is_idempotent_and_observable_after_the_fact() { + let token = CancellationToken::new(); + token.cancel(); + token.cancel(); + assert!(token.is_cancelled()); + token.cancelled().await; + assert_eq!( + format!("{token:?}"), + "CancellationToken { cancelled: true }" + ); + } + + #[tokio::test] + async fn dropping_a_waiter_does_not_consume_cancellation() { + let token = CancellationToken::new(); + let abandoned = token.cancelled(); + drop(abandoned); + + token.cancel(); + token.cancelled().await; + assert!(token.is_cancelled()); + } + + #[tokio::test] + async fn concurrent_waiter_registration_and_cancel_has_no_lost_wakeup() { + for round in 0..64 { + let token = CancellationToken::new(); + let waiter_token = token.clone(); + let waiter = tokio::spawn(async move { + if round % 2 == 0 { + tokio::task::yield_now().await; + } + waiter_token.cancelled().await; + waiter_token.is_cancelled() + }); + if round % 2 == 1 { + tokio::task::yield_now().await; + } + token.cancel(); + assert!(waiter.await.unwrap()); + } + } +} diff --git a/crates/service_host/src/lifecycle/mod.rs b/crates/service_host/src/lifecycle/mod.rs @@ -1,7 +1,9 @@ //! Explicit task ownership and service lifecycle mechanics. +mod cancel; mod task; +pub use cancel::CancellationToken; pub use task::{ ShutdownPhase, TaskClassification, TaskCompletionExpectation, TaskMetadata, TaskMetadataError, TaskName, diff --git a/crates/service_host/tests/package_boundary.rs b/crates/service_host/tests/package_boundary.rs @@ -8,6 +8,11 @@ const STATUS_SOURCE: &str = concat!( include_str!("../src/status/reason.rs"), include_str!("../src/status/service.rs"), ); +const LIFECYCLE_SOURCE: &str = concat!( + include_str!("../src/lifecycle/mod.rs"), + include_str!("../src/lifecycle/cancel.rs"), + include_str!("../src/lifecycle/task.rs"), +); #[test] fn service_host_is_unpublished_lint_governed_and_dependency_bounded() { @@ -31,6 +36,7 @@ fn service_host_is_unpublished_lint_governed_and_dependency_bounded() { "serde", "serde_json", "tokio", + "tokio-util", ]) ); assert_eq!( @@ -45,6 +51,9 @@ fn service_host_is_unpublished_lint_governed_and_dependency_bounded() { ]) ); assert!(!STATUS_SOURCE.contains("serde(untagged)")); + for forbidden in ["tokio::signal", "ctrl_c", "signal_hook"] { + assert!(!LIFECYCLE_SOURCE.contains(forbidden)); + } } fn public_modules(root: &str) -> BTreeSet<&str> {