Skip to content

Commit 645ce1a

Browse files
committed
Merge branch 'worktree-issue-2615' of github.com:moonrepo/moon into worktree-issue-2615
* 'worktree-issue-2615' of github.com:moonrepo/moon: fix: Propagate a failing task's exit code instead of collapsing it to 1
2 parents 3c27ccb + 660de59 commit 645ce1a

3 files changed

Lines changed: 106 additions & 0 deletions

File tree

crates/app/src/helpers.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,39 @@ use moon_action_pipeline::ActionPipeline;
1010
use moon_common::Id;
1111
use moon_console::ui::{OwnedOrShared, Progress, ProgressDisplay, ProgressReporter};
1212
use moon_console::{Console, ConsoleError, Level};
13+
use moon_process::ProcessError;
1314
use serde::Serialize;
1415
use starbase_utils::{fs, json, toml, yaml};
1516
use std::ops::Deref;
1617
use std::path::{Path, PathBuf};
1718
use std::sync::Arc;
1819

20+
/// Walk an error chain and return the underlying process exit code, if the
21+
/// failure originated from a task/process that exited with a non-zero status.
22+
/// Signals, aborts, and codes that can't be represented as a `u8` yield `None`,
23+
/// so the caller can fall back to a generic exit code. This lets the pipeline's
24+
/// bubbled-up error propagate the task's real exit code, which `starbase` would
25+
/// otherwise collapse to 1.
26+
pub fn extract_task_exit_code(report: &miette::Report) -> Option<u8> {
27+
report
28+
.chain()
29+
.find_map(|error| {
30+
// The process error is usually boxed within its parent (e.g.
31+
// `TaskRunnerError` wraps `Box<ProcessError>`), which surfaces as a
32+
// `Box<ProcessError>` in the chain, so check both shapes.
33+
error
34+
.downcast_ref::<ProcessError>()
35+
.or_else(|| {
36+
error
37+
.downcast_ref::<Box<ProcessError>>()
38+
.map(|boxed| boxed.as_ref())
39+
})
40+
.and_then(ProcessError::get_exit_code)
41+
})
42+
.and_then(|code| u8::try_from(code).ok())
43+
.filter(|code| *code != 0)
44+
}
45+
1946
pub fn serialize_config_based_on_extension(
2047
plugin_id: &Id,
2148
path: &Path,

crates/app/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,5 @@ pub mod watchers;
1313

1414
pub use app::*;
1515
pub use app_error::*;
16+
pub use helpers::extract_task_exit_code;
1617
pub use session::*;

crates/app/tests/helpers_test.rs

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
use miette::Diagnostic;
2+
use moon_app::extract_task_exit_code;
3+
use moon_process::ProcessError;
4+
use thiserror::Error;
5+
6+
// Mirrors how `TaskRunnerError::RunFailed` wraps `Box<ProcessError>`, which
7+
// surfaces the process error as a `Box<ProcessError>` in the error chain.
8+
#[derive(Debug, Diagnostic, Error)]
9+
#[error("task failed to run")]
10+
struct WrappedError {
11+
#[source]
12+
error: Box<ProcessError>,
13+
}
14+
15+
fn exit_nonzero(code: Option<i32>) -> ProcessError {
16+
ProcessError::ExitNonZero {
17+
bin: "bash".into(),
18+
status: "exit code".into(),
19+
code,
20+
}
21+
}
22+
23+
fn wrapped(code: Option<i32>) -> miette::Report {
24+
WrappedError {
25+
error: Box::new(exit_nonzero(code)),
26+
}
27+
.into()
28+
}
29+
30+
mod extract_task_exit_code {
31+
use super::*;
32+
33+
#[test]
34+
fn extracts_from_boxed_process_error() {
35+
assert_eq!(extract_task_exit_code(&wrapped(Some(80))), Some(80));
36+
}
37+
38+
#[test]
39+
fn extracts_from_bare_process_error() {
40+
let report: miette::Report = exit_nonzero(Some(2)).into();
41+
42+
assert_eq!(extract_task_exit_code(&report), Some(2));
43+
}
44+
45+
#[test]
46+
fn propagates_codes_above_127() {
47+
assert_eq!(extract_task_exit_code(&wrapped(Some(200))), Some(200));
48+
}
49+
50+
#[test]
51+
fn none_for_signal_without_code() {
52+
assert_eq!(extract_task_exit_code(&wrapped(None)), None);
53+
}
54+
55+
#[test]
56+
fn none_for_injected_abort_code() {
57+
// Aborts before a process spawns are recorded with an exit code of -1
58+
assert_eq!(extract_task_exit_code(&wrapped(Some(-1))), None);
59+
}
60+
61+
#[test]
62+
fn none_for_zero_code() {
63+
assert_eq!(extract_task_exit_code(&wrapped(Some(0))), None);
64+
}
65+
66+
#[test]
67+
fn none_for_out_of_u8_range() {
68+
// e.g. large Windows exit codes that can't map to a u8
69+
assert_eq!(extract_task_exit_code(&wrapped(Some(300))), None);
70+
}
71+
72+
#[test]
73+
fn none_for_non_process_error() {
74+
let report = miette::miette!("something else went wrong");
75+
76+
assert_eq!(extract_task_exit_code(&report), None);
77+
}
78+
}

0 commit comments

Comments
 (0)