1use std::sync::Arc;
4use std::time::Duration;
5
6use thiserror::Error;
7
8use crate::identity::TaskId;
9
10pub type BoxError = Box<dyn std::error::Error + Send + Sync + 'static>;
12
13pub type SharedError = Arc<dyn std::error::Error + Send + Sync + 'static>;
15
16#[non_exhaustive]
28#[derive(Error, Debug)]
29pub enum RuntimeError {
30 #[error("shutdown timeout {grace:?} exceeded; stuck: {stuck:?}; forcing termination")]
34 GraceExceeded {
35 grace: Duration,
37 stuck: Vec<Arc<str>>,
39 },
40
41 #[error("task '{name}' already exists in registry")]
43 TaskAlreadyExists {
44 name: Arc<str>,
46 },
47
48 #[error("timeout waiting for task {id} removal after {timeout:?}")]
50 TaskRemoveTimeout {
51 id: TaskId,
53 timeout: Duration,
55 },
56
57 #[error("timeout waiting for task '{name}' registration after {timeout:?}")]
59 TaskAddTimeout {
60 name: Arc<str>,
62 timeout: Duration,
64 },
65
66 #[error("failed to install shutdown signal handlers: {source}")]
71 SignalSetupFailed {
72 #[source]
74 source: std::io::Error,
75 },
76
77 #[error("supervisor is shutting down")]
79 ShuttingDown,
80
81 #[error("supervisor run() was already started")]
85 AlreadyRunning,
86}
87
88impl RuntimeError {
89 #[must_use]
93 pub fn as_label(&self) -> &'static str {
94 match self {
95 RuntimeError::GraceExceeded { .. } => "runtime_grace_exceeded",
96 RuntimeError::TaskAlreadyExists { .. } => "runtime_task_already_exists",
97 RuntimeError::TaskRemoveTimeout { .. } => "runtime_task_remove_timeout",
98 RuntimeError::TaskAddTimeout { .. } => "runtime_task_add_timeout",
99 RuntimeError::SignalSetupFailed { .. } => "runtime_signal_setup_failed",
100 RuntimeError::ShuttingDown => "runtime_shutting_down",
101 RuntimeError::AlreadyRunning => "runtime_already_running",
102 }
103 }
104}
105
106#[non_exhaustive]
123#[derive(Error, Debug)]
124pub enum TaskError {
125 #[error("timed out after {timeout:?}")]
127 Timeout {
128 timeout: Duration,
130 },
131
132 #[error("fatal error (no retry): {reason}")]
136 #[non_exhaustive]
137 Fatal {
138 reason: String,
140 exit_code: Option<i32>,
144 #[source]
146 source: Option<BoxError>,
147 },
148
149 #[error("execution failed: {reason}")]
153 #[non_exhaustive]
154 Fail {
155 reason: String,
157 exit_code: Option<i32>,
161 #[source]
163 source: Option<BoxError>,
164 },
165
166 #[error("context canceled")]
171 Canceled,
172}
173
174impl TaskError {
175 pub fn fail(reason: impl Into<String>) -> Self {
177 TaskError::Fail {
178 reason: reason.into(),
179 exit_code: None,
180 source: None,
181 }
182 }
183
184 pub fn fatal(reason: impl Into<String>) -> Self {
186 TaskError::Fatal {
187 reason: reason.into(),
188 exit_code: None,
189 source: None,
190 }
191 }
192
193 pub fn fail_from<E>(source: E) -> Self
198 where
199 E: std::error::Error + Send + Sync + 'static,
200 {
201 TaskError::Fail {
202 reason: source.to_string(),
203 exit_code: None,
204 source: Some(Box::new(source)),
205 }
206 }
207
208 pub fn fatal_from<E>(source: E) -> Self
213 where
214 E: std::error::Error + Send + Sync + 'static,
215 {
216 TaskError::Fatal {
217 reason: source.to_string(),
218 exit_code: None,
219 source: Some(Box::new(source)),
220 }
221 }
222
223 #[must_use]
227 pub fn with_exit_code(mut self, code: impl Into<Option<i32>>) -> Self {
228 let code = code.into();
229 if let TaskError::Fail { exit_code, .. } | TaskError::Fatal { exit_code, .. } = &mut self {
230 *exit_code = code;
231 }
232 self
233 }
234
235 #[must_use]
239 pub fn with_source(mut self, source: impl Into<BoxError>) -> Self {
240 if let TaskError::Fail { source: s, .. } | TaskError::Fatal { source: s, .. } = &mut self {
241 *s = Some(source.into());
242 }
243 self
244 }
245
246 #[must_use]
248 pub fn into_source(self) -> Option<BoxError> {
249 match self {
250 TaskError::Fail { source, .. } | TaskError::Fatal { source, .. } => source,
251 _ => None,
252 }
253 }
254
255 #[must_use]
259 pub fn as_label(&self) -> &'static str {
260 match self {
261 TaskError::Timeout { .. } => "task_timeout",
262 TaskError::Fatal { .. } => "task_fatal",
263 TaskError::Fail { .. } => "task_failed",
264 TaskError::Canceled => "task_canceled",
265 }
266 }
267
268 #[must_use]
270 pub fn is_retryable(&self) -> bool {
271 matches!(self, TaskError::Timeout { .. } | TaskError::Fail { .. })
272 }
273
274 #[must_use]
276 pub fn is_fatal(&self) -> bool {
277 matches!(self, TaskError::Fatal { .. })
278 }
279
280 #[must_use]
282 pub fn exit_code(&self) -> Option<i32> {
283 match self {
284 TaskError::Fatal { exit_code, .. } | TaskError::Fail { exit_code, .. } => *exit_code,
285 TaskError::Timeout { .. } | TaskError::Canceled => None,
286 }
287 }
288}
289
290#[non_exhaustive]
309#[derive(Error, Debug)]
310pub enum Error {
311 #[error(transparent)]
313 Runtime(#[from] RuntimeError),
314
315 #[cfg(feature = "controller")]
319 #[error(transparent)]
320 Controller(#[from] crate::controller::ControllerError),
321}
322
323impl Error {
324 #[must_use]
328 pub fn as_label(&self) -> &'static str {
329 match self {
330 Error::Runtime(e) => e.as_label(),
331 #[cfg(feature = "controller")]
332 Error::Controller(e) => e.as_label(),
333 }
334 }
335}
336
337#[cfg(test)]
338mod tests {
339 use super::*;
340
341 #[test]
342 fn exit_code_is_some_for_fail_with_code() {
343 let e = TaskError::fail("x").with_exit_code(5);
344 assert_eq!(e.exit_code(), Some(5));
345 assert!(e.is_retryable());
346 assert!(!e.is_fatal());
347 }
348
349 #[test]
350 fn exit_code_is_some_for_fatal_with_code() {
351 let e = TaskError::fatal("x").with_exit_code(137);
352 assert_eq!(e.exit_code(), Some(137));
353 assert!(!e.is_retryable());
354 assert!(e.is_fatal());
355 }
356
357 #[test]
358 fn exit_code_is_none_for_logical_fail() {
359 let e = TaskError::fail("logical");
360 assert_eq!(e.exit_code(), None);
361 }
362
363 #[test]
364 fn exit_code_is_none_for_timeout_and_canceled() {
365 assert_eq!(
366 TaskError::Timeout {
367 timeout: Duration::from_secs(1),
368 }
369 .exit_code(),
370 None,
371 );
372 assert_eq!(TaskError::Canceled.exit_code(), None);
373 }
374
375 #[test]
376 fn display_still_renders_reason_only() {
377 let e = TaskError::fail("boom").with_exit_code(1);
378 assert_eq!(e.to_string(), "execution failed: boom");
379 }
380
381 #[test]
382 fn fail_constructor_is_retryable_and_sourceless() {
383 let e = TaskError::fail("logical");
384 assert_eq!(e.to_string(), "execution failed: logical");
385 assert!(e.is_retryable());
386 assert_eq!(e.exit_code(), None);
387 assert!(std::error::Error::source(&e).is_none());
388 }
389
390 #[test]
391 fn fail_from_preserves_source_chain_and_io_kind() {
392 let io = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
393 let e = TaskError::fail_from(io);
394
395 assert!(e.to_string().contains("denied"));
396 let src = std::error::Error::source(&e).expect("source must be present");
397 let io_ref = src
398 .downcast_ref::<std::io::Error>()
399 .expect("source must downcast to the original io::Error");
400 assert_eq!(io_ref.kind(), std::io::ErrorKind::PermissionDenied);
401 }
402
403 #[test]
404 fn with_exit_code_and_with_source_builders_compose() {
405 let io = std::io::Error::other("boom");
406 let e = TaskError::fail("upload failed")
407 .with_exit_code(13)
408 .with_source(io);
409
410 assert_eq!(e.exit_code(), Some(13));
411 assert_eq!(e.to_string(), "execution failed: upload failed");
412 assert!(std::error::Error::source(&e).is_some());
413 }
414
415 #[test]
416 fn with_exit_code_accepts_bare_and_option() {
417 assert_eq!(TaskError::fail("x").with_exit_code(7).exit_code(), Some(7));
418
419 let dynamic: Option<i32> = None;
420 assert_eq!(
421 TaskError::fail("y").with_exit_code(dynamic).exit_code(),
422 None
423 );
424 }
425
426 #[test]
427 fn fatal_from_is_fatal_and_carries_source() {
428 let io = std::io::Error::new(std::io::ErrorKind::NotFound, "missing");
429 let e = TaskError::fatal_from(io);
430
431 assert!(e.is_fatal());
432 assert!(!e.is_retryable());
433
434 let src = std::error::Error::source(&e).expect("source present");
435 assert_eq!(
436 src.downcast_ref::<std::io::Error>().unwrap().kind(),
437 std::io::ErrorKind::NotFound
438 );
439 }
440
441 #[test]
442 fn signal_setup_failed_exposes_io_source() {
443 let io = std::io::Error::new(std::io::ErrorKind::AddrInUse, "in use");
444 let e = RuntimeError::SignalSetupFailed { source: io };
445
446 assert!(e.to_string().contains("in use"));
447
448 let src = std::error::Error::source(&e).expect("source present");
449 assert_eq!(
450 src.downcast_ref::<std::io::Error>().unwrap().kind(),
451 std::io::ErrorKind::AddrInUse
452 );
453 }
454}