use std::sync::Arc;
use tokio::sync::oneshot;
use crate::error::{RuntimeError, SharedError};
use crate::identity::TaskId;
#[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 {
reason: Arc<str>,
},
}
impl TaskOutcome {
#[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(reason: impl Into<Arc<str>>) -> Self {
Self::Rejected {
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 {
match self {
TaskOutcome::Completed => "outcome_completed",
TaskOutcome::Failed { .. } => "outcome_failed",
TaskOutcome::Fatal { .. } => "outcome_fatal",
TaskOutcome::Canceled => "outcome_canceled",
TaskOutcome::ForceAborted => "outcome_force_aborted",
TaskOutcome::Panicked => "outcome_panicked",
TaskOutcome::Rejected { .. } => "outcome_rejected",
}
}
}
#[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)"
)]
#[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("queue_full");
assert!(matches!(
&rejected,
TaskOutcome::Rejected { reason, .. } if reason.as_ref() == "queue_full"
));
assert!(rejected.source().is_none());
}
#[test]
fn labels_and_success_flags_are_stable_for_every_variant() {
let cases = [
(TaskOutcome::Completed, "outcome_completed", true),
(
TaskOutcome::Failed {
reason: Arc::from("x"),
exit_code: None,
source: None,
},
"outcome_failed",
false,
),
(
TaskOutcome::Fatal {
reason: Arc::from("x"),
exit_code: Some(1),
source: None,
},
"outcome_fatal",
false,
),
(TaskOutcome::Canceled, "outcome_canceled", false),
(TaskOutcome::ForceAborted, "outcome_force_aborted", false),
(TaskOutcome::Panicked, "outcome_panicked", false),
(
TaskOutcome::Rejected {
reason: Arc::from("x"),
},
"outcome_rejected",
false,
),
];
let labels: std::collections::HashSet<_> = cases
.iter()
.map(|(outcome, expected_label, expected_success)| {
assert_eq!(outcome.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)
));
}
}