use std::sync::Arc;
use tokio::sync::oneshot;
use crate::error::{RuntimeError, SharedError};
use crate::events::RejectionKind;
use crate::identity::TaskId;
#[non_exhaustive]
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum TaskOutcomeKind {
Completed,
Failed,
Fatal,
Canceled,
ForceAborted,
Panicked,
Rejected,
}
impl TaskOutcomeKind {
#[must_use]
pub const fn as_label(self) -> &'static str {
match self {
Self::Completed => "outcome_completed",
Self::Failed => "outcome_failed",
Self::Fatal => "outcome_fatal",
Self::Canceled => "outcome_canceled",
Self::ForceAborted => "outcome_force_aborted",
Self::Panicked => "outcome_panicked",
Self::Rejected => "outcome_rejected",
}
}
}
#[cfg_attr(
feature = "controller",
doc = "- [`SupervisorHandle::submit_and_watch`](crate::SupervisorHandle::submit_and_watch) and [`SupervisorHandle::try_submit_and_watch`](crate::SupervisorHandle::try_submit_and_watch) - controller watched submission"
)]
#[non_exhaustive]
#[derive(Debug, Clone)]
pub enum TaskOutcome {
Completed,
#[non_exhaustive]
Failed {
reason: Arc<str>,
exit_code: Option<i32>,
source: Option<SharedError>,
},
#[non_exhaustive]
Fatal {
reason: Arc<str>,
exit_code: Option<i32>,
source: Option<SharedError>,
},
Canceled,
ForceAborted,
Panicked,
#[non_exhaustive]
Rejected {
kind: RejectionKind,
reason: Arc<str>,
},
}
impl TaskOutcome {
#[must_use]
pub const fn kind(&self) -> TaskOutcomeKind {
match self {
TaskOutcome::Completed => TaskOutcomeKind::Completed,
TaskOutcome::Failed { .. } => TaskOutcomeKind::Failed,
TaskOutcome::Fatal { .. } => TaskOutcomeKind::Fatal,
TaskOutcome::Canceled => TaskOutcomeKind::Canceled,
TaskOutcome::ForceAborted => TaskOutcomeKind::ForceAborted,
TaskOutcome::Panicked => TaskOutcomeKind::Panicked,
TaskOutcome::Rejected { .. } => TaskOutcomeKind::Rejected,
}
}
#[must_use]
pub fn is_success(&self) -> bool {
matches!(self, TaskOutcome::Completed)
}
#[cfg(feature = "test-util")]
#[cfg_attr(docsrs, doc(cfg(feature = "test-util")))]
#[must_use]
pub fn failed_for_tests(reason: impl Into<Arc<str>>, exit_code: Option<i32>) -> Self {
Self::Failed {
reason: reason.into(),
exit_code,
source: None,
}
}
#[cfg(feature = "test-util")]
#[cfg_attr(docsrs, doc(cfg(feature = "test-util")))]
#[must_use]
pub fn fatal_for_tests(reason: impl Into<Arc<str>>, exit_code: Option<i32>) -> Self {
Self::Fatal {
reason: reason.into(),
exit_code,
source: None,
}
}
#[cfg(feature = "test-util")]
#[cfg_attr(docsrs, doc(cfg(feature = "test-util")))]
#[must_use]
pub fn rejected_for_tests(kind: RejectionKind, reason: impl Into<Arc<str>>) -> Self {
Self::Rejected {
kind,
reason: reason.into(),
}
}
#[must_use]
pub fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
TaskOutcome::Failed { source, .. } | TaskOutcome::Fatal { source, .. } => {
source.as_ref().map(|e| {
let e: &(dyn std::error::Error + 'static) = e.as_ref();
e
})
}
_ => None,
}
}
#[must_use]
pub fn as_label(&self) -> &'static str {
self.kind().as_label()
}
}
#[cfg_attr(
feature = "controller",
doc = "- [`SupervisorHandle::submit_and_watch`](crate::SupervisorHandle::submit_and_watch)\n- [`SupervisorHandle::try_submit_and_watch`](crate::SupervisorHandle::try_submit_and_watch)\n- [`PreparedSubmission::submit_and_watch`](crate::PreparedSubmission::submit_and_watch)\n- [`PreparedSubmission::try_submit_and_watch`](crate::PreparedSubmission::try_submit_and_watch)"
)]
#[derive(Debug)]
#[must_use = "a TaskWaiter does nothing unless awaited via `.wait()`"]
pub struct TaskWaiter {
id: TaskId,
rx: oneshot::Receiver<TaskOutcome>,
}
impl TaskWaiter {
pub(crate) fn new(id: TaskId, rx: oneshot::Receiver<TaskOutcome>) -> Self {
Self { id, rx }
}
#[must_use]
pub fn id(&self) -> TaskId {
self.id
}
pub async fn wait(self) -> Result<TaskOutcome, RuntimeError> {
self.rx.await.map_err(|_| RuntimeError::ShuttingDown)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "test-util")]
#[test]
fn test_constructors_build_the_terminal_failure_and_rejection_variants() {
let failed = TaskOutcome::failed_for_tests("boom", Some(3));
assert!(matches!(
&failed,
TaskOutcome::Failed { reason, exit_code: Some(3), .. } if reason.as_ref() == "boom"
));
assert!(failed.source().is_none(), "test outcomes carry no source");
let fatal = TaskOutcome::fatal_for_tests("bad config", None);
assert!(matches!(
&fatal,
TaskOutcome::Fatal { reason, exit_code: None, .. } if reason.as_ref() == "bad config"
));
let rejected = TaskOutcome::rejected_for_tests(
RejectionKind::QueueFull,
"slot queue reached capacity",
);
assert!(matches!(
&rejected,
TaskOutcome::Rejected { kind: RejectionKind::QueueFull, reason, .. }
if reason.as_ref() == "slot queue reached capacity"
));
assert!(rejected.source().is_none());
}
#[test]
fn labels_and_success_flags_are_stable_for_every_variant() {
let cases = [
(
TaskOutcome::Completed,
TaskOutcomeKind::Completed,
"outcome_completed",
true,
),
(
TaskOutcome::Failed {
reason: Arc::from("x"),
exit_code: None,
source: None,
},
TaskOutcomeKind::Failed,
"outcome_failed",
false,
),
(
TaskOutcome::Fatal {
reason: Arc::from("x"),
exit_code: Some(1),
source: None,
},
TaskOutcomeKind::Fatal,
"outcome_fatal",
false,
),
(
TaskOutcome::Canceled,
TaskOutcomeKind::Canceled,
"outcome_canceled",
false,
),
(
TaskOutcome::ForceAborted,
TaskOutcomeKind::ForceAborted,
"outcome_force_aborted",
false,
),
(
TaskOutcome::Panicked,
TaskOutcomeKind::Panicked,
"outcome_panicked",
false,
),
(
TaskOutcome::Rejected {
kind: RejectionKind::AdmissionFailed,
reason: Arc::from("x"),
},
TaskOutcomeKind::Rejected,
"outcome_rejected",
false,
),
];
let labels: std::collections::HashSet<_> = cases
.iter()
.map(
|(outcome, expected_kind, expected_label, expected_success)| {
assert_eq!(outcome.kind(), *expected_kind);
assert_eq!(outcome.as_label(), *expected_label);
assert_eq!(expected_kind.as_label(), *expected_label);
assert_eq!(outcome.is_success(), *expected_success, "{expected_label}");
outcome.as_label()
},
)
.collect();
assert_eq!(labels.len(), cases.len(), "labels must remain distinct");
}
#[test]
fn failed_outcome_exposes_downcastable_source() {
let io = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
let outcome = TaskOutcome::Failed {
reason: Arc::from("denied"),
exit_code: None,
source: Some(Arc::new(io)),
};
let src = outcome
.source()
.expect("a Failed outcome with a cause must expose its source");
assert_eq!(
src.downcast_ref::<std::io::Error>().unwrap().kind(),
std::io::ErrorKind::PermissionDenied
);
}
#[test]
fn sourceless_outcomes_report_no_source() {
assert!(TaskOutcome::Completed.source().is_none());
assert!(
TaskOutcome::Failed {
reason: Arc::from("plain"),
exit_code: Some(1),
source: None,
}
.source()
.is_none()
);
}
#[tokio::test]
async fn waiter_resolves_sent_outcome_and_maps_a_dropped_sender() {
let (tx, rx) = oneshot::channel();
let waiter = TaskWaiter::new(TaskId::next(), rx);
tx.send(TaskOutcome::Completed).unwrap();
assert!(matches!(
waiter.wait().await.unwrap(),
TaskOutcome::Completed
));
let (tx, rx) = oneshot::channel::<TaskOutcome>();
let waiter = TaskWaiter::new(TaskId::next(), rx);
drop(tx);
assert!(matches!(
waiter.wait().await,
Err(RuntimeError::ShuttingDown)
));
}
}