use std::sync::Arc;
use std::time::Duration;
use thiserror::Error;
use crate::identity::TaskId;
pub type BoxError = Box<dyn std::error::Error + Send + Sync + 'static>;
pub type SharedError = Arc<dyn std::error::Error + Send + Sync + 'static>;
#[non_exhaustive]
#[derive(Error, Debug)]
pub enum RuntimeError {
#[error("shutdown timeout {grace:?} exceeded; stuck: {stuck:?}; forcing termination")]
#[non_exhaustive]
GraceExceeded {
grace: Duration,
stuck: Vec<Arc<str>>,
},
#[error("task name '{name}' already exists")]
#[non_exhaustive]
TaskAlreadyExists {
name: Arc<str>,
},
#[error("management command queue is full")]
CommandQueueFull,
#[error("timeout waiting for task {id} termination after {timeout:?}")]
#[non_exhaustive]
TaskTerminationTimeout {
id: TaskId,
timeout: Duration,
},
#[error("failed to install shutdown signal handlers: {source}")]
#[non_exhaustive]
SignalSetupFailed {
#[source]
source: std::io::Error,
},
#[error("supervisor is shutting down")]
ShuttingDown,
#[error("supervisor run() was already started")]
AlreadyRunning,
}
impl RuntimeError {
#[must_use]
pub fn as_label(&self) -> &'static str {
match self {
RuntimeError::GraceExceeded { .. } => "runtime_grace_exceeded",
RuntimeError::TaskAlreadyExists { .. } => "runtime_task_already_exists",
RuntimeError::CommandQueueFull => "runtime_command_queue_full",
RuntimeError::TaskTerminationTimeout { .. } => "runtime_task_termination_timeout",
RuntimeError::SignalSetupFailed { .. } => "runtime_signal_setup_failed",
RuntimeError::ShuttingDown => "runtime_shutting_down",
RuntimeError::AlreadyRunning => "runtime_already_running",
}
}
}
#[non_exhaustive]
#[derive(Error, Debug)]
pub enum TaskError {
#[error("timed out after {timeout:?}")]
#[non_exhaustive]
Timeout {
timeout: Duration,
},
#[error("fatal error (no retry): {reason}")]
#[non_exhaustive]
Fatal {
reason: String,
exit_code: Option<i32>,
#[source]
source: Option<BoxError>,
},
#[error("execution failed: {reason}")]
#[non_exhaustive]
Fail {
reason: String,
exit_code: Option<i32>,
#[source]
source: Option<BoxError>,
},
#[error("context canceled")]
Canceled,
}
impl TaskError {
#[must_use]
pub const fn timeout(timeout: Duration) -> Self {
TaskError::Timeout { timeout }
}
pub fn fail(reason: impl Into<String>) -> Self {
TaskError::Fail {
reason: reason.into(),
exit_code: None,
source: None,
}
}
pub fn fatal(reason: impl Into<String>) -> Self {
TaskError::Fatal {
reason: reason.into(),
exit_code: None,
source: None,
}
}
pub fn fail_from<E>(source: E) -> Self
where
E: std::error::Error + Send + Sync + 'static,
{
TaskError::Fail {
reason: source.to_string(),
exit_code: None,
source: Some(Box::new(source)),
}
}
pub fn fatal_from<E>(source: E) -> Self
where
E: std::error::Error + Send + Sync + 'static,
{
TaskError::Fatal {
reason: source.to_string(),
exit_code: None,
source: Some(Box::new(source)),
}
}
#[must_use]
pub fn with_exit_code(mut self, code: impl Into<Option<i32>>) -> Self {
let code = code.into();
if let TaskError::Fail { exit_code, .. } | TaskError::Fatal { exit_code, .. } = &mut self {
*exit_code = code;
}
self
}
#[must_use]
pub fn with_source(mut self, source: impl Into<BoxError>) -> Self {
if let TaskError::Fail { source: s, .. } | TaskError::Fatal { source: s, .. } = &mut self {
*s = Some(source.into());
}
self
}
#[must_use]
pub fn into_source(self) -> Option<BoxError> {
match self {
TaskError::Fail { source, .. } | TaskError::Fatal { source, .. } => source,
_ => None,
}
}
#[must_use]
pub fn as_label(&self) -> &'static str {
match self {
TaskError::Timeout { .. } => "task_timeout",
TaskError::Fatal { .. } => "task_fatal",
TaskError::Fail { .. } => "task_failed",
TaskError::Canceled => "task_canceled",
}
}
#[must_use]
pub fn is_retryable(&self) -> bool {
matches!(self, TaskError::Timeout { .. } | TaskError::Fail { .. })
}
#[must_use]
pub fn is_fatal(&self) -> bool {
matches!(self, TaskError::Fatal { .. })
}
#[must_use]
pub fn exit_code(&self) -> Option<i32> {
match self {
TaskError::Fatal { exit_code, .. } | TaskError::Fail { exit_code, .. } => *exit_code,
TaskError::Timeout { .. } | TaskError::Canceled => None,
}
}
}
#[non_exhaustive]
#[derive(Error, Debug)]
pub enum Error {
#[error(transparent)]
Runtime(#[from] RuntimeError),
#[cfg(feature = "controller")]
#[cfg_attr(docsrs, doc(cfg(feature = "controller")))]
#[error(transparent)]
Controller(#[from] crate::controller::ControllerError),
}
impl Error {
#[must_use]
pub fn as_label(&self) -> &'static str {
match self {
Error::Runtime(e) => e.as_label(),
#[cfg(feature = "controller")]
Error::Controller(e) => e.as_label(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn runtime_error_labels_are_stable() {
let id = TaskId::next();
let cases = [
(
RuntimeError::GraceExceeded {
grace: Duration::from_secs(1),
stuck: vec![Arc::from("worker")],
},
"runtime_grace_exceeded",
),
(
RuntimeError::TaskAlreadyExists {
name: Arc::from("worker"),
},
"runtime_task_already_exists",
),
(RuntimeError::CommandQueueFull, "runtime_command_queue_full"),
(
RuntimeError::TaskTerminationTimeout {
id,
timeout: Duration::from_secs(1),
},
"runtime_task_termination_timeout",
),
(
RuntimeError::SignalSetupFailed {
source: std::io::Error::other("boom"),
},
"runtime_signal_setup_failed",
),
(RuntimeError::ShuttingDown, "runtime_shutting_down"),
(RuntimeError::AlreadyRunning, "runtime_already_running"),
];
for (error, expected) in cases {
assert_eq!(error.as_label(), expected, "{error:?}");
}
}
#[test]
fn runtime_error_displays_are_stable() {
assert_eq!(
RuntimeError::CommandQueueFull.to_string(),
"management command queue is full"
);
let id = TaskId::next();
let error = RuntimeError::TaskTerminationTimeout {
id,
timeout: Duration::from_secs(1),
};
assert_eq!(
error.to_string(),
format!("timeout waiting for task {id} termination after 1s")
);
}
#[test]
fn timeout_constructor_is_const_and_preserves_payload_and_display() {
const TIMEOUT: TaskError = TaskError::timeout(Duration::from_secs(1));
assert!(matches!(
&TIMEOUT,
TaskError::Timeout { timeout, .. } if *timeout == Duration::from_secs(1)
));
assert_eq!(TIMEOUT.to_string(), "timed out after 1s");
}
#[test]
fn fail_constructor_preserves_reason_and_is_sourceless() {
let e = TaskError::fail("logical");
assert_eq!(e.to_string(), "execution failed: logical");
assert!(std::error::Error::source(&e).is_none());
}
#[test]
fn fail_from_preserves_source_chain_and_io_kind() {
let io = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
let e = TaskError::fail_from(io);
assert!(e.to_string().contains("denied"));
let src = std::error::Error::source(&e).expect("source must be present");
let io_ref = src
.downcast_ref::<std::io::Error>()
.expect("source must downcast to the original io::Error");
assert_eq!(io_ref.kind(), std::io::ErrorKind::PermissionDenied);
}
#[test]
fn with_exit_code_and_with_source_builders_compose() {
let io = std::io::Error::other("boom");
let e = TaskError::fail("upload failed")
.with_exit_code(13)
.with_source(io);
assert_eq!(e.exit_code(), Some(13));
assert_eq!(e.to_string(), "execution failed: upload failed");
assert!(std::error::Error::source(&e).is_some());
}
#[test]
fn classification_and_exit_codes_cover_every_task_error_variant() {
let dynamic: Option<i32> = None;
let cases = [
(
"fail",
TaskError::fail("x").with_exit_code(7),
"task_failed",
Some(7),
true,
false,
),
(
"fatal",
TaskError::fatal("x").with_exit_code(137),
"task_fatal",
Some(137),
false,
true,
),
(
"optional exit code",
TaskError::fail("y").with_exit_code(dynamic),
"task_failed",
None,
true,
false,
),
(
"timeout",
TaskError::timeout(Duration::from_secs(1)),
"task_timeout",
None,
true,
false,
),
(
"canceled",
TaskError::Canceled,
"task_canceled",
None,
false,
false,
),
];
for (case, error, label, exit_code, retryable, fatal) in cases {
assert_eq!(error.as_label(), label, "{case}");
assert_eq!(error.exit_code(), exit_code, "{case}");
assert_eq!(error.is_retryable(), retryable, "{case}");
assert_eq!(error.is_fatal(), fatal, "{case}");
}
}
#[test]
fn fatal_from_is_fatal_and_carries_source() {
let io = std::io::Error::new(std::io::ErrorKind::NotFound, "missing");
let e = TaskError::fatal_from(io);
assert!(e.is_fatal());
assert!(!e.is_retryable());
let src = std::error::Error::source(&e).expect("source present");
assert_eq!(
src.downcast_ref::<std::io::Error>().unwrap().kind(),
std::io::ErrorKind::NotFound
);
}
#[test]
fn signal_setup_failed_exposes_io_source() {
let io = std::io::Error::new(std::io::ErrorKind::AddrInUse, "in use");
let e = RuntimeError::SignalSetupFailed { source: io };
assert!(e.to_string().contains("in use"));
let src = std::error::Error::source(&e).expect("source present");
assert_eq!(
src.downcast_ref::<std::io::Error>().unwrap().kind(),
std::io::ErrorKind::AddrInUse
);
}
}