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}