Skip to main content

haz_exec/
exit_code.rs

1//! Exit-code mapping for `EXEC-021`.
2//!
3//! Maps a finished [`crate::run_graph::RunGraphOutcome`] (and an
4//! optional [`CancellationSignal`] recorded by the binary's OS
5//! signal handler) to the numeric exit status the `haz run`
6//! process MUST report.
7//!
8//! Per locked decision D5 of the parent haz-exec plan
9//! ("Pure library. No `process::exit`, stdio writes, or signal
10//! handlers inside haz-exec."), this module computes the number
11//! and nothing else. The actual `process::exit(code)` call lives
12//! in the binary that consumes haz-exec (haz-cli).
13//!
14//! # Spec contract (`EXEC-021`)
15//!
16//! The spec requires four distinguishable classes:
17//!
18//! 1. Success: every requested task reached the succeeded state
19//!    and no runtime invariant (`EXEC-019` cycle, `EXEC-020`
20//!    overlap) was violated. Maps to `0`.
21//! 2. Task failure: at least one task reached the failed state
22//!    under `EXEC-009`, or the lookup-then-spawn pipeline
23//!    surfaced an error, or a runtime invariant was violated.
24//!    Maps to `1` in this implementation. The spec leaves the
25//!    specific number to implementation v1; the class-from-class
26//!    distinguishability is the load-bearing requirement.
27//! 3. Signal interruption: the run was cancelled because an OS
28//!    signal flipped the cancellation token (`EXEC-012`). Maps
29//!    to `128 + signal_number` per POSIX convention (`130` for
30//!    `SIGINT`, `143` for `SIGTERM`).
31//! 4. Workspace-load / internal error: handled by the consuming
32//!    binary (haz-cli), which by definition never invokes the
33//!    scheduler if workspace load fails. This module does NOT
34//!    produce a number for class 4; the binary picks a value
35//!    distinct from those this module returns.
36//!
37//! # Precedence
38//!
39//! Signal interruption wins over task failure. A run where the
40//! user pressed Ctrl+C while a task was failing exits with the
41//! signal code, mirroring POSIX semantics for a process killed
42//! by signal.
43//!
44//! # Cancellation without a recorded signal
45//!
46//! A [`crate::run_task::RunOutcome::Cancelled`] entry without
47//! an accompanying [`CancellationSignal`] argument indicates an
48//! internal cancellation (e.g. the scheduler tripped its own
49//! child token after detecting a runtime cycle per `EXEC-019`).
50//! In that case the runtime-cycle diagnostic lives in
51//! [`crate::run_graph::RunGraphOutcome::invariant_violations`],
52//! which by itself maps to the task-failure class. Treating
53//! bare-Cancelled-without-signal as a third failure source
54//! would double-count the cycle, so the helper does not.
55//!
56//! # Skipped tasks
57//!
58//! [`crate::run_task::RunOutcome::Skipped`] entries are effects,
59//! not causes: a skip records that the cascade prevented a task
60//! from running, never that the task itself failed. The original
61//! failure or cancellation that caused the cascade appears in
62//! the same `outcomes` map (as a `Completed` with state `Failed`
63//! or a `Cancelled` entry), or in `task_errors`, or in
64//! `invariant_violations`. The helper consults those sources
65//! directly; it does not re-count skips.
66
67use crate::run_graph::RunGraphOutcome;
68use crate::run_task::{RunOutcome, RunState};
69
70/// The OS signal that initiated a run-cancellation request, per
71/// `EXEC-012`.
72///
73/// Captured by the consuming binary's signal handler and passed
74/// to [`exit_code_for`] so the resulting exit code reflects POSIX
75/// convention (`128 + signal_number`, per `EXEC-021`).
76///
77/// This type is intentionally distinct from
78/// [`crate::process::Signal`] (which is the EXECUTOR-to-child
79/// signal vocabulary and includes `Kill`). `CancellationSignal`
80/// is INCOMING from the OS to the haz binary; only `SIGINT` and
81/// `SIGTERM` are user-installable cancellation sources
82/// (`SIGKILL` is uncatchable).
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
84pub enum CancellationSignal {
85    /// `SIGINT`, the interactive interrupt (typically Ctrl+C in
86    /// a foreground shell). POSIX signal number 2.
87    Interrupt,
88    /// `SIGTERM`, the polite-termination signal. POSIX signal
89    /// number 15.
90    Terminate,
91}
92
93impl CancellationSignal {
94    /// POSIX signal number for this cancellation signal.
95    ///
96    /// Returns `2` for [`Self::Interrupt`] and `15` for
97    /// [`Self::Terminate`].
98    #[must_use]
99    pub const fn posix_number(self) -> i32 {
100        match self {
101            Self::Interrupt => 2,
102            Self::Terminate => 15,
103        }
104    }
105}
106
107/// Exit code reported when at least one task failed, surfaced a
108/// pipeline error, or a runtime invariant was violated, AND the
109/// run was not cancelled by an OS signal.
110///
111/// The spec (`EXEC-021`) leaves the specific number for the
112/// task-failure class to implementation v1; `1` is the natural
113/// POSIX "something went wrong" sentinel. The class is what is
114/// load-bearing, not the number.
115pub const EXIT_TASK_FAILURE: i32 = 1;
116
117/// Compute the process exit code for a finished
118/// [`RunGraphOutcome`] per `EXEC-021`.
119///
120/// - `signal: Some(_)` ALWAYS wins: returns
121///   `128 + signal.posix_number()` (`130` for SIGINT, `143` for
122///   SIGTERM). The user's cancellation intent dominates any
123///   concurrent task failure (POSIX convention).
124/// - Otherwise, returns [`EXIT_TASK_FAILURE`] when any of the
125///   following holds:
126///     - `outcome.task_errors` is non-empty.
127///     - `outcome.invariant_violations` is non-empty.
128///     - any entry in `outcome.outcomes` is a
129///       [`RunOutcome::Completed`] whose state is
130///       [`RunState::Failed`].
131/// - Otherwise, returns `0`.
132///
133/// See the module-level documentation for the design rationale,
134/// particularly around `Skipped` and `Cancelled` entries not
135/// being independent failure indicators.
136#[must_use]
137pub fn exit_code_for(outcome: &RunGraphOutcome, signal: Option<CancellationSignal>) -> i32 {
138    if let Some(sig) = signal {
139        return 128 + sig.posix_number();
140    }
141    if is_failure(outcome) {
142        return EXIT_TASK_FAILURE;
143    }
144    0
145}
146
147fn is_failure(outcome: &RunGraphOutcome) -> bool {
148    if !outcome.task_errors.is_empty() {
149        return true;
150    }
151    if !outcome.invariant_violations.is_empty() {
152        return true;
153    }
154    outcome.outcomes.values().any(|o| match o {
155        RunOutcome::Completed(rec) => rec.state == RunState::Failed,
156        RunOutcome::Skipped(_) | RunOutcome::Cancelled(_) => false,
157    })
158}
159
160#[cfg(test)]
161mod tests {
162    use std::collections::{BTreeMap, BTreeSet};
163    use std::str::FromStr;
164
165    use haz_domain::name::{ProjectName, TaskName};
166    use haz_domain::task_id::TaskId;
167
168    use crate::exit_code::{CancellationSignal, EXIT_TASK_FAILURE, exit_code_for};
169    use crate::run_graph::{RunGraphOutcome, RuntimeInvariantViolation};
170    use crate::run_task::{CompletedRecord, RunOutcome, RunSource, RunState};
171
172    fn tid(project: &str, task: &str) -> TaskId {
173        TaskId {
174            project: ProjectName::from_str(project).unwrap(),
175            task: TaskName::from_str(task).unwrap(),
176        }
177    }
178
179    fn completed(task: TaskId, state: RunState) -> RunOutcome {
180        RunOutcome::Completed(CompletedRecord {
181            task,
182            source: RunSource::FreshRun,
183            state,
184            exit_status: None,
185            stdout_hash: [0; 32],
186            stderr_hash: [0; 32],
187            materialised_outputs: Vec::new(),
188        })
189    }
190
191    fn empty_outcome() -> RunGraphOutcome {
192        RunGraphOutcome {
193            outcomes: BTreeMap::new(),
194            task_errors: BTreeMap::new(),
195            invariant_violations: Vec::new(),
196        }
197    }
198
199    fn outcome_with_completed(entries: Vec<(TaskId, RunState)>) -> RunGraphOutcome {
200        let mut out = empty_outcome();
201        for (task, state) in entries {
202            out.outcomes.insert(task.clone(), completed(task, state));
203        }
204        out
205    }
206
207    #[test]
208    fn exec_021_empty_outcome_returns_zero() {
209        assert_eq!(exit_code_for(&empty_outcome(), None), 0);
210    }
211
212    #[test]
213    fn exec_021_all_succeeded_returns_zero() {
214        let outcome = outcome_with_completed(vec![
215            (tid("p", "a"), RunState::Succeeded),
216            (tid("p", "b"), RunState::Succeeded),
217        ]);
218        assert_eq!(exit_code_for(&outcome, None), 0);
219    }
220
221    #[test]
222    fn exec_021_failed_task_returns_task_failure_code() {
223        let outcome = outcome_with_completed(vec![
224            (tid("p", "a"), RunState::Succeeded),
225            (tid("p", "b"), RunState::Failed),
226        ]);
227        assert_eq!(exit_code_for(&outcome, None), EXIT_TASK_FAILURE);
228    }
229
230    #[test]
231    fn exec_021_invariant_violation_alone_returns_task_failure_code() {
232        // Per the spec, a runtime cycle keeps each cycle member's
233        // per-task outcome `Succeeded` (the cycle is a run-level
234        // diagnostic); only `invariant_violations` records the
235        // violation. The exit code must still be non-zero.
236        let mut outcome = outcome_with_completed(vec![
237            (tid("lib", "produce"), RunState::Succeeded),
238            (tid("app", "consume"), RunState::Succeeded),
239        ]);
240        outcome
241            .invariant_violations
242            .push(RuntimeInvariantViolation::RuntimeCycle {
243                nodes: BTreeSet::from([tid("lib", "produce"), tid("app", "consume")]),
244                offending_edge: (tid("lib", "produce"), tid("app", "consume")),
245            });
246        assert_eq!(exit_code_for(&outcome, None), EXIT_TASK_FAILURE);
247    }
248
249    #[test]
250    fn exec_021_signal_interrupt_returns_130() {
251        let outcome = empty_outcome();
252        assert_eq!(
253            exit_code_for(&outcome, Some(CancellationSignal::Interrupt)),
254            130
255        );
256    }
257
258    #[test]
259    fn exec_021_signal_terminate_returns_143() {
260        let outcome = empty_outcome();
261        assert_eq!(
262            exit_code_for(&outcome, Some(CancellationSignal::Terminate)),
263            143
264        );
265    }
266
267    #[test]
268    fn exec_021_signal_wins_over_task_failure() {
269        let outcome = outcome_with_completed(vec![(tid("p", "a"), RunState::Failed)]);
270        assert_eq!(
271            exit_code_for(&outcome, Some(CancellationSignal::Interrupt)),
272            130,
273        );
274        assert_eq!(
275            exit_code_for(&outcome, Some(CancellationSignal::Terminate)),
276            143,
277        );
278    }
279
280    #[test]
281    fn exec_021_signal_wins_over_invariant_violation() {
282        let mut outcome = empty_outcome();
283        outcome
284            .invariant_violations
285            .push(RuntimeInvariantViolation::RuntimeCycle {
286                nodes: BTreeSet::from([tid("p", "a"), tid("p", "b")]),
287                offending_edge: (tid("p", "a"), tid("p", "b")),
288            });
289        assert_eq!(
290            exit_code_for(&outcome, Some(CancellationSignal::Interrupt)),
291            130,
292        );
293    }
294
295    #[test]
296    fn cancellation_signal_posix_number_matches_spec() {
297        assert_eq!(CancellationSignal::Interrupt.posix_number(), 2);
298        assert_eq!(CancellationSignal::Terminate.posix_number(), 15);
299    }
300
301    #[test]
302    fn exit_task_failure_constant_is_one() {
303        assert_eq!(EXIT_TASK_FAILURE, 1);
304    }
305}