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,
419}
420
421pub fn build_detached_docker_completion_script_with(
423 container_name: &str,
424 policy: DockerContainerCleanupPolicy,
425 log_path: Option<&PathBuf>,
426 execution_id: Option<&str>,
427 watcher: &DockerWatcherOptions,
428) -> String {
429 let quoted_name = shell_quote(container_name);
430 let mut parts = Vec::new();
431 let mut exited = Vec::new();
434 let quoted_log_path = log_path.map(|path| shell_quote(&path.to_string_lossy()));
435
436 if let Some(quoted_log_path) = quoted_log_path.as_deref() {
437 let since = watcher
438 .since
439 .as_deref()
440 .map(|since| format!(" --since {}", shell_quote(since)))
441 .unwrap_or_default();
442 parts.push(format!(
443 "docker logs -f{} {} >> {} 2>&1",
444 since, quoted_name, quoted_log_path
445 ));
446 parts.push(build_docker_wait_for_exit_snippet(
447 container_name,
448 Some(quoted_log_path),
449 ));
450 parts.push(build_docker_state_snippet(container_name));
451
452 let remove = format!(
453 "docker rm -f {} >> {} 2>&1 || true; {}",
454 quoted_name,
455 quoted_log_path,
456 build_docker_removal_note_snippet(container_name, quoted_log_path)
457 );
458 let keep = format!(
461 "{}; {}",
462 build_docker_post_mortem_snippet(container_name, quoted_log_path),
463 build_docker_kept_log_snippet(container_name, quoted_log_path)
464 );
465 match policy {
466 DockerContainerCleanupPolicy::Always => exited.push(remove),
467 DockerContainerCleanupPolicy::Default | DockerContainerCleanupPolicy::KeepOnFail => {
468 exited.push(format!(
469 "if {}; then {}; else {}; fi",
470 successful_non_oom_condition(),
471 remove,
472 keep
473 ))
474 }
475 DockerContainerCleanupPolicy::Keep => exited.push(keep),
478 }
479 exited.push(format!(
480 "{} >> {}",
481 create_shell_log_footer_snippet(),
482 quoted_log_path
483 ));
484 } else {
485 parts.push(format!("docker wait {} >/dev/null 2>&1", quoted_name));
486 parts.push(build_docker_wait_for_exit_snippet(container_name, None));
487 parts.push(build_docker_state_snippet(container_name));
488 match policy {
489 DockerContainerCleanupPolicy::Always => exited.push(format!(
490 "docker rm -f {} >/dev/null 2>&1 || true",
491 quoted_name
492 )),
493 DockerContainerCleanupPolicy::Default | DockerContainerCleanupPolicy::KeepOnFail => {
494 exited.push(format!(
495 "if {}; then docker rm -f {} >/dev/null 2>&1 || true; fi",
496 successful_non_oom_condition(),
497 quoted_name
498 ))
499 }
500 DockerContainerCleanupPolicy::Keep => {}
501 }
502 }
503
504 if let Some(execution_id) = execution_id {
505 exited.push(build_detached_finalize_snippet(execution_id));
507 }
508
509 let still_running = quoted_log_path
510 .as_deref()
511 .map(|log| build_docker_still_running_note_snippet(container_name, log))
512 .unwrap_or_else(|| ":".to_string());
513 let exited = if exited.is_empty() {
514 ":".to_string()
515 } else {
516 exited.join("; ")
517 };
518 let recovery = match execution_id {
519 Some(execution_id) if watcher.recover_on_kill => format!(
520 "elif {}; then :; ",
521 crate::execution_recovery::build_recovery_snippet(execution_id)
522 ),
523 _ => String::new(),
524 };
525 parts.push(format!(
526 "if [ \"${}\" = true ]; then {}; {}else {}; fi",
527 shell_vars::RUNNING,
528 still_running,
529 recovery,
530 exited
531 ));
532
533 parts.join("; ")
534}
535
536pub(crate) fn start_detached_docker_completion_watcher_with(
537 container_name: &str,
538 policy: DockerContainerCleanupPolicy,
539 log_path: Option<&PathBuf>,
540 execution_id: Option<&str>,
541 watcher: &DockerWatcherOptions,
542) {
543 let script = build_detached_docker_completion_script_with(
544 container_name,
545 policy,
546 log_path,
547 execution_id,
548 watcher,
549 );
550 let _ = Command::new("sh")
551 .args(["-c", &script])
552 .stdout(Stdio::null())
553 .stderr(Stdio::null())
554 .spawn();
555}
556
557pub(crate) struct AttachedDockerChild {
558 child: Child,
559 stdout_thread: Option<thread::JoinHandle<()>>,
560 stderr_thread: Option<thread::JoinHandle<()>>,
561}
562
563impl AttachedDockerChild {
564 pub(crate) fn wait(mut self) -> std::io::Result<ExitStatus> {
565 let status = self.child.wait();
566 if let Some(handle) = self.stdout_thread.take() {
567 let _ = handle.join();
568 }
569 if let Some(handle) = self.stderr_thread.take() {
570 let _ = handle.join();
571 }
572 status
573 }
574}
575
576pub(crate) fn spawn_attached_docker(
577 args: &[&str],
578 log_path: Option<&PathBuf>,
579) -> std::io::Result<AttachedDockerChild> {
580 if log_path.is_none() {
581 let child = Command::new(docker_command())
582 .args(args)
583 .stdin(Stdio::inherit())
584 .stdout(Stdio::inherit())
585 .stderr(Stdio::inherit())
586 .spawn()?;
587 return Ok(AttachedDockerChild {
588 child,
589 stdout_thread: None,
590 stderr_thread: None,
591 });
592 }
593
594 let mut child = Command::new(docker_command())
595 .args(args)
596 .stdin(Stdio::inherit())
597 .stdout(Stdio::piped())
598 .stderr(Stdio::piped())
599 .spawn()?;
600
601 let file = OpenOptions::new()
602 .create(true)
603 .append(true)
604 .open(log_path.unwrap())?;
605 let shared_log = Arc::new(Mutex::new(file));
606
607 let mut stdout_thread = None;
608 let mut stderr_thread = None;
609
610 if let Some(mut stdout) = child.stdout.take() {
611 let log = Arc::clone(&shared_log);
612 stdout_thread = Some(thread::spawn(move || {
613 let mut buffer = [0_u8; 8192];
614 let mut terminal = std::io::stdout();
615 while let Ok(size) = stdout.read(&mut buffer) {
616 if size == 0 {
617 break;
618 }
619 let chunk = &buffer[..size];
620 let _ = terminal.write_all(chunk);
621 let _ = terminal.flush();
622 if let Ok(mut file) = log.lock() {
623 let _ = file.write_all(chunk);
624 let _ = file.flush();
625 }
626 }
627 }));
628 }
629
630 if let Some(mut stderr) = child.stderr.take() {
631 let log = Arc::clone(&shared_log);
632 stderr_thread = Some(thread::spawn(move || {
633 let mut buffer = [0_u8; 8192];
634 let mut terminal = std::io::stderr();
635 while let Ok(size) = stderr.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 Ok(AttachedDockerChild {
651 child,
652 stdout_thread,
653 stderr_thread,
654 })
655}
656
657#[cfg(test)]
658mod tests {
659 use super::*;
660 use crate::docker_post_mortem::POST_MORTEM_HEADER;
661
662 #[test]
663 fn default_policy_keeps_abnormal_containers() {
664 let options = IsolationOptions::default();
665 let policy = get_docker_container_cleanup_policy(&options);
666 assert_eq!(policy, DockerContainerCleanupPolicy::Default);
667 assert!(should_cleanup_docker_container(policy, 0, false));
668 assert!(!should_cleanup_docker_container(policy, 7, false));
669 assert!(!should_cleanup_docker_container(policy, 0, true));
670 }
671
672 #[test]
673 fn keep_on_fail_policy_keeps_oom_killed_containers() {
674 let options = IsolationOptions {
675 keep_container_on_fail: true,
676 ..IsolationOptions::default()
677 };
678 let policy = get_docker_container_cleanup_policy(&options);
679 assert_eq!(policy, DockerContainerCleanupPolicy::KeepOnFail);
680 assert!(should_cleanup_docker_container(policy, 0, false));
681 assert!(!should_cleanup_docker_container(policy, 0, true));
682 }
683
684 #[test]
685 fn explicit_always_policy_cleans_abnormal_containers() {
686 let options = IsolationOptions {
687 always_cleanup_container: true,
688 ..IsolationOptions::default()
689 };
690 let policy = get_docker_container_cleanup_policy(&options);
691 assert_eq!(policy, DockerContainerCleanupPolicy::Always);
692 assert!(should_cleanup_docker_container(policy, 7, false));
693 assert!(should_cleanup_docker_container(policy, 0, true));
694 }
695
696 #[test]
697 fn detached_watcher_inspects_oom_killed_before_default_cleanup() {
698 let log_path = PathBuf::from("/tmp/issue144.log");
699 let script = build_detached_docker_completion_script(
700 "issue144-container",
701 DockerContainerCleanupPolicy::Default,
702 Some(&log_path),
703 None,
704 );
705 assert!(script.contains(".State.ExitCode"));
706 assert!(script.contains(".State.OOMKilled"));
707 assert!(script.contains("__start_command_oom"));
708 assert!(script.contains("Container kept for investigation"));
709 assert!(script.contains("docker rm -f"));
710 assert!(script.contains("issue144-container"));
711 }
712
713 #[cfg(unix)]
715 fn evaluate_reason(exit_code: &str, oom_killed: &str) -> String {
716 let output = Command::new("sh")
717 .arg("-c")
718 .arg(format!(
719 "__start_command_exit={}; __start_command_oom={}; {}; printf '%s' \"$__start_command_reason\"",
720 exit_code,
721 oom_killed,
722 build_docker_kept_reason_snippet()
723 ))
724 .output()
725 .expect("sh");
726 assert!(output.status.success());
727 String::from_utf8_lossy(&output.stdout).into_owned()
728 }
729
730 #[test]
731 #[cfg(unix)]
732 fn kept_footer_does_not_assert_a_bare_oom_false_for_self_aborts() {
733 for exit_code in SELF_ABORT_EXIT_CODES {
736 let reason = evaluate_reason(&exit_code.to_string(), "false");
737 assert!(reason.contains(&format!("exitCode={} oomKilled=false", exit_code)));
738 assert!(reason.contains("invisible to this flag"));
739 }
740 }
741
742 #[test]
743 #[cfg(unix)]
744 fn kept_footer_stays_plain_for_other_exits_and_real_oom_kills() {
745 assert_eq!(evaluate_reason("1", "false"), "exitCode=1 oomKilled=false");
746 assert_eq!(
747 evaluate_reason("137", "true"),
748 "exitCode=137 oomKilled=true"
749 );
750 }
751
752 #[test]
753 fn detached_watcher_computes_the_kept_reason() {
754 let log_path = PathBuf::from("/tmp/issue165.log");
755 let script = build_detached_docker_completion_script(
756 "issue165-container",
757 DockerContainerCleanupPolicy::Default,
758 Some(&log_path),
759 None,
760 );
761 assert!(script.contains("__start_command_reason="));
762 assert!(script.contains("Reason: %s"));
763 }
764
765 #[test]
768 fn detached_watcher_invokes_the_finalizer_with_the_inspected_facts() {
769 let log_path = PathBuf::from("/tmp/issue170.log");
770 let script = build_detached_docker_completion_script(
771 "issue170-container",
772 DockerContainerCleanupPolicy::Default,
773 Some(&log_path),
774 Some("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"),
775 );
776 assert!(script.contains(crate::detached_finalize::INTERNAL_FINALIZE_FLAG));
777 assert!(script.contains("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"));
778 assert!(script.contains("$__start_command_exit"));
779 assert!(script.contains("$__start_command_finished"));
780 let finalize_at = script
783 .find(crate::detached_finalize::INTERNAL_FINALIZE_FLAG)
784 .unwrap();
785 let footer_at = script.find("Exit Code: %s").unwrap();
786 assert!(finalize_at > footer_at);
787 }
788
789 #[test]
791 fn detached_watcher_stays_backward_compatible_without_an_execution_id() {
792 let log_path = PathBuf::from("/tmp/issue170.log");
793 let script = build_detached_docker_completion_script(
794 "issue170-container",
795 DockerContainerCleanupPolicy::Default,
796 Some(&log_path),
797 None,
798 );
799 assert!(!script.contains(crate::detached_finalize::INTERNAL_FINALIZE_FLAG));
800 }
801
802 #[test]
804 fn detached_watcher_writes_the_post_mortem_on_both_paths() {
805 let log_path = PathBuf::from("/tmp/issue171.log");
806 let script = build_detached_docker_completion_script(
807 "issue171-container",
808 DockerContainerCleanupPolicy::KeepOnFail,
809 Some(&log_path),
810 None,
811 );
812 assert!(script.contains("{{.State.StartedAt}}"));
813 assert!(script.contains("{{.State.FinishedAt}}"));
814 assert!(script.contains("{{.State.Error}}"));
815 assert!(script.contains("=== Container post-mortem ==="));
816 assert!(script.contains("Container removed:"));
817 assert!(script.contains("Container kept for investigation"));
818 }
819
820 #[test]
823 fn always_kept_containers_still_get_a_post_mortem() {
824 let log_path = PathBuf::from("/tmp/issue171.log");
825 let script = build_detached_docker_completion_script(
826 "issue171-container",
827 DockerContainerCleanupPolicy::Keep,
828 Some(&log_path),
829 None,
830 );
831 assert!(script.contains("=== Container post-mortem ==="));
832 assert!(!script.contains("docker rm -f 'issue171-container' >>"));
834 }
835
836 #[test]
838 fn always_removed_containers_get_the_removal_note() {
839 let log_path = PathBuf::from("/tmp/issue171.log");
840 let script = build_detached_docker_completion_script(
841 "issue171-container",
842 DockerContainerCleanupPolicy::Always,
843 Some(&log_path),
844 None,
845 );
846 assert!(script.contains("Container removed:"));
847 assert!(script.contains("docker rm -f 'issue171-container' >>"));
848 }
849
850 #[test]
851 fn attached_runs_read_every_documented_fact_in_one_inspect() {
852 let facts = parse_docker_container_state(
853 "demo",
854 "137 false 2026-09-15T22:21:40.942007645Z 2026-09-15T22:21:46.740817278Z",
855 Some(""),
856 );
857
858 assert_eq!(facts.container_name, "demo");
859 assert_eq!(facts.exit_code, Some(137));
860 assert_eq!(facts.oom_killed, Some(false));
861 assert_eq!(
862 facts.started_at.as_deref(),
863 Some("2026-09-15T22:21:40.942007645Z")
864 );
865 assert_eq!(
866 facts.finished_at.as_deref(),
867 Some("2026-09-15T22:21:46.740817278Z")
868 );
869 assert_eq!(facts.error, None);
870 }
871
872 #[test]
873 fn attached_runs_reject_the_zero_time_of_a_container_that_never_started() {
874 let facts = parse_docker_container_state(
875 "demo",
876 "125 false 0001-01-01T00:00:00Z 0001-01-01T00:00:00Z",
877 Some("no such file or directory"),
878 );
879
880 assert_eq!(facts.started_at, None);
881 assert_eq!(facts.finished_at, None);
882 assert_eq!(facts.error.as_deref(), Some("no such file or directory"));
883 }
884
885 #[test]
886 fn attached_runs_survive_an_inspect_that_answered_nothing_useful() {
887 let facts = parse_docker_container_state("demo", "", None);
888
889 assert_eq!(facts.exit_code, None);
890 assert_eq!(facts.oom_killed, None);
891 assert_eq!(facts.started_at, None);
892 assert_eq!(facts.error, None);
893 }
894
895 #[test]
896 fn attached_kept_containers_get_the_post_mortem_block_in_their_log() {
897 let dir =
898 std::env::temp_dir().join(format!("start-attached-kept-171-{}", std::process::id()));
899 std::fs::create_dir_all(&dir).unwrap();
900 let log_path = dir.join("run.log");
901 std::fs::write(&log_path, "output\n").unwrap();
902
903 let facts = ContainerPostMortem {
904 container_name: "demo".to_string(),
905 exit_code: Some(137),
906 oom_killed: Some(false),
907 started_at: Some("2026-09-15T22:21:40.942007645Z".to_string()),
908 finished_at: Some("2026-09-15T22:21:46.740817278Z".to_string()),
909 error: None,
910 };
911 let message = record_attached_docker_post_mortem(Some(&facts), Some(&log_path), false);
912
913 let log = std::fs::read_to_string(&log_path).unwrap();
914 assert!(log.contains(POST_MORTEM_HEADER));
915 assert!(log.contains("Exit Code: 137 (SIGKILL - 128+9)"));
916 assert!(log.contains("Lifetime: 5.798s"));
917 assert!(message.contains(POST_MORTEM_HEADER));
918 let _ = std::fs::remove_dir_all(&dir);
919 }
920
921 #[test]
922 fn attached_removed_containers_get_the_one_line_note() {
923 let dir =
924 std::env::temp_dir().join(format!("start-attached-removed-171-{}", std::process::id()));
925 std::fs::create_dir_all(&dir).unwrap();
926 let log_path = dir.join("run.log");
927 std::fs::write(&log_path, "output\n").unwrap();
928
929 let facts = ContainerPostMortem {
930 container_name: "demo".to_string(),
931 exit_code: Some(0),
932 oom_killed: Some(false),
933 started_at: Some("2026-09-15T22:21:40.942007645Z".to_string()),
934 finished_at: Some("2026-09-15T22:21:46.740817278Z".to_string()),
935 error: None,
936 };
937 let message = record_attached_docker_post_mortem(Some(&facts), Some(&log_path), true);
938
939 assert!(std::fs::read_to_string(&log_path)
940 .unwrap()
941 .contains("Container removed: demo (exit 0, lifetime 5.798s, oomKilled=false)"));
942 assert_eq!(
943 message,
944 "\nContainer removed: demo (exit 0, lifetime 5.798s, oomKilled=false)"
945 );
946 let _ = std::fs::remove_dir_all(&dir);
947 }
948
949 #[test]
950 fn attached_runs_stay_silent_when_docker_could_not_be_inspected() {
951 let dir =
952 std::env::temp_dir().join(format!("start-attached-silent-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 assert_eq!(
958 record_attached_docker_post_mortem(None, Some(&log_path), false),
959 ""
960 );
961 assert_eq!(std::fs::read_to_string(&log_path).unwrap(), "output\n");
962 let _ = std::fs::remove_dir_all(&dir);
963 }
964}