lib

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

commit 262aa11acf7a37341e9f71111e73e52ca442d628
parent 234824a8aa2d4ccd7c7d158d48afefa5c6dab89b
Author: triesap <tyson@radroots.org>
Date:   Tue, 11 Aug 2026 01:56:29 +0000

service-host: supervise critical tasks

- own every spawned task through its observed join
- propagate critical errors panics and early completion
- expose optional and fatal outcomes as they occur
- cancel peers and drain all cooperative joins on failure

Diffstat:
Mcrates/service_host/Cargo.toml | 2+-
Mcrates/service_host/src/lib.rs | 5+++--
Mcrates/service_host/src/lifecycle/mod.rs | 5+++++
Acrates/service_host/src/lifecycle/supervisor.rs | 570+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/service_host/tests/package_boundary.rs | 1+
5 files changed, 580 insertions(+), 3 deletions(-)

diff --git a/crates/service_host/Cargo.toml b/crates/service_host/Cargo.toml @@ -16,7 +16,7 @@ getrandom = { workspace = true } radroots_runtime_paths = { workspace = true } serde = { workspace = true, features = ["derive", "std"] } serde_json = { workspace = true, features = ["std"] } -tokio = { workspace = true, features = ["sync"] } +tokio = { workspace = true, features = ["rt", "sync"] } tokio-util = { workspace = true } [dev-dependencies] diff --git a/crates/service_host/src/lib.rs b/crates/service_host/src/lib.rs @@ -16,8 +16,9 @@ pub use build_info::{ pub use entropy::{EntropyError, EntropySource, SystemEntropy}; pub use error::{HostError, HostErrorCode, HostErrorKind, SafeHostError}; pub use lifecycle::{ - CancellationToken, ShutdownPhase, TaskClassification, TaskCompletionExpectation, TaskMetadata, - TaskMetadataError, TaskName, + CancellationToken, ShutdownPhase, SupervisedTaskExit, SupervisedTaskExitStatus, + SupervisionFailure, SupervisionFailureKind, TaskClassification, TaskCompletionExpectation, + TaskMetadata, TaskMetadataError, TaskName, TaskRegistrationError, TaskSupervisor, }; pub use status::{ CONFIGURATION_SCHEMA_VERSION, CachedServiceState, CachedServiceStatePublisher, diff --git a/crates/service_host/src/lifecycle/mod.rs b/crates/service_host/src/lifecycle/mod.rs @@ -1,9 +1,14 @@ //! Explicit task ownership and service lifecycle mechanics. mod cancel; +mod supervisor; mod task; pub use cancel::CancellationToken; +pub use supervisor::{ + SupervisedTaskExit, SupervisedTaskExitStatus, SupervisionFailure, SupervisionFailureKind, + TaskRegistrationError, TaskSupervisor, +}; pub use task::{ ShutdownPhase, TaskClassification, TaskCompletionExpectation, TaskMetadata, TaskMetadataError, TaskName, diff --git a/crates/service_host/src/lifecycle/supervisor.rs b/crates/service_host/src/lifecycle/supervisor.rs @@ -0,0 +1,570 @@ +//! Join-owned task supervision with typed completion policy. + +use core::{fmt, future::Future}; +use std::{collections::HashMap, error::Error}; + +use tokio::task::{Id, JoinError, JoinSet}; + +use crate::HostError; + +use super::{CancellationToken, TaskClassification, TaskMetadata}; + +/// Owns every spawned service task until its join result is observed. +#[must_use = "a task supervisor must be run or drained so authoritative tasks are joined"] +pub struct TaskSupervisor { + cancellation: CancellationToken, + tasks: JoinSet<TaskCompletion>, + metadata: HashMap<Id, TaskMetadata>, +} + +impl TaskSupervisor { + pub fn new() -> Self { + Self { + cancellation: CancellationToken::new(), + tasks: JoinSet::new(), + metadata: HashMap::new(), + } + } + + /// Returns a cloneable observer and cancellation authority for composition boundaries. + #[must_use] + pub fn cancellation_token(&self) -> CancellationToken { + self.cancellation.clone() + } + + /// Requests cooperative cancellation of every task child token. + pub fn request_cancellation(&self) { + self.cancellation.cancel(); + } + + #[must_use] + pub fn task_count(&self) -> usize { + self.tasks.len() + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.tasks.is_empty() + } + + /// Registers and spawns one task on the current runtime without exposing a detachable handle. + pub fn spawn<F, Fut>( + &mut self, + metadata: TaskMetadata, + task: F, + ) -> Result<(), TaskRegistrationError> + where + F: FnOnce(CancellationToken) -> Fut + Send + 'static, + Fut: Future<Output = Result<(), HostError>> + Send + 'static, + { + if self + .metadata + .values() + .any(|active| active.name() == metadata.name()) + { + return Err(TaskRegistrationError::DuplicateName); + } + let runtime = + tokio::runtime::Handle::try_current().map_err(|_| TaskRegistrationError::NoRuntime)?; + let task_metadata = metadata.clone(); + let child = self.cancellation.child_token(); + let completion_observer = child.clone(); + let abort = self.tasks.spawn_on( + async move { + let result = task(child).await; + TaskCompletion { + metadata: task_metadata, + cancelled_at_return: completion_observer.is_cancelled(), + result, + } + }, + &runtime, + ); + self.metadata.insert(abort.id(), metadata); + Ok(()) + } + + /// Observes all task exits, cancels peers on the first fatal outcome, and drains every join. + pub async fn supervise(&mut self) -> Result<Vec<SupervisedTaskExit>, SupervisionFailure> { + let mut exits = Vec::with_capacity(self.tasks.len()); + let mut first_failure = None; + while let Some(outcome) = self.join_next().await { + match outcome { + Ok(exit) => exits.push(exit), + Err(failure) => { + if first_failure.is_none() { + first_failure = Some(failure); + } + } + } + } + if let Some(failure) = first_failure { + Err(failure) + } else { + Ok(exits) + } + } + + /// Returns the next classified task outcome as soon as it is joined. + /// + /// Optional failures are returned without cancelling peers. A fatal + /// outcome requests cancellation before it is returned. Cancelling this + /// wait does not remove a task or lose its later outcome. + pub async fn join_next(&mut self) -> Option<Result<SupervisedTaskExit, SupervisionFailure>> { + let joined = self.tasks.join_next_with_id().await?; + let outcome = self.classify_join(joined); + if outcome.is_err() { + self.cancellation.cancel(); + } + Some(outcome) + } + + fn classify_join( + &mut self, + joined: Result<(Id, TaskCompletion), JoinError>, + ) -> Result<SupervisedTaskExit, SupervisionFailure> { + match joined { + Ok((id, completion)) => { + self.metadata.remove(&id); + classify_completion(completion) + } + Err(error) => { + let metadata = self.metadata.remove(&error.id()); + let cancelled_during_shutdown = + error.is_cancelled() && self.cancellation.is_cancelled(); + if cancelled_during_shutdown && let Some(metadata) = metadata { + return Ok(SupervisedTaskExit::expected(metadata)); + } + let kind = if error.is_panic() { + SupervisionFailureKind::TaskPanicked + } else if error.is_cancelled() { + SupervisionFailureKind::UnexpectedCancellation + } else { + SupervisionFailureKind::JoinFailed + }; + match metadata { + Some(metadata) if !metadata.classification().failure_is_fatal() => Ok( + SupervisedTaskExit::optional_failure(metadata, Box::new(error)), + ), + metadata => Err(SupervisionFailure::new( + metadata, + kind, + Some(Box::new(error)), + )), + } + } + } + } +} + +impl Default for TaskSupervisor { + fn default() -> Self { + Self::new() + } +} + +fn classify_completion( + completion: TaskCompletion, +) -> Result<SupervisedTaskExit, SupervisionFailure> { + match completion.result { + Ok(()) + if completion.metadata.classification() == TaskClassification::Critical + && !completion.cancelled_at_return => + { + Err(SupervisionFailure::new( + Some(completion.metadata), + SupervisionFailureKind::UnexpectedCompletion, + None, + )) + } + Ok(()) => Ok(SupervisedTaskExit::expected(completion.metadata)), + Err(error) if completion.metadata.classification().failure_is_fatal() => { + Err(SupervisionFailure::new( + Some(completion.metadata), + SupervisionFailureKind::TaskReturnedError, + Some(Box::new(error)), + )) + } + Err(error) => Ok(SupervisedTaskExit::optional_failure( + completion.metadata, + Box::new(error), + )), + } +} + +struct TaskCompletion { + metadata: TaskMetadata, + cancelled_at_return: bool, + result: Result<(), HostError>, +} + +/// Nonfatal observed task completion retained for metrics and status consumers. +pub struct SupervisedTaskExit { + metadata: TaskMetadata, + status: SupervisedTaskExitStatus, + source: Option<Box<dyn Error + Send + Sync + 'static>>, +} + +impl SupervisedTaskExit { + fn expected(metadata: TaskMetadata) -> Self { + Self { + metadata, + status: SupervisedTaskExitStatus::ExpectedCompletion, + source: None, + } + } + + fn optional_failure( + metadata: TaskMetadata, + source: Box<dyn Error + Send + Sync + 'static>, + ) -> Self { + Self { + metadata, + status: SupervisedTaskExitStatus::OptionalFailure, + source: Some(source), + } + } + + #[must_use] + pub const fn metadata(&self) -> &TaskMetadata { + &self.metadata + } + + #[must_use] + pub const fn status(&self) -> SupervisedTaskExitStatus { + self.status + } + + /// Returns the trusted internal cause for an optional failure. + #[must_use] + pub fn source(&self) -> Option<&(dyn Error + Send + Sync + 'static)> { + self.source.as_deref() + } +} + +impl fmt::Debug for SupervisedTaskExit { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("SupervisedTaskExit") + .field("metadata", &self.metadata) + .field("status", &self.status) + .field("source", &self.source.as_ref().map(|_| "<redacted>")) + .finish() + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SupervisedTaskExitStatus { + ExpectedCompletion, + OptionalFailure, +} + +/// Fatal supervisor outcome after every remaining task has been joined. +pub struct SupervisionFailure { + metadata: Option<TaskMetadata>, + kind: SupervisionFailureKind, + source: Option<Box<dyn Error + Send + Sync + 'static>>, +} + +impl SupervisionFailure { + fn new( + metadata: Option<TaskMetadata>, + kind: SupervisionFailureKind, + source: Option<Box<dyn Error + Send + Sync + 'static>>, + ) -> Self { + Self { + metadata, + kind, + source, + } + } + + #[must_use] + pub const fn metadata(&self) -> Option<&TaskMetadata> { + self.metadata.as_ref() + } + + #[must_use] + pub const fn kind(&self) -> SupervisionFailureKind { + self.kind + } +} + +impl fmt::Debug for SupervisionFailure { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("SupervisionFailure") + .field("metadata", &self.metadata) + .field("kind", &self.kind) + .field("source", &self.source.as_ref().map(|_| "<redacted>")) + .finish() + } +} + +impl fmt::Display for SupervisionFailure { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("authoritative service task supervision failed") + } +} + +impl Error for SupervisionFailure { + fn source(&self) -> Option<&(dyn Error + 'static)> { + self.source + .as_deref() + .map(|source| source as &(dyn Error + 'static)) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SupervisionFailureKind { + TaskReturnedError, + TaskPanicked, + UnexpectedCompletion, + UnexpectedCancellation, + JoinFailed, +} + +/// Failure to register a task without spawning it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TaskRegistrationError { + DuplicateName, + NoRuntime, +} + +impl fmt::Display for TaskRegistrationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::DuplicateName => "supervised task name is already active", + Self::NoRuntime => "supervised task registration requires an active runtime", + }) + } +} + +impl Error for TaskRegistrationError {} + +#[cfg(test)] +mod tests { + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + + use crate::{HostErrorKind, ShutdownPhase, TaskClassification, TaskName}; + + use super::*; + + fn metadata(name: &str, classification: TaskClassification) -> TaskMetadata { + let shutdown_phase = classification + .requires_shutdown_phase() + .then_some(ShutdownPhase::CancelIngress); + TaskMetadata::new(TaskName::new(name).unwrap(), classification, shutdown_phase).unwrap() + } + + #[test] + fn registration_without_a_runtime_fails_before_spawning() { + let mut supervisor = TaskSupervisor::new(); + assert_eq!( + supervisor.spawn( + metadata("critical_worker", TaskClassification::Critical), + |_| async { Ok(()) }, + ), + Err(TaskRegistrationError::NoRuntime) + ); + assert!(supervisor.is_empty()); + } + + #[tokio::test] + async fn critical_error_cancels_peers_propagates_and_drains_all_joins() { + let drained = Arc::new(AtomicUsize::new(0)); + let mut supervisor = TaskSupervisor::new(); + let peer_drained = Arc::clone(&drained); + supervisor + .spawn( + metadata("peer_worker", TaskClassification::Critical), + move |token| async move { + token.cancelled().await; + peer_drained.fetch_add(1, Ordering::SeqCst); + Ok(()) + }, + ) + .unwrap(); + supervisor + .spawn( + metadata("failing_worker", TaskClassification::Critical), + |_| async { Err(HostError::new(HostErrorKind::TaskFailure)) }, + ) + .unwrap(); + + let error = supervisor.supervise().await.unwrap_err(); + assert_eq!(error.kind(), SupervisionFailureKind::TaskReturnedError); + assert_eq!(error.metadata().unwrap().name().as_str(), "failing_worker"); + assert!(error.source().is_some()); + assert_eq!(drained.load(Ordering::SeqCst), 1); + assert!(supervisor.is_empty()); + assert!(supervisor.cancellation_token().is_cancelled()); + } + + #[tokio::test] + async fn critical_panic_and_early_success_are_fatal() { + for (name, expected, panic_task) in [ + ("panic_worker", SupervisionFailureKind::TaskPanicked, true), + ( + "early_worker", + SupervisionFailureKind::UnexpectedCompletion, + false, + ), + ] { + let mut supervisor = TaskSupervisor::new(); + supervisor + .spawn( + metadata(name, TaskClassification::Critical), + move |_| async move { + assert!(!panic_task, "sensitive panic payload"); + Ok(()) + }, + ) + .unwrap(); + let error = supervisor.supervise().await.unwrap_err(); + assert_eq!(error.kind(), expected); + assert!(!error.to_string().contains("sensitive")); + assert!(supervisor.is_empty()); + } + } + + #[tokio::test] + async fn optional_error_and_one_shot_success_are_observed_without_failure() { + let mut supervisor = TaskSupervisor::new(); + supervisor + .spawn( + metadata("optional_worker", TaskClassification::Optional), + |_| async { Err(HostError::new(HostErrorKind::TaskFailure)) }, + ) + .unwrap(); + supervisor + .spawn( + metadata("startup_once", TaskClassification::OneShot), + |_| async { Ok(()) }, + ) + .unwrap(); + + let mut exits = supervisor.supervise().await.unwrap(); + exits.sort_by(|left, right| left.metadata().name().cmp(right.metadata().name())); + assert_eq!(exits.len(), 2); + assert_eq!(exits[0].status(), SupervisedTaskExitStatus::OptionalFailure); + assert!(exits[0].source().is_some()); + assert_eq!( + exits[1].status(), + SupervisedTaskExitStatus::ExpectedCompletion + ); + assert!(supervisor.is_empty()); + } + + #[tokio::test] + async fn optional_failure_is_observable_while_a_critical_peer_is_alive() { + let mut supervisor = TaskSupervisor::new(); + supervisor + .spawn( + metadata("critical_worker", TaskClassification::Critical), + |token| async move { + token.cancelled().await; + Ok(()) + }, + ) + .unwrap(); + supervisor + .spawn( + metadata("optional_worker", TaskClassification::Optional), + |_| async { Err(HostError::new(HostErrorKind::TaskFailure)) }, + ) + .unwrap(); + + let exit = supervisor.join_next().await.unwrap().unwrap(); + assert_eq!(exit.metadata().name().as_str(), "optional_worker"); + assert_eq!(exit.status(), SupervisedTaskExitStatus::OptionalFailure); + assert_eq!(supervisor.task_count(), 1); + assert!(!supervisor.cancellation_token().is_cancelled()); + + supervisor.request_cancellation(); + assert!(supervisor.join_next().await.unwrap().is_ok()); + assert!(supervisor.is_empty()); + } + + #[tokio::test] + async fn optional_failure_remains_observed_before_a_later_fatal_exit() { + let (release_fatal, wait_for_release) = tokio::sync::oneshot::channel(); + let mut supervisor = TaskSupervisor::new(); + supervisor + .spawn( + metadata("fatal_worker", TaskClassification::Critical), + |_| async move { + let _ = wait_for_release.await; + Err(HostError::new(HostErrorKind::TaskFailure)) + }, + ) + .unwrap(); + supervisor + .spawn( + metadata("optional_worker", TaskClassification::Optional), + |_| async { Err(HostError::new(HostErrorKind::TaskFailure)) }, + ) + .unwrap(); + + let optional = supervisor.join_next().await.unwrap().unwrap(); + assert_eq!(optional.metadata().name().as_str(), "optional_worker"); + assert_eq!(optional.status(), SupervisedTaskExitStatus::OptionalFailure); + release_fatal.send(()).unwrap(); + + let fatal = supervisor.join_next().await.unwrap().unwrap_err(); + assert_eq!(fatal.kind(), SupervisionFailureKind::TaskReturnedError); + assert_eq!(fatal.metadata().unwrap().name().as_str(), "fatal_worker"); + assert!(supervisor.cancellation_token().is_cancelled()); + assert!(supervisor.is_empty()); + } + + #[tokio::test] + async fn externally_cancelled_critical_task_may_complete_successfully() { + let mut supervisor = TaskSupervisor::new(); + supervisor + .spawn( + metadata("critical_worker", TaskClassification::Critical), + |token| async move { + token.cancelled().await; + Ok(()) + }, + ) + .unwrap(); + supervisor.request_cancellation(); + + let exits = supervisor.supervise().await.unwrap(); + assert_eq!(exits.len(), 1); + assert_eq!( + exits[0].status(), + SupervisedTaskExitStatus::ExpectedCompletion + ); + assert!(supervisor.is_empty()); + } + + #[tokio::test] + async fn duplicate_active_names_are_rejected_without_detaching_work() { + let mut supervisor = TaskSupervisor::new(); + supervisor + .spawn( + metadata("same_worker", TaskClassification::Critical), + |token| async move { + token.cancelled().await; + Ok(()) + }, + ) + .unwrap(); + assert_eq!( + supervisor.spawn( + metadata("same_worker", TaskClassification::Critical), + |_| async { Ok(()) }, + ), + Err(TaskRegistrationError::DuplicateName) + ); + assert_eq!(supervisor.task_count(), 1); + supervisor.request_cancellation(); + assert!(supervisor.supervise().await.is_ok()); + } +} diff --git a/crates/service_host/tests/package_boundary.rs b/crates/service_host/tests/package_boundary.rs @@ -11,6 +11,7 @@ const STATUS_SOURCE: &str = concat!( const LIFECYCLE_SOURCE: &str = concat!( include_str!("../src/lifecycle/mod.rs"), include_str!("../src/lifecycle/cancel.rs"), + include_str!("../src/lifecycle/supervisor.rs"), include_str!("../src/lifecycle/task.rs"), );