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