lib

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

commit a5d132a3213b40c44f1ee9ec62955f2b6199f77a
parent 5127c14466bd29b09d2c3d2dd2ed7f33c23e516b
Author: triesap <tyson@radroots.org>
Date:   Tue, 11 Aug 2026 01:36:12 +0000

service-host: add task supervision metadata

- validate bounded stable task identities
- classify critical optional and one-shot lifetimes
- bind completion and failure semantics explicitly
- freeze the exact ordered shutdown phase inventory

Diffstat:
Mcrates/service_host/src/lib.rs | 5+++++
Acrates/service_host/src/lifecycle/mod.rs | 8++++++++
Acrates/service_host/src/lifecycle/task.rs | 276+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/service_host/tests/package_boundary.rs | 9++++++++-
4 files changed, 297 insertions(+), 1 deletion(-)

diff --git a/crates/service_host/src/lib.rs b/crates/service_host/src/lib.rs @@ -5,6 +5,7 @@ pub mod build_info; pub mod entropy; pub mod error; +pub mod lifecycle; pub mod status; pub mod time; @@ -14,6 +15,10 @@ 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, +}; pub use status::{ CONFIGURATION_SCHEMA_VERSION, CachedServiceState, CachedServiceStatePublisher, CachedServiceStateReader, CommonReasonCode, ConfigurationIdentity, ConfigurationSource, diff --git a/crates/service_host/src/lifecycle/mod.rs b/crates/service_host/src/lifecycle/mod.rs @@ -0,0 +1,8 @@ +//! Explicit task ownership and service lifecycle mechanics. + +mod task; + +pub use task::{ + ShutdownPhase, TaskClassification, TaskCompletionExpectation, TaskMetadata, TaskMetadataError, + TaskName, +}; diff --git a/crates/service_host/src/lifecycle/task.rs b/crates/service_host/src/lifecycle/task.rs @@ -0,0 +1,276 @@ +//! Validated metadata for every supervisor-owned task. + +use core::fmt; + +use serde::Serialize; + +pub const TASK_NAME_MAX_BYTES: usize = 64; + +/// A stable, non-secret identifier for one supervised task. +#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(transparent)] +pub struct TaskName(String); + +impl TaskName { + pub fn new(value: impl Into<String>) -> Result<Self, TaskMetadataError> { + let value = value.into(); + if !valid_task_name(&value) { + return Err(TaskMetadataError::InvalidTaskName); + } + Ok(Self(value)) + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for TaskName { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +/// Service impact and lifetime class for a supervised task. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TaskClassification { + /// A long-lived authoritative task whose failure or early success fails the service. + Critical, + /// A bounded optional capability whose failure is observable but not service-fatal. + Optional, + /// A finite authoritative operation whose successful completion is expected. + OneShot, +} + +impl TaskClassification { + #[must_use] + pub const fn completion_expectation(self) -> TaskCompletionExpectation { + match self { + Self::Critical => TaskCompletionExpectation::AfterCancellation, + Self::Optional => TaskCompletionExpectation::MayComplete, + Self::OneShot => TaskCompletionExpectation::CompletesOnce, + } + } + + /// Returns whether a returned error or panic fails the service. + #[must_use] + pub const fn failure_is_fatal(self) -> bool { + matches!(self, Self::Critical | Self::OneShot) + } + + #[must_use] + pub const fn requires_shutdown_phase(self) -> bool { + !matches!(self, Self::OneShot) + } +} + +/// When successful completion is valid for a task class. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TaskCompletionExpectation { + AfterCancellation, + MayComplete, + CompletesOnce, +} + +/// Ordered shutdown phase assigned to each long-lived task. +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ShutdownPhase { + RejectNewMutations, + CancelIngress, + DrainOperations, + PersistRecoverableWork, + CloseNetwork, + CloseSqlite, + CloseSockets, +} + +/// Complete static identity and lifecycle ownership for one task. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct TaskMetadata { + name: TaskName, + classification: TaskClassification, + shutdown_phase: Option<ShutdownPhase>, +} + +impl TaskMetadata { + pub fn new( + name: TaskName, + classification: TaskClassification, + shutdown_phase: Option<ShutdownPhase>, + ) -> Result<Self, TaskMetadataError> { + if classification.requires_shutdown_phase() != shutdown_phase.is_some() { + return Err(TaskMetadataError::InvalidShutdownPhaseAssignment); + } + Ok(Self { + name, + classification, + shutdown_phase, + }) + } + + #[must_use] + pub const fn name(&self) -> &TaskName { + &self.name + } + + #[must_use] + pub const fn classification(&self) -> TaskClassification { + self.classification + } + + #[must_use] + pub const fn shutdown_phase(&self) -> Option<ShutdownPhase> { + self.shutdown_phase + } +} + +/// Validation failure for static supervisor metadata. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TaskMetadataError { + InvalidTaskName, + InvalidShutdownPhaseAssignment, +} + +impl fmt::Display for TaskMetadataError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::InvalidTaskName => "supervised task name is invalid", + Self::InvalidShutdownPhaseAssignment => { + "supervised task shutdown phase does not match its classification" + } + }) + } +} + +impl std::error::Error for TaskMetadataError {} + +fn valid_task_name(value: &str) -> bool { + let mut bytes = value.bytes(); + let Some(first) = bytes.next() else { + return false; + }; + value.len() <= TASK_NAME_MAX_BYTES + && first.is_ascii_lowercase() + && bytes.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn task_names_are_bounded_stable_and_secret_safe() { + for valid in ["a", "admin_listener", "outbox_worker_01"] { + assert_eq!(TaskName::new(valid).unwrap().as_str(), valid); + } + assert!(TaskName::new("a".repeat(TASK_NAME_MAX_BYTES)).is_ok()); + for invalid in [ + "", + "AdminListener", + "1_worker", + "admin-listener", + "admin.listener", + "admin listener", + "worker/secret", + "café", + ] { + assert_eq!( + TaskName::new(invalid), + Err(TaskMetadataError::InvalidTaskName) + ); + } + assert!(TaskName::new("a".repeat(TASK_NAME_MAX_BYTES + 1)).is_err()); + } + + #[test] + fn classification_semantics_are_exhaustive() { + let expected = [ + ( + TaskClassification::Critical, + TaskCompletionExpectation::AfterCancellation, + true, + true, + ), + ( + TaskClassification::Optional, + TaskCompletionExpectation::MayComplete, + false, + true, + ), + ( + TaskClassification::OneShot, + TaskCompletionExpectation::CompletesOnce, + true, + false, + ), + ]; + for (classification, completion, fatal_failure, requires_shutdown) in expected { + assert_eq!(classification.completion_expectation(), completion); + assert_eq!(classification.failure_is_fatal(), fatal_failure); + assert_eq!(classification.requires_shutdown_phase(), requires_shutdown); + } + } + + #[test] + fn shutdown_phase_assignment_matches_task_lifetime() { + let name = || TaskName::new("relay_subscription").unwrap(); + assert!( + TaskMetadata::new( + name(), + TaskClassification::Critical, + Some(ShutdownPhase::CancelIngress), + ) + .is_ok() + ); + assert_eq!( + TaskMetadata::new(name(), TaskClassification::Optional, None), + Err(TaskMetadataError::InvalidShutdownPhaseAssignment) + ); + assert_eq!( + TaskMetadata::new( + name(), + TaskClassification::OneShot, + Some(ShutdownPhase::CancelIngress), + ), + Err(TaskMetadataError::InvalidShutdownPhaseAssignment) + ); + assert!(TaskMetadata::new(name(), TaskClassification::OneShot, None).is_ok()); + } + + #[test] + fn serde_debug_and_shutdown_order_are_stable() { + let phases = [ + ShutdownPhase::RejectNewMutations, + ShutdownPhase::CancelIngress, + ShutdownPhase::DrainOperations, + ShutdownPhase::PersistRecoverableWork, + ShutdownPhase::CloseNetwork, + ShutdownPhase::CloseSqlite, + ShutdownPhase::CloseSockets, + ]; + assert!(phases.windows(2).all(|pair| pair[0] < pair[1])); + assert_eq!( + serde_json::to_string(&phases).unwrap(), + r#"["reject_new_mutations","cancel_ingress","drain_operations","persist_recoverable_work","close_network","close_sqlite","close_sockets"]"# + ); + + let metadata = TaskMetadata::new( + TaskName::new("admin_listener").unwrap(), + TaskClassification::Critical, + Some(ShutdownPhase::CloseSockets), + ) + .unwrap(); + assert_eq!( + serde_json::to_string(&metadata).unwrap(), + r#"{"name":"admin_listener","classification":"critical","shutdown_phase":"close_sockets"}"# + ); + assert_eq!( + format!("{metadata:?}"), + "TaskMetadata { name: TaskName(\"admin_listener\"), classification: Critical, shutdown_phase: Some(CloseSockets) }" + ); + } +} diff --git a/crates/service_host/tests/package_boundary.rs b/crates/service_host/tests/package_boundary.rs @@ -35,7 +35,14 @@ fn service_host_is_unpublished_lint_governed_and_dependency_bounded() { ); assert_eq!( public_modules(ROOT), - BTreeSet::from(["build_info", "entropy", "error", "status", "time"]) + BTreeSet::from([ + "build_info", + "entropy", + "error", + "lifecycle", + "status", + "time", + ]) ); assert!(!STATUS_SOURCE.contains("serde(untagged)")); }