1use std::fs::OpenOptions;
2use std::io::{Read, Write};
3use std::path::PathBuf;
4use std::process::{Child, Command, ExitStatus, Stdio};
5use std::sync::{Arc, Mutex};
6use std::thread;
7
8use crate::cgroup_memory::{
9 build_cgroup_memory_log_snippet, build_cgroup_sampler_start_snippet,
10 build_cgroup_sampler_stop_snippet,
11};
12use crate::detached_finalize::build_detached_finalize_snippet;
13use crate::docker_post_mortem::{
14 build_docker_post_mortem_snippet, build_docker_removal_note_snippet,
15 build_docker_state_snippet, build_docker_still_running_note_snippet,
16 build_docker_wait_for_exit_snippet, format_container_post_mortem,
17 format_container_removal_note, normalize_docker_timestamp, shell_vars, ContainerPostMortem,
18 DOCKER_STATE_INSPECT_FORMAT,
19};
20use crate::exit_reason::{is_oom_kill_of_command, resolve_memory_exhaustion};
21use crate::isolation::isolation_log::{
22 append_log_file, create_shell_log_footer_snippet, read_log_tail, shell_quote,
23 FATAL_MARKER_TAIL_BYTES,
24};
25use crate::isolation::IsolationOptions;
26
27const SELF_ABORT_EXIT_CODES: [i32; 2] = [134, 139];
32
33const OOM_FLAG_BLIND_NOTE: &str = "a runtime self-abort on its own memory limit is invisible to this flag - check the log above for a fatal memory marker";
37
38pub(crate) fn build_docker_kept_reason_snippet() -> String {
40 let codes: Vec<String> = SELF_ABORT_EXIT_CODES
41 .iter()
42 .map(|code| code.to_string())
43 .collect();
44 format!(
45 "__start_command_reason=\"exitCode=$__start_command_exit oomKilled=$__start_command_oom\"; case \"$__start_command_exit\" in {}) [ \"$__start_command_oom\" = true ] || __start_command_reason=\"$__start_command_reason ({})\";; esac",
46 codes.join("|"),
47 OOM_FLAG_BLIND_NOTE
48 )
49}
50
51pub(crate) fn build_docker_runtime_args(options: &IsolationOptions) -> Vec<&str> {
56 let mut args: Vec<&str> = Vec::new();
57 if options.privileged {
58 args.push("--privileged");
59 }
60 for env_var in &options.env {
61 args.push("-e");
62 args.push(env_var);
63 }
64 for volume in &options.volumes {
65 args.push("-v");
66 args.push(volume);
67 }
68 for mount in &options.mounts {
69 args.push("--mount");
70 args.push(mount);
71 }
72 if let Some(network) = docker_networks(options).first() {
73 args.push("--network");
74 args.push(network);
75 }
76 for alias in &options.network_aliases {
77 args.push("--network-alias");
78 args.push(alias);
79 }
80 args.extend(options.resource_limits.iter().map(String::as_str));
82 args
83}
84
85pub(crate) fn docker_networks(options: &IsolationOptions) -> Vec<&str> {
86 if options.networks.is_empty() {
87 options.network.iter().map(String::as_str).collect()
88 } else {
89 options.networks.iter().map(String::as_str).collect()
90 }
91}
92
93#[derive(Clone, Copy, Debug, Eq, PartialEq)]
94pub enum DockerContainerCleanupPolicy {
95 Default,
96 Always,
97 Keep,
98 KeepOnFail,
99}
100
101pub(crate) fn docker_command() -> std::ffi::OsString {
102 std::env::var_os("START_DOCKER_BIN").unwrap_or_else(|| std::ffi::OsString::from("docker"))
103}
104
105pub(crate) fn get_docker_container_cleanup_policy(
106 options: &IsolationOptions,
107) -> DockerContainerCleanupPolicy {
108 if options.keep_container {
109 DockerContainerCleanupPolicy::Keep
110 } else if options.keep_container_on_fail {
111 DockerContainerCleanupPolicy::KeepOnFail
112 } else if options.always_cleanup_container || options.auto_remove_docker_container {
113 DockerContainerCleanupPolicy::Always
114 } else {
115 DockerContainerCleanupPolicy::Default
116 }
117}
118
119pub(crate) fn is_abnormal_docker_exit(exit_code: i32, oom_killed: bool) -> bool {
120 exit_code != 0 || oom_killed
121}
122
123pub(crate) fn should_cleanup_docker_container(
124 policy: DockerContainerCleanupPolicy,
125 exit_code: i32,
126 oom_killed: bool,
127) -> bool {
128 match policy {
129 DockerContainerCleanupPolicy::Default => !is_abnormal_docker_exit(exit_code, oom_killed),
130 DockerContainerCleanupPolicy::Always => true,
131 DockerContainerCleanupPolicy::Keep => false,
132 DockerContainerCleanupPolicy::KeepOnFail => !is_abnormal_docker_exit(exit_code, oom_killed),
133 }
134}
135
136pub(crate) fn docker_container_cleanup_instructions(container_name: &str) -> String {
137 format!(
138 "Container kept for investigation: {}\nRe-enter while running: $ --attach {}\nContinue the stored command: $ --resume {}\nRun another command in the same container: $ --resume {} -- <command>\nRemove when done: docker rm -f {}",
139 container_name, container_name, container_name, container_name, container_name
140 )
141}
142
143pub(crate) fn append_docker_container_cleanup_policy_message(
144 message: &mut String,
145 container_name: &str,
146 policy: DockerContainerCleanupPolicy,
147) {
148 match policy {
149 DockerContainerCleanupPolicy::Always => {
150 message.push_str("\nContainer will be removed after command completes.");
151 }
152 DockerContainerCleanupPolicy::Default => {
153 message.push_str("\nContainer will be removed after successful completion.");
154 message.push_str(
155 "\nContainer will be kept if the command fails or Docker reports OOMKilled.",
156 );
157 message.push_str(&format!(
158 "\nRemove when done: docker rm -f {}",
159 container_name
160 ));
161 }
162 DockerContainerCleanupPolicy::Keep => {
163 message.push('\n');
164 message.push_str(&docker_container_cleanup_instructions(container_name));
165 }
166 DockerContainerCleanupPolicy::KeepOnFail => {
167 message.push_str("\nContainer will be removed after successful completion.");
168 message.push_str(
169 "\nContainer will be kept if the command fails or Docker reports OOMKilled.",
170 );
171 message.push_str(&format!(
172 "\nRemove when done: docker rm -f {}",
173 container_name
174 ));
175 }
176 }
177}
178
179pub(crate) fn read_docker_container_state(container_name: &str) -> Option<ContainerPostMortem> {
187 let inspect = |format: &str| -> Option<String> {
188 let output = Command::new(docker_command())
189 .args(["inspect", "-f", format, container_name])
190 .output()
191 .ok()?;
192 if !output.status.success() {
193 return None;
194 }
195 Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
196 };
197 let state = inspect(DOCKER_STATE_INSPECT_FORMAT)?;
198 let error = inspect("{{.State.Error}}");
201 Some(parse_docker_container_state(
202 container_name,
203 &state,
204 error.as_deref(),
205 ))
206}
207
208pub(crate) fn parse_docker_container_state(
214 container_name: &str,
215 state: &str,
216 error: Option<&str>,
217) -> ContainerPostMortem {
218 let mut parts = state.split_whitespace();
219 let exit_code = parts.next().and_then(|value| value.parse::<i32>().ok());
220 let oom_killed = match parts.next() {
221 Some("true") => Some(true),
222 Some("false") => Some(false),
223 _ => None,
224 };
225 let started_at = normalize_docker_timestamp(parts.next());
226 let finished_at = normalize_docker_timestamp(parts.next());
227 ContainerPostMortem {
228 container_name: container_name.to_string(),
229 exit_code,
230 oom_killed,
231 started_at,
232 finished_at,
233 error: error
234 .map(str::trim)
235 .filter(|text| !text.is_empty())
236 .map(str::to_string),
237 }
238}
239
240pub(crate) fn record_attached_docker_post_mortem(
248 facts: Option<&ContainerPostMortem>,
249 log_path: Option<&PathBuf>,
250 removed: bool,
251) -> String {
252 let Some(facts) = facts else {
253 return String::new();
254 };
255 let text = if removed {
256 format_container_removal_note(facts)
257 } else {
258 format_container_post_mortem(facts)
259 };
260 if let Some(path) = log_path {
261 append_log_file(path, &format!("\n{}", text));
262 }
263 format!("\n{}", text.trim_end())
264}
265
266pub(crate) fn read_docker_container_status(container_name: &str) -> Option<String> {
267 let output = Command::new(docker_command())
268 .args(["inspect", "-f", "{{.State.Status}}", container_name])
269 .output()
270 .ok()?;
271 if !output.status.success() {
272 return None;
273 }
274 let status = String::from_utf8_lossy(&output.stdout).trim().to_string();
275 if status.is_empty() {
276 None
277 } else {
278 Some(status)
279 }
280}
281
282pub(crate) fn attached_docker_kept_reason(exit_code: i32, oom_killed: bool) -> &'static str {
286 if !oom_killed {
287 "\nContainer kept because the command failed."
288 } else if is_oom_kill_of_command(Some(exit_code), Some(true), None) {
289 "\nContainer kept because Docker reports it was OOM-killed."
290 } else {
291 "\nContainer kept because Docker reports a process in it was OOM-killed."
292 }
293}
294
295pub(crate) fn append_attached_docker_cleanup_message(
296 message: &mut String,
297 container_name: &str,
298 policy: DockerContainerCleanupPolicy,
299 exit_code: i32,
300 log_path: Option<&PathBuf>,
301 container_existed_before_launch: bool,
302) {
303 let state = read_docker_container_state(container_name);
304 let oom_killed = state
305 .as_ref()
306 .and_then(|facts| facts.oom_killed)
307 .unwrap_or(false);
308 let post_mortem =
309 |removed: bool| record_attached_docker_post_mortem(state.as_ref(), log_path, removed);
310 if !container_existed_before_launch
311 && read_docker_container_status(container_name).as_deref() == Some("created")
312 {
313 if remove_docker_container(container_name, log_path) {
314 message.push_str("\nContainer removed after launch failure.");
315 } else {
316 message.push_str("\nWarning: failed to remove container after launch failure.");
317 }
318 } else if should_cleanup_docker_container(policy, exit_code, oom_killed) {
319 if remove_docker_container(container_name, log_path) {
320 message.push_str("\nContainer removed after completion.");
321 message.push_str(&post_mortem(true));
322 } else {
323 message.push_str("\nWarning: failed to remove container automatically.");
324 message.push_str(&format!(
325 "\nRemove when done: docker rm -f {container_name}"
326 ));
327 }
328 } else if policy == DockerContainerCleanupPolicy::Keep {
329 message.push('\n');
330 message.push_str(&docker_container_cleanup_instructions(container_name));
331 message.push_str(&post_mortem(false));
332 } else {
333 message.push_str(attached_docker_kept_reason(exit_code, oom_killed));
334 let tail = log_path
339 .and_then(|path| read_log_tail(&path.to_string_lossy(), FATAL_MARKER_TAIL_BYTES));
340 if let Some(memory) =
341 resolve_memory_exhaustion(Some(exit_code), tail.as_deref(), Some(oom_killed), None)
342 {
343 message.push_str(&format!(
344 "\nMemory exhaustion detected in the log: {}",
345 memory.memory_exhausted_reason
346 ));
347 }
348 message.push_str(&format!(
349 "\nRemove when done: docker rm -f {container_name}"
350 ));
351 message.push_str(&post_mortem(false));
352 }
353}
354
355pub(crate) fn remove_docker_container(container_name: &str, log_path: Option<&PathBuf>) -> bool {
356 let output = Command::new(docker_command())
357 .args(["rm", "-f", container_name])
358 .output();
359 match output {
360 Ok(output) => {
361 if let Some(path) = log_path {
362 let mut combined = String::new();
363 combined.push_str(&String::from_utf8_lossy(&output.stdout));
364 combined.push_str(&String::from_utf8_lossy(&output.stderr));
365 if !combined.is_empty() {
366 let content = if combined.ends_with('\n') {
367 combined
368 } else {
369 format!("{}\n", combined)
370 };
371 append_log_file(path, &content);
372 }
373 }
374 output.status.success()
375 }
376 Err(_) => false,
377 }
378}
379
380fn build_docker_kept_log_snippet(container_name: &str, quoted_log_path: &str) -> String {
381 let quoted_name = shell_quote(container_name);
382 format!(
383 "{}; printf '\\nContainer kept for investigation: %s\\nReason: %s\\nRe-enter while running: $ --attach %s\\nContinue the stored command: $ --resume %s\\nRun another command in the same container: $ --resume %s -- <command>\\nRemove when done: docker rm -f %s\\n' {} \"$__start_command_reason\" {} {} {} {} >> {}",
384 build_docker_kept_reason_snippet(),
385 quoted_name, quoted_name, quoted_name, quoted_name, quoted_name, quoted_log_path
386 )
387}
388
389fn successful_non_oom_condition() -> &'static str {
390 "[ \"$__start_command_exit\" -eq 0 ] 2>/dev/null && [ \"$__start_command_oom\" != true ]"
391}
392
393pub fn build_detached_docker_completion_script(
406 container_name: &str,
407 policy: DockerContainerCleanupPolicy,
408 log_path: Option<&PathBuf>,
409 execution_id: Option<&str>,
410) -> String {
411 build_detached_docker_completion_script_with(
412 container_name,
413 policy,
414 log_path,
415 execution_id,
416 &DockerWatcherOptions::default(),
417 )
418}
419
420#[derive(Clone, Debug, Default, PartialEq, Eq)]
422pub struct DockerWatcherOptions {
423 pub since: Option<String>,
426 pub recover_on_kill: bool,
432}
433
434pub fn build_detached_docker_completion_script_with(
436 container_name: &str,
437 policy: DockerContainerCleanupPolicy,
438 log_path: Option<&PathBuf>,
439 execution_id: Option<&str>,
440 watcher: &DockerWatcherOptions,
441) -> String {
442 let quoted_name = shell_quote(container_name);
443 let mut parts = vec![build_cgroup_sampler_start_snippet(container_name)];
446 let mut exited = Vec::new();
449 let quoted_log_path = log_path.map(|path| shell_quote(&path.to_string_lossy()));
450
451 if let Some(quoted_log_path) = quoted_log_path.as_deref() {
452 let since = watcher
453 .since
454 .as_deref()
455 .map(|since| format!(" --since {}", shell_quote(since)))
456 .unwrap_or_default();
457 parts.push(format!(
458 "docker logs -f{} {} >> {} 2>&1",
459 since, quoted_name, quoted_log_path
460 ));
461 parts.push(build_docker_wait_for_exit_snippet(
462 container_name,
463 Some(quoted_log_path),
464 ));
465 parts.push(build_cgroup_sampler_stop_snippet());
466 parts.push(build_docker_state_snippet(container_name));
467
468 let memory = build_cgroup_memory_log_snippet(quoted_log_path);
469 let remove = format!(
470 "docker rm -f {} >> {} 2>&1 || true; {}; {}",
471 quoted_name,
472 quoted_log_path,
473 build_docker_removal_note_snippet(container_name, quoted_log_path),
474 memory
475 );
476 let keep = format!(
479 "{}; {}; {}",
480 build_docker_post_mortem_snippet(container_name, quoted_log_path),
481 memory,
482 build_docker_kept_log_snippet(container_name, quoted_log_path)
483 );
484 match policy {
485 DockerContainerCleanupPolicy::Always => exited.push(remove),
486 DockerContainerCleanupPolicy::Default | DockerContainerCleanupPolicy::KeepOnFail => {
487 exited.push(format!(
488 "if {}; then {}; else {}; fi",
489 successful_non_oom_condition(),
490 remove,
491 keep
492 ))
493 }
494 DockerContainerCleanupPolicy::Keep => exited.push(keep),
497 }
498 exited.push(format!(
499 "{} >> {}",
500 create_shell_log_footer_snippet(),
501 quoted_log_path
502 ));
503 } else {
504 parts.push(format!("docker wait {} >/dev/null 2>&1", quoted_name));
505 parts.push(build_docker_wait_for_exit_snippet(container_name, None));
506 parts.push(build_cgroup_sampler_stop_snippet());
507 parts.push(build_docker_state_snippet(container_name));
508 match policy {
509 DockerContainerCleanupPolicy::Always => exited.push(format!(
510 "docker rm -f {} >/dev/null 2>&1 || true",
511 quoted_name
512 )),
513 DockerContainerCleanupPolicy::Default | DockerContainerCleanupPolicy::KeepOnFail => {
514 exited.push(format!(
515 "if {}; then docker rm -f {} >/dev/null 2>&1 || true; fi",
516 successful_non_oom_condition(),
517 quoted_name
518 ))
519 }
520 DockerContainerCleanupPolicy::Keep => {}
521 }
522 }
523
524 if let Some(execution_id) = execution_id {
525 exited.push(build_detached_finalize_snippet(execution_id));
527 }
528
529 let still_running = quoted_log_path
530 .as_deref()
531 .map(|log| build_docker_still_running_note_snippet(container_name, log))
532 .unwrap_or_else(|| ":".to_string());
533 let exited = if exited.is_empty() {
534 ":".to_string()
535 } else {
536 exited.join("; ")
537 };
538 let recovery = match execution_id {
539 Some(execution_id) if watcher.recover_on_kill => format!(
540 "elif {}; then :; ",
541 crate::execution_recovery::build_recovery_snippet(execution_id)
542 ),
543 _ => String::new(),
544 };
545 parts.push(format!(
546 "if [ \"${}\" = true ]; then {}; {}else {}; fi",
547 shell_vars::RUNNING,
548 still_running,
549 recovery,
550 exited
551 ));
552
553 parts.join("; ")
554}
555
556pub(crate) fn start_detached_docker_completion_watcher_with(
557 container_name: &str,
558 policy: DockerContainerCleanupPolicy,
559 log_path: Option<&PathBuf>,
560 execution_id: Option<&str>,
561 watcher: &DockerWatcherOptions,
562) {
563 let script = build_detached_docker_completion_script_with(
564 container_name,
565 policy,
566 log_path,
567 execution_id,
568 watcher,
569 );
570 let _ = Command::new("sh")
571 .args(["-c", &script])
572 .stdout(Stdio::null())
573 .stderr(Stdio::null())
574 .spawn();
575}
576
577pub(crate) struct AttachedDockerChild {
578 child: Child,
579 stdout_thread: Option<thread::JoinHandle<()>>,
580 stderr_thread: Option<thread::JoinHandle<()>>,
581}
582
583impl AttachedDockerChild {
584 pub(crate) fn wait(mut self) -> std::io::Result<ExitStatus> {
585 let status = self.child.wait();
586 if let Some(handle) = self.stdout_thread.take() {
587 let _ = handle.join();
588 }
589 if let Some(handle) = self.stderr_thread.take() {
590 let _ = handle.join();
591 }
592 status
593 }
594}
595
596pub(crate) fn spawn_attached_docker(
597 args: &[&str],
598 log_path: Option<&PathBuf>,
599) -> std::io::Result<AttachedDockerChild> {
600 if log_path.is_none() {
601 let child = Command::new(docker_command())
602 .args(args)
603 .stdin(Stdio::inherit())
604 .stdout(Stdio::inherit())
605 .stderr(Stdio::inherit())
606 .spawn()?;
607 return Ok(AttachedDockerChild {
608 child,
609 stdout_thread: None,
610 stderr_thread: None,
611 });
612 }
613
614 let mut child = Command::new(docker_command())
615 .args(args)
616 .stdin(Stdio::inherit())
617 .stdout(Stdio::piped())
618 .stderr(Stdio::piped())
619 .spawn()?;
620
621 let file = OpenOptions::new()
622 .create(true)
623 .append(true)
624 .open(log_path.unwrap())?;
625 let shared_log = Arc::new(Mutex::new(file));
626
627 let mut stdout_thread = None;
628 let mut stderr_thread = None;
629
630 if let Some(mut stdout) = child.stdout.take() {
631 let log = Arc::clone(&shared_log);
632 stdout_thread = Some(thread::spawn(move || {
633 let mut buffer = [0_u8; 8192];
634 let mut terminal = std::io::stdout();
635 while let Ok(size) = stdout.read(&mut buffer) {
636 if size == 0 {
637 break;
638 }
639 let chunk = &buffer[..size];
640 let _ = terminal.write_all(chunk);
641 let _ = terminal.flush();
642 if let Ok(mut file) = log.lock() {
643 let _ = file.write_all(chunk);
644 let _ = file.flush();
645 }
646 }
647 }));
648 }
649
650 if let Some(mut stderr) = child.stderr.take() {
651 let log = Arc::clone(&shared_log);
652 stderr_thread = Some(thread::spawn(move || {
653 let mut buffer = [0_u8; 8192];
654 let mut terminal = std::io::stderr();
655 while let Ok(size) = stderr.read(&mut buffer) {
656 if size == 0 {
657 break;
658 }
659 let chunk = &buffer[..size];
660 let _ = terminal.write_all(chunk);
661 let _ = terminal.flush();
662 if let Ok(mut file) = log.lock() {
663 let _ = file.write_all(chunk);
664 let _ = file.flush();
665 }
666 }
667 }));
668 }
669
670 Ok(AttachedDockerChild {
671 child,
672 stdout_thread,
673 stderr_thread,
674 })
675}
676
677#[cfg(test)]
678mod tests {
679 use super::*;
680 use crate::docker_post_mortem::POST_MORTEM_HEADER;
681
682 #[test]
683 fn default_policy_keeps_abnormal_containers() {
684 let options = IsolationOptions::default();
685 let policy = get_docker_container_cleanup_policy(&options);
686 assert_eq!(policy, DockerContainerCleanupPolicy::Default);
687 assert!(should_cleanup_docker_container(policy, 0, false));
688 assert!(!should_cleanup_docker_container(policy, 7, false));
689 assert!(!should_cleanup_docker_container(policy, 0, true));
690 }
691
692 #[test]
693 fn keep_on_fail_policy_keeps_oom_killed_containers() {
694 let options = IsolationOptions {
695 keep_container_on_fail: true,
696 ..IsolationOptions::default()
697 };
698 let policy = get_docker_container_cleanup_policy(&options);
699 assert_eq!(policy, DockerContainerCleanupPolicy::KeepOnFail);
700 assert!(should_cleanup_docker_container(policy, 0, false));
701 assert!(!should_cleanup_docker_container(policy, 0, true));
702 }
703
704 #[test]
705 fn explicit_always_policy_cleans_abnormal_containers() {
706 let options = IsolationOptions {
707 always_cleanup_container: true,
708 ..IsolationOptions::default()
709 };
710 let policy = get_docker_container_cleanup_policy(&options);
711 assert_eq!(policy, DockerContainerCleanupPolicy::Always);
712 assert!(should_cleanup_docker_container(policy, 7, false));
713 assert!(should_cleanup_docker_container(policy, 0, true));
714 }
715
716 #[test]
717 fn detached_watcher_inspects_oom_killed_before_default_cleanup() {
718 let log_path = PathBuf::from("/tmp/issue144.log");
719 let script = build_detached_docker_completion_script(
720 "issue144-container",
721 DockerContainerCleanupPolicy::Default,
722 Some(&log_path),
723 None,
724 );
725 assert!(script.contains(".State.ExitCode"));
726 assert!(script.contains(".State.OOMKilled"));
727 assert!(script.contains("__start_command_oom"));
728 assert!(script.contains("Container kept for investigation"));
729 assert!(script.contains("docker rm -f"));
730 assert!(script.contains("issue144-container"));
731 }
732
733 #[cfg(unix)]
735 fn evaluate_reason(exit_code: &str, oom_killed: &str) -> String {
736 let output = Command::new("sh")
737 .arg("-c")
738 .arg(format!(
739 "__start_command_exit={}; __start_command_oom={}; {}; printf '%s' \"$__start_command_reason\"",
740 exit_code,
741 oom_killed,
742 build_docker_kept_reason_snippet()
743 ))
744 .output()
745 .expect("sh");
746 assert!(output.status.success());
747 String::from_utf8_lossy(&output.stdout).into_owned()
748 }
749
750 #[test]
751 #[cfg(unix)]
752 fn kept_footer_does_not_assert_a_bare_oom_false_for_self_aborts() {
753 for exit_code in SELF_ABORT_EXIT_CODES {
756 let reason = evaluate_reason(&exit_code.to_string(), "false");
757 assert!(reason.contains(&format!("exitCode={} oomKilled=false", exit_code)));
758 assert!(reason.contains("invisible to this flag"));
759 }
760 }
761
762 #[test]
763 #[cfg(unix)]
764 fn kept_footer_stays_plain_for_other_exits_and_real_oom_kills() {
765 assert_eq!(evaluate_reason("1", "false"), "exitCode=1 oomKilled=false");
766 assert_eq!(
767 evaluate_reason("137", "true"),
768 "exitCode=137 oomKilled=true"
769 );
770 }
771
772 #[test]
773 fn attached_kept_reason_names_a_child_oom_kill_not_the_command() {
774 assert!(attached_docker_kept_reason(1, true).contains("a process in it was OOM-killed"));
775 assert!(attached_docker_kept_reason(0, true).contains("a process in it was OOM-killed"));
776 assert!(attached_docker_kept_reason(137, true).contains("reports it was OOM-killed."));
777 assert!(attached_docker_kept_reason(1, false).contains("the command failed"));
778 }
779
780 #[test]
781 fn detached_watcher_computes_the_kept_reason() {
782 let log_path = PathBuf::from("/tmp/issue165.log");
783 let script = build_detached_docker_completion_script(
784 "issue165-container",
785 DockerContainerCleanupPolicy::Default,
786 Some(&log_path),
787 None,
788 );
789 assert!(script.contains("__start_command_reason="));
790 assert!(script.contains("Reason: %s"));
791 }
792
793 #[test]
796 fn detached_watcher_invokes_the_finalizer_with_the_inspected_facts() {
797 let log_path = PathBuf::from("/tmp/issue170.log");
798 let script = build_detached_docker_completion_script(
799 "issue170-container",
800 DockerContainerCleanupPolicy::Default,
801 Some(&log_path),
802 Some("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"),
803 );
804 assert!(script.contains(crate::detached_finalize::INTERNAL_FINALIZE_FLAG));
805 assert!(script.contains("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"));
806 assert!(script.contains("$__start_command_exit"));
807 assert!(script.contains("$__start_command_finished"));
808 let finalize_at = script
811 .find(crate::detached_finalize::INTERNAL_FINALIZE_FLAG)
812 .unwrap();
813 let footer_at = script.find("Exit Code: %s").unwrap();
814 assert!(finalize_at > footer_at);
815 }
816
817 #[test]
819 fn detached_watcher_stays_backward_compatible_without_an_execution_id() {
820 let log_path = PathBuf::from("/tmp/issue170.log");
821 let script = build_detached_docker_completion_script(
822 "issue170-container",
823 DockerContainerCleanupPolicy::Default,
824 Some(&log_path),
825 None,
826 );
827 assert!(!script.contains(crate::detached_finalize::INTERNAL_FINALIZE_FLAG));
828 }
829
830 #[test]
832 fn detached_watcher_writes_the_post_mortem_on_both_paths() {
833 let log_path = PathBuf::from("/tmp/issue171.log");
834 let script = build_detached_docker_completion_script(
835 "issue171-container",
836 DockerContainerCleanupPolicy::KeepOnFail,
837 Some(&log_path),
838 None,
839 );
840 assert!(script.contains("{{.State.StartedAt}}"));
841 assert!(script.contains("{{.State.FinishedAt}}"));
842 assert!(script.contains("{{.State.Error}}"));
843 assert!(script.contains("=== Container post-mortem ==="));
844 assert!(script.contains("Container removed:"));
845 assert!(script.contains("Container kept for investigation"));
846 }
847
848 #[test]
851 fn always_kept_containers_still_get_a_post_mortem() {
852 let log_path = PathBuf::from("/tmp/issue171.log");
853 let script = build_detached_docker_completion_script(
854 "issue171-container",
855 DockerContainerCleanupPolicy::Keep,
856 Some(&log_path),
857 None,
858 );
859 assert!(script.contains("=== Container post-mortem ==="));
860 assert!(!script.contains("docker rm -f 'issue171-container' >>"));
862 }
863
864 #[test]
866 fn always_removed_containers_get_the_removal_note() {
867 let log_path = PathBuf::from("/tmp/issue171.log");
868 let script = build_detached_docker_completion_script(
869 "issue171-container",
870 DockerContainerCleanupPolicy::Always,
871 Some(&log_path),
872 None,
873 );
874 assert!(script.contains("Container removed:"));
875 assert!(script.contains("docker rm -f 'issue171-container' >>"));
876 }
877
878 #[test]
879 fn attached_runs_read_every_documented_fact_in_one_inspect() {
880 let facts = parse_docker_container_state(
881 "demo",
882 "137 false 2026-09-15T22:21:40.942007645Z 2026-09-15T22:21:46.740817278Z",
883 Some(""),
884 );
885
886 assert_eq!(facts.container_name, "demo");
887 assert_eq!(facts.exit_code, Some(137));
888 assert_eq!(facts.oom_killed, Some(false));
889 assert_eq!(
890 facts.started_at.as_deref(),
891 Some("2026-09-15T22:21:40.942007645Z")
892 );
893 assert_eq!(
894 facts.finished_at.as_deref(),
895 Some("2026-09-15T22:21:46.740817278Z")
896 );
897 assert_eq!(facts.error, None);
898 }
899
900 #[test]
901 fn attached_runs_reject_the_zero_time_of_a_container_that_never_started() {
902 let facts = parse_docker_container_state(
903 "demo",
904 "125 false 0001-01-01T00:00:00Z 0001-01-01T00:00:00Z",
905 Some("no such file or directory"),
906 );
907
908 assert_eq!(facts.started_at, None);
909 assert_eq!(facts.finished_at, None);
910 assert_eq!(facts.error.as_deref(), Some("no such file or directory"));
911 }
912
913 #[test]
914 fn attached_runs_survive_an_inspect_that_answered_nothing_useful() {
915 let facts = parse_docker_container_state("demo", "", None);
916
917 assert_eq!(facts.exit_code, None);
918 assert_eq!(facts.oom_killed, None);
919 assert_eq!(facts.started_at, None);
920 assert_eq!(facts.error, None);
921 }
922
923 #[test]
924 fn attached_kept_containers_get_the_post_mortem_block_in_their_log() {
925 let dir =
926 std::env::temp_dir().join(format!("start-attached-kept-171-{}", std::process::id()));
927 std::fs::create_dir_all(&dir).unwrap();
928 let log_path = dir.join("run.log");
929 std::fs::write(&log_path, "output\n").unwrap();
930
931 let facts = ContainerPostMortem {
932 container_name: "demo".to_string(),
933 exit_code: Some(137),
934 oom_killed: Some(false),
935 started_at: Some("2026-09-15T22:21:40.942007645Z".to_string()),
936 finished_at: Some("2026-09-15T22:21:46.740817278Z".to_string()),
937 error: None,
938 };
939 let message = record_attached_docker_post_mortem(Some(&facts), Some(&log_path), false);
940
941 let log = std::fs::read_to_string(&log_path).unwrap();
942 assert!(log.contains(POST_MORTEM_HEADER));
943 assert!(log.contains("Exit Code: 137 (SIGKILL - 128+9)"));
944 assert!(log.contains("Lifetime: 5.798s"));
945 assert!(message.contains(POST_MORTEM_HEADER));
946 let _ = std::fs::remove_dir_all(&dir);
947 }
948
949 #[test]
950 fn attached_removed_containers_get_the_one_line_note() {
951 let dir =
952 std::env::temp_dir().join(format!("start-attached-removed-171-{}", std::process::id()));
953 std::fs::create_dir_all(&dir).unwrap();
954 let log_path = dir.join("run.log");
955 std::fs::write(&log_path, "output\n").unwrap();
956
957 let facts = ContainerPostMortem {
958 container_name: "demo".to_string(),
959 exit_code: Some(0),
960 oom_killed: Some(false),
961 started_at: Some("2026-09-15T22:21:40.942007645Z".to_string()),
962 finished_at: Some("2026-09-15T22:21:46.740817278Z".to_string()),
963 error: None,
964 };
965 let message = record_attached_docker_post_mortem(Some(&facts), Some(&log_path), true);
966
967 assert!(std::fs::read_to_string(&log_path)
968 .unwrap()
969 .contains("Container removed: demo (exit 0, lifetime 5.798s, oomKilled=false)"));
970 assert_eq!(
971 message,
972 "\nContainer removed: demo (exit 0, lifetime 5.798s, oomKilled=false)"
973 );
974 let _ = std::fs::remove_dir_all(&dir);
975 }
976
977 #[test]
978 fn attached_runs_stay_silent_when_docker_could_not_be_inspected() {
979 let dir =
980 std::env::temp_dir().join(format!("start-attached-silent-171-{}", std::process::id()));
981 std::fs::create_dir_all(&dir).unwrap();
982 let log_path = dir.join("run.log");
983 std::fs::write(&log_path, "output\n").unwrap();
984
985 assert_eq!(
986 record_attached_docker_post_mortem(None, Some(&log_path), false),
987 ""
988 );
989 assert_eq!(std::fs::read_to_string(&log_path).unwrap(), "output\n");
990 let _ = std::fs::remove_dir_all(&dir);
991 }
992}