Skip to main content

lean_ctx/shell/
exit_status.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Exit-status mapping shared by every path that runs a child command (#1881).
3//!
4//! `ExitStatus::code()` is `None` when the child was terminated by a signal.
5//! Mapping that to `1` made a SIGTERM'd or SIGKILL'd command look like an
6//! ordinary failure — or, for grep-style commands with output, like success.
7//! Shells report such a child as `128 + signal`, and so do we.
8
9use std::process::ExitStatus;
10
11/// The shell-convention exit code of a finished child: its exit code, or
12/// `128 + signal` when a signal terminated it (130 SIGINT, 137 SIGKILL,
13/// 143 SIGTERM, …).
14pub fn exit_code(status: ExitStatus) -> i32 {
15    #[cfg(unix)]
16    {
17        use std::os::unix::process::ExitStatusExt;
18        if let Some(sig) = status.signal() {
19            return 128 + sig;
20        }
21    }
22    status.code().unwrap_or(1)
23}
24
25/// Name of the signal a `128 + n` exit code conventionally stands for.
26pub(crate) fn signal_name(code: i32) -> Option<&'static str> {
27    #[cfg(unix)]
28    {
29        let sig = code.checked_sub(128).filter(|s| *s > 0)?;
30        let name = match sig {
31            libc::SIGHUP => "SIGHUP",
32            libc::SIGINT => "SIGINT",
33            libc::SIGQUIT => "SIGQUIT",
34            libc::SIGILL => "SIGILL",
35            libc::SIGTRAP => "SIGTRAP",
36            libc::SIGABRT => "SIGABRT",
37            libc::SIGBUS => "SIGBUS",
38            libc::SIGFPE => "SIGFPE",
39            libc::SIGKILL => "SIGKILL",
40            libc::SIGUSR1 => "SIGUSR1",
41            libc::SIGSEGV => "SIGSEGV",
42            libc::SIGUSR2 => "SIGUSR2",
43            libc::SIGPIPE => "SIGPIPE",
44            libc::SIGALRM => "SIGALRM",
45            libc::SIGTERM => "SIGTERM",
46            libc::SIGXCPU => "SIGXCPU",
47            libc::SIGXFSZ => "SIGXFSZ",
48            _ => return None,
49        };
50        Some(name)
51    }
52    #[cfg(not(unix))]
53    {
54        let _ = code;
55        None
56    }
57}
58
59/// The `[exit:N]` marker appended to a failed command's output, without a
60/// leading newline. `None` on success. Timeouts (124, #815) and signal
61/// terminations are spelled out so an agent does not mistake them for an
62/// ordinary failure.
63pub fn exit_marker(code: i32) -> Option<String> {
64    match code {
65        0 => None,
66        124 => Some("[exit:124 — command timed out]".to_string()),
67        _ => Some(match signal_name(code) {
68            Some(name) => format!("[exit:{code} — {name}]"),
69            None => format!("[exit:{code}]"),
70        }),
71    }
72}
73
74/// [`exit_marker`] as a footer: empty on success, otherwise the marker on its
75/// own line.
76pub fn exit_footer(code: i32) -> String {
77    exit_marker(code).map_or_else(String::new, |m| format!("\n{m}"))
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    #[test]
85    fn footer_for_success_is_empty() {
86        assert_eq!(exit_footer(0), "");
87        assert_eq!(exit_marker(0), None);
88    }
89
90    #[test]
91    fn footer_keeps_plain_and_timeout_formats() {
92        assert_eq!(exit_footer(1), "\n[exit:1]");
93        assert_eq!(exit_footer(2), "\n[exit:2]");
94        assert_eq!(exit_footer(124), "\n[exit:124 — command timed out]");
95        assert_eq!(exit_footer(128), "\n[exit:128]");
96    }
97
98    #[cfg(unix)]
99    #[test]
100    fn footer_names_signal_terminations() {
101        assert_eq!(exit_footer(130), "\n[exit:130 — SIGINT]");
102        assert_eq!(exit_footer(137), "\n[exit:137 — SIGKILL]");
103        assert_eq!(exit_footer(143), "\n[exit:143 — SIGTERM]");
104        assert_eq!(exit_marker(141).as_deref(), Some("[exit:141 — SIGPIPE]"));
105    }
106
107    #[cfg(unix)]
108    fn run_sh(script: &str) -> ExitStatus {
109        std::process::Command::new("sh")
110            .arg("-c")
111            .arg(script)
112            .status()
113            .expect("spawn sh")
114    }
115
116    #[cfg(unix)]
117    #[test]
118    fn exit_code_passes_normal_codes_through() {
119        assert_eq!(exit_code(run_sh("exit 0")), 0);
120        assert_eq!(exit_code(run_sh("exit 3")), 3);
121    }
122
123    /// #1881: a child terminated by a signal reports 128 + signal, not 1.
124    #[cfg(unix)]
125    #[test]
126    fn exit_code_maps_signal_termination_to_128_plus_signal() {
127        assert_eq!(exit_code(run_sh("kill -TERM $$")), 143);
128        assert_eq!(exit_code(run_sh("kill -KILL $$")), 137);
129    }
130
131    #[cfg(unix)]
132    #[test]
133    fn exit_code_from_raw_wait_status() {
134        use std::os::unix::process::ExitStatusExt;
135        // Raw wait(2) encoding: low 7 bits = terminating signal.
136        assert_eq!(exit_code(ExitStatus::from_raw(libc::SIGTERM)), 143);
137        // Normal exit: code in bits 8..16.
138        assert_eq!(exit_code(ExitStatus::from_raw(2 << 8)), 2);
139    }
140}