Skip to main content

start_command/
exit_reason.rs

1//! Exit reason hints for finished executions.
2//!
3//! A numeric exit code alone is frequently misleading: a Bun/Node process that
4//! exhausts its heap aborts itself, so the container reports `exitCode 139`
5//! with `OOMKilled false` even though the run died of memory exhaustion (issue
6//! #162, related to #144, #148 and #151). `start` already reads the tail of the
7//! log to find the terminal footer, so the same tail is scanned for well-known
8//! fatal markers and the finding is surfaced as an extra `exitReason` field.
9//!
10//! The same scan answers a narrower question for consumers that key off
11//! `oomKilled`: was this run killed by memory exhaustion at all? That answer is
12//! surfaced as `memoryExhausted` plus `memoryExhaustedReason`, the log line that
13//! carried the evidence (issue #165).
14//!
15//! Both fields are hints, never verdicts: they never change `status`,
16//! `exitCode` or `oomKilled`.
17
18use regex::Regex;
19
20/// Fatal markers, most specific first. The first matching entry wins.
21const EXIT_REASON_MARKERS: [(&str, &str); 5] = [
22    (
23        "memory-exhaustion (v8-heap-limit)",
24        r"(?i)FATAL ERROR:[^\r\n]*(?:Reached heap limit|JavaScript heap out of memory)",
25    ),
26    (
27        "memory-exhaustion (v8-heap-limit)",
28        r"(?i)JavaScript heap out of memory",
29    ),
30    (
31        "memory-exhaustion (kernel-oom-killer)",
32        r"(?i)Out of memory: Kill(?:ed)? process|oom-kill(?:er)?[: ]|Killed process \d+",
33    ),
34    (
35        "memory-exhaustion (go-runtime)",
36        r"(?i)fatal error: runtime: out of memory",
37    ),
38    (
39        "memory-exhaustion (allocation-failure)",
40        r"(?i)std::bad_alloc|Cannot allocate memory|memory allocation of \d+ bytes failed|Allocation failed - process out of memory|Array buffer allocation failed",
41    ),
42];
43
44/// Exit codes above 128 encode the signal that killed the process.
45/// Only the signals that actually show up in command logs are named.
46///
47/// Public because the detached completion watcher generates a POSIX `case`
48/// from this very table, so the signal the watcher writes into the log and the
49/// one `--status` reports can never disagree (issue #171.4).
50pub const SIGNAL_NAMES: [(i32, &str); 10] = [
51    (1, "SIGHUP"),
52    (2, "SIGINT"),
53    (3, "SIGQUIT"),
54    (4, "SIGILL"),
55    (6, "SIGABRT"),
56    (8, "SIGFPE"),
57    (9, "SIGKILL"),
58    (11, "SIGSEGV"),
59    (13, "SIGPIPE"),
60    (15, "SIGTERM"),
61];
62
63/// Reasons carrying this prefix report memory exhaustion, whatever the
64/// mechanism (runtime self-abort, kernel OOM killer, failed allocation).
65const MEMORY_EXHAUSTION_PREFIX: &str = "memory-exhaustion";
66
67/// A matched marker line longer than this is truncated: the evidence travels in
68/// `--status` output, and some runtimes print very long single-line traces.
69const MAX_MARKER_LINE_LENGTH: usize = 300;
70
71/// A fatal marker found in the log: its category and the line that carried it.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct ExitReasonMarker {
74    pub reason: String,
75    pub line: String,
76}
77
78/// The memory-exhaustion observation derived from a log tail (issue #165).
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct MemoryExhaustion {
81    pub memory_exhausted: bool,
82    pub memory_exhausted_reason: String,
83}
84
85/// Extract the whole (trimmed, length-bounded) line containing byte `index`.
86fn extract_line_at(text: &str, index: usize) -> String {
87    let start = text[..index].rfind('\n').map(|i| i + 1).unwrap_or(0);
88    let end = text[index..]
89        .find('\n')
90        .map(|i| index + i)
91        .unwrap_or(text.len());
92    let line = text[start..end].trim();
93    if line.chars().count() <= MAX_MARKER_LINE_LENGTH {
94        return line.to_string();
95    }
96    let truncated: String = line.chars().take(MAX_MARKER_LINE_LENGTH).collect();
97    format!("{}...", truncated)
98}
99
100/// Scan log text for the first accepted fatal marker, most specific first.
101pub fn find_exit_reason_marker(
102    text: Option<&str>,
103    accept: impl Fn(&str) -> bool,
104) -> Option<ExitReasonMarker> {
105    let text = text?;
106    if text.is_empty() {
107        return None;
108    }
109    for (reason, pattern) in EXIT_REASON_MARKERS {
110        if !accept(reason) {
111            continue;
112        }
113        if let Ok(re) = Regex::new(pattern) {
114            if let Some(found) = re.find(text) {
115                return Some(ExitReasonMarker {
116                    reason: reason.to_string(),
117                    line: extract_line_at(text, found.start()),
118                });
119            }
120        }
121    }
122    None
123}
124
125/// Scan log text for a known fatal marker.
126pub fn detect_exit_reason(text: Option<&str>) -> Option<String> {
127    find_exit_reason_marker(text, |_| true).map(|marker| marker.reason)
128}
129
130/// Scan log text for a marker that reports memory exhaustion specifically.
131pub fn detect_memory_marker(text: Option<&str>) -> Option<ExitReasonMarker> {
132    find_exit_reason_marker(text, |reason| reason.starts_with(MEMORY_EXHAUSTION_PREFIX))
133}
134
135/// Decide whether an execution died of memory exhaustion, and say why.
136///
137/// Only abnormal exits are explained: a command that merely printed a fatal
138/// marker (an `rg` dump, a quoted incident log) and then succeeded is not a
139/// memory failure. An observation, never a verdict (issue #151).
140///
141/// The container-wide OOM signals (`State.OOMKilled`, the cgroup `oom_kill`
142/// counter) only count when the exit fits an OOM kill of the command itself:
143/// a child OOM-killed under a command that later exits 1 is not the reason the
144/// command ended (issue #180).
145pub fn resolve_memory_exhaustion(
146    exit_code: Option<i32>,
147    log_tail: Option<&str>,
148    oom_killed: Option<bool>,
149    oom_kills: Option<u64>,
150) -> Option<MemoryExhaustion> {
151    match exit_code {
152        Some(code) if code != 0 => {}
153        _ => return None,
154    }
155    let (main_oom, daemon_restart) = crate::exit_evidence::from_log(log_tail);
156    if daemon_restart {
157        return None;
158    }
159    if let Some(marker) = detect_memory_marker(log_tail)
160        .filter(|m| !m.reason.contains("kernel-oom-killer") || main_oom)
161    {
162        return Some(MemoryExhaustion {
163            memory_exhausted: true,
164            memory_exhausted_reason: marker.line,
165        });
166    }
167    let main_oom = main_oom && exit_code.is_some_and(|code| code == 137 || code < 0);
168    if oom_killed == Some(true) && main_oom {
169        return Some(MemoryExhaustion {
170            memory_exhausted: true,
171            memory_exhausted_reason: "Docker reported State.OOMKilled=true".to_string(),
172        });
173    }
174    let kills = oom_kills.unwrap_or(0);
175    if kills > 0 && main_oom {
176        return Some(MemoryExhaustion {
177            memory_exhausted: true,
178            memory_exhausted_reason: format!("cgroup memory.events reported oom_kill={}", kills),
179        });
180    }
181    if main_oom {
182        return Some(MemoryExhaustion {
183            memory_exhausted: true,
184            memory_exhausted_reason: "Exit-time evidence reported a main-process OOM kill".into(),
185        });
186    }
187    None
188}
189
190/// Reason reported when the container-wide OOM observation explains the exit.
191pub const CGROUP_OOM_EXIT_REASON: &str = "memory-exhaustion (cgroup-oom-killer)";
192
193/// Can a container-wide OOM observation be blamed on the command itself?
194///
195/// Docker's `State.OOMKilled` and the cgroup `oom_kill` counter are
196/// container-wide and sticky (moby/moby#43564): they turn on as soon as *any*
197/// process in the container is OOM-killed, e.g. one `rustc` child under
198/// `cargo test`, while the command keeps running and later exits on its own
199/// (issue #180). The OOM killer always sends SIGKILL, so the observation only
200/// explains the command's exit when that exit is 137 (`128 + SIGKILL`) or there
201/// is no usable exit code at all (none, or the `-1` of an unknown exit). This
202/// is the rule `is_killed_exit` already applies to `--on-kill-resume` (#178).
203pub fn is_oom_kill_of_command(
204    exit_code: Option<i32>,
205    oom_killed: Option<bool>,
206    oom_kills: Option<u64>,
207) -> bool {
208    // Without attempt-scoped evidence this legacy signature cannot attribute OOM.
209    let _ = (exit_code, oom_killed, oom_kills);
210    false
211}
212
213/// Text used wherever a fact could not be observed at all.
214pub const UNKNOWN_EXIT_CODE: &str = "unknown";
215
216/// A decoded exit code: the numeric value, the signal it encodes (if any), and
217/// the human-readable rendering both the container post-mortem and `--status`
218/// print.
219#[derive(Debug, Clone, PartialEq, Eq)]
220pub struct ExitCodeDescription {
221    pub code: Option<i32>,
222    pub signal: Option<String>,
223    pub text: String,
224}
225
226/// Decode an exit code once, for every caller (issue #171.4).
227///
228/// `128 + n` was previously decoded in the status formatter and described in
229/// prose by the watcher, which is how the same `137` could be reported as
230/// `SIGKILL` in one place and as a bare number in the other.
231pub fn describe_exit_code(exit_code: Option<i32>) -> ExitCodeDescription {
232    let code = match exit_code {
233        Some(code) => code,
234        None => {
235            return ExitCodeDescription {
236                code: None,
237                signal: None,
238                text: UNKNOWN_EXIT_CODE.to_string(),
239            }
240        }
241    };
242    let signal = signal_name_for_exit_code(Some(code));
243    let text = match &signal {
244        Some(name) => format!("{} ({} - 128+{})", code, name, code - 128),
245        None => code.to_string(),
246    };
247    ExitCodeDescription {
248        code: Some(code),
249        signal,
250        text,
251    }
252}
253
254/// Decode an exit code that arrived as text (e.g. from the watcher's shell).
255pub fn describe_exit_code_str(value: &str) -> ExitCodeDescription {
256    describe_exit_code(value.trim().parse::<i32>().ok())
257}
258
259/// Map a shell exit code to the signal name it encodes.
260pub fn signal_name_for_exit_code(exit_code: Option<i32>) -> Option<String> {
261    let code = exit_code?;
262    if code <= 128 || code > 128 + 64 {
263        return None;
264    }
265    SIGNAL_NAMES
266        .iter()
267        .find(|(signal, _)| *signal == code - 128)
268        .map(|(_, name)| name.to_string())
269}
270
271/// Resolve the best available hint for why an execution ended.
272///
273/// Precedence: the log marker (evidence written by the command itself), then
274/// the cgroup OOM observation when the exit fits an OOM kill of the command
275/// (issue #180), then the signal encoded in the exit code.
276pub fn resolve_exit_reason(
277    exit_code: Option<i32>,
278    log_tail: Option<&str>,
279    oom_killed: Option<bool>,
280    oom_kills: Option<u64>,
281) -> Option<String> {
282    let (main_oom, daemon_restart) = crate::exit_evidence::from_log(log_tail);
283    if daemon_restart {
284        return Some("killed (docker daemon restart)".into());
285    }
286    if let Some(reason) =
287        detect_exit_reason(log_tail).filter(|r| !r.contains("kernel-oom-killer") || main_oom)
288    {
289        return Some(reason);
290    }
291
292    let _ = (oom_killed, oom_kills);
293    if main_oom && exit_code.is_none_or(|code| code < 0 || code == 137) {
294        return Some(CGROUP_OOM_EXIT_REASON.to_string());
295    }
296
297    signal_name_for_exit_code(exit_code).map(|name| {
298        format!(
299            "signal ({}{})",
300            name,
301            if name == "SIGKILL" {
302                "; cause unknown"
303            } else {
304                ""
305            }
306        )
307    })
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    #[test]
315    fn detects_v8_heap_limit_from_the_incident_log() {
316        let tail =
317            "FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory";
318        assert_eq!(
319            detect_exit_reason(Some(tail)),
320            Some("memory-exhaustion (v8-heap-limit)".to_string())
321        );
322    }
323
324    #[test]
325    fn detects_kernel_oom_killer() {
326        assert_eq!(
327            detect_exit_reason(Some("Out of memory: Killed process 1234 (bun)")),
328            Some("memory-exhaustion (kernel-oom-killer)".to_string())
329        );
330    }
331
332    #[test]
333    fn detects_other_runtimes_reporting_their_own_exhaustion() {
334        assert_eq!(
335            detect_exit_reason(Some("fatal error: runtime: out of memory")),
336            Some("memory-exhaustion (go-runtime)".to_string())
337        );
338        assert_eq!(
339            detect_exit_reason(Some("Array buffer allocation failed")),
340            Some("memory-exhaustion (allocation-failure)".to_string())
341        );
342    }
343
344    #[test]
345    fn returns_none_for_ordinary_output() {
346        assert_eq!(detect_exit_reason(Some("all tests passed")), None);
347        assert_eq!(detect_exit_reason(None), None);
348    }
349
350    #[test]
351    fn maps_signal_exit_codes() {
352        assert_eq!(signal_name_for_exit_code(Some(137)), Some("SIGKILL".into()));
353        assert_eq!(signal_name_for_exit_code(Some(139)), Some("SIGSEGV".into()));
354        assert_eq!(signal_name_for_exit_code(Some(0)), None);
355        assert_eq!(signal_name_for_exit_code(Some(1)), None);
356        assert_eq!(signal_name_for_exit_code(None), None);
357    }
358
359    #[test]
360    fn log_marker_wins_over_signal_and_cgroup_flag() {
361        let tail =
362            "FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory";
363        assert_eq!(
364            resolve_exit_reason(Some(139), Some(tail), Some(false), None),
365            Some("memory-exhaustion (v8-heap-limit)".to_string())
366        );
367    }
368
369    #[test]
370    fn memory_marker_returns_the_whole_matched_line() {
371        let tail = "building...\nFATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory\n----- Native stack trace -----\n";
372        let marker = detect_memory_marker(Some(tail)).expect("marker");
373        assert_eq!(marker.reason, "memory-exhaustion (v8-heap-limit)");
374        assert_eq!(
375            marker.line,
376            "FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory"
377        );
378    }
379
380    #[test]
381    fn memory_marker_line_is_length_bounded() {
382        let tail = format!(
383            "{}JavaScript heap out of memory{}",
384            "x".repeat(400),
385            "y".repeat(400)
386        );
387        let marker = detect_memory_marker(Some(&tail)).expect("marker");
388        assert!(marker.line.chars().count() <= MAX_MARKER_LINE_LENGTH + 3);
389        assert!(marker.line.ends_with("..."));
390    }
391
392    #[test]
393    fn memory_exhaustion_explains_only_abnormal_exits() {
394        let tail =
395            "FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory";
396        let observed =
397            resolve_memory_exhaustion(Some(139), Some(tail), Some(false), None).expect("observed");
398        assert!(observed.memory_exhausted);
399        assert_eq!(observed.memory_exhausted_reason, tail);
400        // The same marker with a successful exit is just output, not a failure.
401        assert_eq!(
402            resolve_memory_exhaustion(Some(0), Some(tail), Some(false), None),
403            None
404        );
405        assert_eq!(
406            resolve_memory_exhaustion(None, Some(tail), None, None),
407            None
408        );
409    }
410
411    #[test]
412    fn memory_exhaustion_falls_back_to_the_container_flag() {
413        let observed = resolve_memory_exhaustion(
414            Some(137),
415            Some(crate::exit_evidence::MAIN_OOM),
416            Some(true),
417            None,
418        )
419        .expect("observed");
420        assert_eq!(
421            observed.memory_exhausted_reason,
422            "Docker reported State.OOMKilled=true"
423        );
424        assert_eq!(
425            resolve_memory_exhaustion(Some(1), Some("no marker"), Some(false), None),
426            None
427        );
428    }
429
430    #[test]
431    fn falls_back_to_cgroup_observation_then_signal() {
432        assert_eq!(
433            resolve_exit_reason(
434                Some(137),
435                Some(crate::exit_evidence::MAIN_OOM),
436                Some(true),
437                None
438            ),
439            Some("memory-exhaustion (cgroup-oom-killer)".to_string())
440        );
441        assert_eq!(
442            resolve_exit_reason(Some(139), Some("no marker here"), Some(false), None),
443            Some("signal (SIGSEGV)".to_string())
444        );
445        assert_eq!(resolve_exit_reason(Some(0), Some("fine"), None, None), None);
446    }
447}