1use regex::Regex;
19
20const 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
44pub 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
63const MEMORY_EXHAUSTION_PREFIX: &str = "memory-exhaustion";
66
67const MAX_MARKER_LINE_LENGTH: usize = 300;
70
71#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct ExitReasonMarker {
74 pub reason: String,
75 pub line: String,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct MemoryExhaustion {
81 pub memory_exhausted: bool,
82 pub memory_exhausted_reason: String,
83}
84
85fn 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
100pub 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
125pub fn detect_exit_reason(text: Option<&str>) -> Option<String> {
127 find_exit_reason_marker(text, |_| true).map(|marker| marker.reason)
128}
129
130pub fn detect_memory_marker(text: Option<&str>) -> Option<ExitReasonMarker> {
132 find_exit_reason_marker(text, |reason| reason.starts_with(MEMORY_EXHAUSTION_PREFIX))
133}
134
135pub 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
190pub const CGROUP_OOM_EXIT_REASON: &str = "memory-exhaustion (cgroup-oom-killer)";
192
193pub fn is_oom_kill_of_command(
204 exit_code: Option<i32>,
205 oom_killed: Option<bool>,
206 oom_kills: Option<u64>,
207) -> bool {
208 let _ = (exit_code, oom_killed, oom_kills);
210 false
211}
212
213pub const UNKNOWN_EXIT_CODE: &str = "unknown";
215
216#[derive(Debug, Clone, PartialEq, Eq)]
220pub struct ExitCodeDescription {
221 pub code: Option<i32>,
222 pub signal: Option<String>,
223 pub text: String,
224}
225
226pub 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
254pub fn describe_exit_code_str(value: &str) -> ExitCodeDescription {
256 describe_exit_code(value.trim().parse::<i32>().ok())
257}
258
259pub 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
271pub 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 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}