Skip to main content

start_command/
docker_cleanup.rs

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
23/// Exit codes a runtime produces when it aborts itself (SIGABRT / SIGSEGV).
24/// Node/V8 prints `FATAL ERROR: Reached heap limit ...` and aborts long before
25/// the container limit is reached, so the kernel never OOM-kills anything and
26/// `State.OOMKilled` stays `false` (issue #165).
27const SELF_ABORT_EXIT_CODES: [i32; 2] = [134, 139];
28
29/// Appended to the kept-container reason for a self-abort exit code, so the
30/// footer stops asserting the opposite of the `FATAL ERROR` printed a few lines
31/// above it. The footer is the string downstream tooling greps.
32const 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
34/// Shell fragment computing `$__start_command_reason` for the kept footer.
35pub(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
47/// Build the extra `docker run` arguments contributed by runtime options
48/// (--privileged, --env/-e, --volume/-v, --mount, --network,
49/// --network-alias). Returned references borrow
50/// from `options`, which outlives the `docker run` invocation.
51pub(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
77}
78
79pub(crate) fn docker_networks(options: &IsolationOptions) -> Vec<&str> {
80    if options.networks.is_empty() {
81        options.network.iter().map(String::as_str).collect()
82    } else {
83        options.networks.iter().map(String::as_str).collect()
84    }
85}
86
87#[derive(Clone, Copy, Debug, Eq, PartialEq)]
88pub enum DockerContainerCleanupPolicy {
89    Default,
90    Always,
91    Keep,
92    KeepOnFail,
93}
94
95pub(crate) fn docker_command() -> std::ffi::OsString {
96    std::env::var_os("START_DOCKER_BIN").unwrap_or_else(|| std::ffi::OsString::from("docker"))
97}
98
99pub(crate) fn get_docker_container_cleanup_policy(
100    options: &IsolationOptions,
101) -> DockerContainerCleanupPolicy {
102    if options.keep_container {
103        DockerContainerCleanupPolicy::Keep
104    } else if options.keep_container_on_fail {
105        DockerContainerCleanupPolicy::KeepOnFail
106    } else if options.always_cleanup_container || options.auto_remove_docker_container {
107        DockerContainerCleanupPolicy::Always
108    } else {
109        DockerContainerCleanupPolicy::Default
110    }
111}
112
113pub(crate) fn is_abnormal_docker_exit(exit_code: i32, oom_killed: bool) -> bool {
114    exit_code != 0 || oom_killed
115}
116
117pub(crate) fn should_cleanup_docker_container(
118    policy: DockerContainerCleanupPolicy,
119    exit_code: i32,
120    oom_killed: bool,
121) -> bool {
122    match policy {
123        DockerContainerCleanupPolicy::Default => !is_abnormal_docker_exit(exit_code, oom_killed),
124        DockerContainerCleanupPolicy::Always => true,
125        DockerContainerCleanupPolicy::Keep => false,
126        DockerContainerCleanupPolicy::KeepOnFail => !is_abnormal_docker_exit(exit_code, oom_killed),
127    }
128}
129
130pub(crate) fn docker_container_cleanup_instructions(container_name: &str) -> String {
131    format!(
132        "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 {}",
133        container_name, container_name, container_name, container_name, container_name
134    )
135}
136
137pub(crate) fn append_docker_container_cleanup_policy_message(
138    message: &mut String,
139    container_name: &str,
140    policy: DockerContainerCleanupPolicy,
141) {
142    match policy {
143        DockerContainerCleanupPolicy::Always => {
144            message.push_str("\nContainer will be removed after command completes.");
145        }
146        DockerContainerCleanupPolicy::Default => {
147            message.push_str("\nContainer will be removed after successful completion.");
148            message.push_str(
149                "\nContainer will be kept if the command fails or Docker reports OOMKilled.",
150            );
151            message.push_str(&format!(
152                "\nRemove when done: docker rm -f {}",
153                container_name
154            ));
155        }
156        DockerContainerCleanupPolicy::Keep => {
157            message.push('\n');
158            message.push_str(&docker_container_cleanup_instructions(container_name));
159        }
160        DockerContainerCleanupPolicy::KeepOnFail => {
161            message.push_str("\nContainer will be removed after successful completion.");
162            message.push_str(
163                "\nContainer will be kept if the command fails or Docker reports OOMKilled.",
164            );
165            message.push_str(&format!(
166                "\nRemove when done: docker rm -f {}",
167                container_name
168            ));
169        }
170    }
171}
172
173/// Read a finished container's post-mortem facts in a single `docker inspect`.
174///
175/// The attached path only ever inspected `State.OOMKilled`, which left its
176/// "Container kept for investigation" message unable to say *why* the container
177/// died — the same gap the detached watcher had (issue #171.1). Reading the
178/// facts once, before any `docker rm`, also makes them available to the removal
179/// path, where the container is about to stop existing (issue #171.3).
180pub(crate) fn read_docker_container_state(container_name: &str) -> Option<ContainerPostMortem> {
181    let inspect = |format: &str| -> Option<String> {
182        let output = Command::new(docker_command())
183            .args(["inspect", "-f", format, container_name])
184            .output()
185            .ok()?;
186        if !output.status.success() {
187            return None;
188        }
189        Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
190    };
191    let state = inspect(DOCKER_STATE_INSPECT_FORMAT)?;
192    // `State.Error` is free-form text that may contain spaces, so it cannot
193    // ride along in the whitespace-separated template above.
194    let error = inspect("{{.State.Error}}");
195    Some(parse_docker_container_state(
196        container_name,
197        &state,
198        error.as_deref(),
199    ))
200}
201
202/// Turn the raw output of [`DOCKER_STATE_INSPECT_FORMAT`] into facts.
203///
204/// Split from the `docker inspect` call so the mapping — including the
205/// rejection of docker's zero time — is testable without a docker daemon or a
206/// process-wide `START_DOCKER_BIN` override.
207pub(crate) fn parse_docker_container_state(
208    container_name: &str,
209    state: &str,
210    error: Option<&str>,
211) -> ContainerPostMortem {
212    let mut parts = state.split_whitespace();
213    let exit_code = parts.next().and_then(|value| value.parse::<i32>().ok());
214    let oom_killed = match parts.next() {
215        Some("true") => Some(true),
216        Some("false") => Some(false),
217        _ => None,
218    };
219    let started_at = normalize_docker_timestamp(parts.next());
220    let finished_at = normalize_docker_timestamp(parts.next());
221    ContainerPostMortem {
222        container_name: container_name.to_string(),
223        exit_code,
224        oom_killed,
225        started_at,
226        finished_at,
227        error: error
228            .map(str::trim)
229            .filter(|text| !text.is_empty())
230            .map(str::to_string),
231    }
232}
233
234/// Write the post-mortem for an attached run into its log and return the same
235/// text for the console message.
236///
237/// Kept and removed containers both get their facts recorded — a kept container
238/// gets the full block, a removed one the single line — so the log of an
239/// attached run carries exactly what the detached watcher writes (issues
240/// #171.2 and #171.3).
241pub(crate) fn record_attached_docker_post_mortem(
242    facts: Option<&ContainerPostMortem>,
243    log_path: Option<&PathBuf>,
244    removed: bool,
245) -> String {
246    let Some(facts) = facts else {
247        return String::new();
248    };
249    let text = if removed {
250        format_container_removal_note(facts)
251    } else {
252        format_container_post_mortem(facts)
253    };
254    if let Some(path) = log_path {
255        append_log_file(path, &format!("\n{}", text));
256    }
257    format!("\n{}", text.trim_end())
258}
259
260pub(crate) fn read_docker_container_status(container_name: &str) -> Option<String> {
261    let output = Command::new(docker_command())
262        .args(["inspect", "-f", "{{.State.Status}}", container_name])
263        .output()
264        .ok()?;
265    if !output.status.success() {
266        return None;
267    }
268    let status = String::from_utf8_lossy(&output.stdout).trim().to_string();
269    if status.is_empty() {
270        None
271    } else {
272        Some(status)
273    }
274}
275
276pub(crate) fn append_attached_docker_cleanup_message(
277    message: &mut String,
278    container_name: &str,
279    policy: DockerContainerCleanupPolicy,
280    exit_code: i32,
281    log_path: Option<&PathBuf>,
282    container_existed_before_launch: bool,
283) {
284    let state = read_docker_container_state(container_name);
285    let oom_killed = state
286        .as_ref()
287        .and_then(|facts| facts.oom_killed)
288        .unwrap_or(false);
289    let post_mortem =
290        |removed: bool| record_attached_docker_post_mortem(state.as_ref(), log_path, removed);
291    if !container_existed_before_launch
292        && read_docker_container_status(container_name).as_deref() == Some("created")
293    {
294        if remove_docker_container(container_name, log_path) {
295            message.push_str("\nContainer removed after launch failure.");
296        } else {
297            message.push_str("\nWarning: failed to remove container after launch failure.");
298        }
299    } else if should_cleanup_docker_container(policy, exit_code, oom_killed) {
300        if remove_docker_container(container_name, log_path) {
301            message.push_str("\nContainer removed after completion.");
302            message.push_str(&post_mortem(true));
303        } else {
304            message.push_str("\nWarning: failed to remove container automatically.");
305            message.push_str(&format!(
306                "\nRemove when done: docker rm -f {container_name}"
307            ));
308        }
309    } else if policy == DockerContainerCleanupPolicy::Keep {
310        message.push('\n');
311        message.push_str(&docker_container_cleanup_instructions(container_name));
312        message.push_str(&post_mortem(false));
313    } else {
314        if oom_killed {
315            message.push_str("\nContainer kept because Docker reports it was OOM-killed.");
316        } else {
317            message.push_str("\nContainer kept because the command failed.");
318        }
319        // A runtime that aborts on its own memory limit never trips the
320        // container flag, so `oomKilled false` alone would contradict the
321        // `FATAL ERROR` the runtime just printed into this very log (issue
322        // #165). Best effort: the tail is read right after the child exits.
323        let tail = log_path
324            .and_then(|path| read_log_tail(&path.to_string_lossy(), FATAL_MARKER_TAIL_BYTES));
325        if let Some(memory) =
326            resolve_memory_exhaustion(Some(exit_code), tail.as_deref(), Some(oom_killed))
327        {
328            message.push_str(&format!(
329                "\nMemory exhaustion detected in the log: {}",
330                memory.memory_exhausted_reason
331            ));
332        }
333        message.push_str(&format!(
334            "\nRemove when done: docker rm -f {container_name}"
335        ));
336        message.push_str(&post_mortem(false));
337    }
338}
339
340pub(crate) fn remove_docker_container(container_name: &str, log_path: Option<&PathBuf>) -> bool {
341    let output = Command::new(docker_command())
342        .args(["rm", "-f", container_name])
343        .output();
344    match output {
345        Ok(output) => {
346            if let Some(path) = log_path {
347                let mut combined = String::new();
348                combined.push_str(&String::from_utf8_lossy(&output.stdout));
349                combined.push_str(&String::from_utf8_lossy(&output.stderr));
350                if !combined.is_empty() {
351                    let content = if combined.ends_with('\n') {
352                        combined
353                    } else {
354                        format!("{}\n", combined)
355                    };
356                    append_log_file(path, &content);
357                }
358            }
359            output.status.success()
360        }
361        Err(_) => false,
362    }
363}
364
365fn build_docker_kept_log_snippet(container_name: &str, quoted_log_path: &str) -> String {
366    let quoted_name = shell_quote(container_name);
367    format!(
368        "{}; 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\" {} {} {} {} >> {}",
369        build_docker_kept_reason_snippet(),
370        quoted_name, quoted_name, quoted_name, quoted_name, quoted_name, quoted_log_path
371    )
372}
373
374fn successful_non_oom_condition() -> &'static str {
375    "[ \"$__start_command_exit\" -eq 0 ] 2>/dev/null && [ \"$__start_command_oom\" != true ]"
376}
377
378/// Build the shell the detached completion watcher runs after the container is
379/// gone.
380///
381/// Order matters. The post-mortem block and the removal note go in *before* the
382/// footer, so the `Finished:`/`Exit Code:` pair stays the last thing in the log
383/// (issue #171.2); the store is finalized *after* the footer, so a record only
384/// becomes terminal once its log is complete (issue #170.1).
385///
386/// It only does any of that once the container has really exited: the return of
387/// `docker logs -f` / `docker wait` is not proof of exit (issue #174). A
388/// container that is somehow still running afterwards is never removed, gets no
389/// `Exit Code:` footer and is never finalized — its record stays `executing`.
390pub fn build_detached_docker_completion_script(
391    container_name: &str,
392    policy: DockerContainerCleanupPolicy,
393    log_path: Option<&PathBuf>,
394    execution_id: Option<&str>,
395) -> String {
396    let quoted_name = shell_quote(container_name);
397    let mut parts = Vec::new();
398    // Everything that assumes the container has exited: cleanup, footer and
399    // finalization. Guarded as a whole by `.State.Running` below.
400    let mut exited = Vec::new();
401    let quoted_log_path = log_path.map(|path| shell_quote(&path.to_string_lossy()));
402
403    if let Some(quoted_log_path) = quoted_log_path.as_deref() {
404        parts.push(format!(
405            "docker logs -f {} >> {} 2>&1",
406            quoted_name, quoted_log_path
407        ));
408        parts.push(build_docker_wait_for_exit_snippet(
409            container_name,
410            Some(quoted_log_path),
411        ));
412        parts.push(build_docker_state_snippet(container_name));
413
414        let remove = format!(
415            "docker rm -f {} >> {} 2>&1 || true; {}",
416            quoted_name,
417            quoted_log_path,
418            build_docker_removal_note_snippet(container_name, quoted_log_path)
419        );
420        // A kept container is exactly the case the user will investigate, so it
421        // gets the full post-mortem before the copy-paste instructions.
422        let keep = format!(
423            "{}; {}",
424            build_docker_post_mortem_snippet(container_name, quoted_log_path),
425            build_docker_kept_log_snippet(container_name, quoted_log_path)
426        );
427        match policy {
428            DockerContainerCleanupPolicy::Always => exited.push(remove),
429            DockerContainerCleanupPolicy::Default | DockerContainerCleanupPolicy::KeepOnFail => {
430                exited.push(format!(
431                    "if {}; then {}; else {}; fi",
432                    successful_non_oom_condition(),
433                    remove,
434                    keep
435                ))
436            }
437            // Previously wrote nothing at all: the container is always kept, so
438            // the log said nothing about why it stopped (issue #171.2).
439            DockerContainerCleanupPolicy::Keep => exited.push(keep),
440        }
441        exited.push(format!(
442            "{} >> {}",
443            create_shell_log_footer_snippet(),
444            quoted_log_path
445        ));
446    } else {
447        parts.push(format!("docker wait {} >/dev/null 2>&1", quoted_name));
448        parts.push(build_docker_wait_for_exit_snippet(container_name, None));
449        parts.push(build_docker_state_snippet(container_name));
450        match policy {
451            DockerContainerCleanupPolicy::Always => exited.push(format!(
452                "docker rm -f {} >/dev/null 2>&1 || true",
453                quoted_name
454            )),
455            DockerContainerCleanupPolicy::Default | DockerContainerCleanupPolicy::KeepOnFail => {
456                exited.push(format!(
457                    "if {}; then docker rm -f {} >/dev/null 2>&1 || true; fi",
458                    successful_non_oom_condition(),
459                    quoted_name
460                ))
461            }
462            DockerContainerCleanupPolicy::Keep => {}
463        }
464    }
465
466    if let Some(execution_id) = execution_id {
467        // Last, so the record is only marked terminal once the log is complete.
468        exited.push(build_detached_finalize_snippet(execution_id));
469    }
470
471    let still_running = quoted_log_path
472        .as_deref()
473        .map(|log| build_docker_still_running_note_snippet(container_name, log))
474        .unwrap_or_else(|| ":".to_string());
475    let exited = if exited.is_empty() {
476        ":".to_string()
477    } else {
478        exited.join("; ")
479    };
480    parts.push(format!(
481        "if [ \"${}\" = true ]; then {}; else {}; fi",
482        shell_vars::RUNNING,
483        still_running,
484        exited
485    ));
486
487    parts.join("; ")
488}
489
490pub(crate) fn start_detached_docker_completion_watcher(
491    container_name: &str,
492    policy: DockerContainerCleanupPolicy,
493    log_path: Option<&PathBuf>,
494    execution_id: Option<&str>,
495) {
496    let script =
497        build_detached_docker_completion_script(container_name, policy, log_path, execution_id);
498    let _ = Command::new("sh")
499        .args(["-c", &script])
500        .stdout(Stdio::null())
501        .stderr(Stdio::null())
502        .spawn();
503}
504
505pub(crate) struct AttachedDockerChild {
506    child: Child,
507    stdout_thread: Option<thread::JoinHandle<()>>,
508    stderr_thread: Option<thread::JoinHandle<()>>,
509}
510
511impl AttachedDockerChild {
512    pub(crate) fn wait(mut self) -> std::io::Result<ExitStatus> {
513        let status = self.child.wait();
514        if let Some(handle) = self.stdout_thread.take() {
515            let _ = handle.join();
516        }
517        if let Some(handle) = self.stderr_thread.take() {
518            let _ = handle.join();
519        }
520        status
521    }
522}
523
524pub(crate) fn spawn_attached_docker(
525    args: &[&str],
526    log_path: Option<&PathBuf>,
527) -> std::io::Result<AttachedDockerChild> {
528    if log_path.is_none() {
529        let child = Command::new(docker_command())
530            .args(args)
531            .stdin(Stdio::inherit())
532            .stdout(Stdio::inherit())
533            .stderr(Stdio::inherit())
534            .spawn()?;
535        return Ok(AttachedDockerChild {
536            child,
537            stdout_thread: None,
538            stderr_thread: None,
539        });
540    }
541
542    let mut child = Command::new(docker_command())
543        .args(args)
544        .stdin(Stdio::inherit())
545        .stdout(Stdio::piped())
546        .stderr(Stdio::piped())
547        .spawn()?;
548
549    let file = OpenOptions::new()
550        .create(true)
551        .append(true)
552        .open(log_path.unwrap())?;
553    let shared_log = Arc::new(Mutex::new(file));
554
555    let mut stdout_thread = None;
556    let mut stderr_thread = None;
557
558    if let Some(mut stdout) = child.stdout.take() {
559        let log = Arc::clone(&shared_log);
560        stdout_thread = Some(thread::spawn(move || {
561            let mut buffer = [0_u8; 8192];
562            let mut terminal = std::io::stdout();
563            while let Ok(size) = stdout.read(&mut buffer) {
564                if size == 0 {
565                    break;
566                }
567                let chunk = &buffer[..size];
568                let _ = terminal.write_all(chunk);
569                let _ = terminal.flush();
570                if let Ok(mut file) = log.lock() {
571                    let _ = file.write_all(chunk);
572                    let _ = file.flush();
573                }
574            }
575        }));
576    }
577
578    if let Some(mut stderr) = child.stderr.take() {
579        let log = Arc::clone(&shared_log);
580        stderr_thread = Some(thread::spawn(move || {
581            let mut buffer = [0_u8; 8192];
582            let mut terminal = std::io::stderr();
583            while let Ok(size) = stderr.read(&mut buffer) {
584                if size == 0 {
585                    break;
586                }
587                let chunk = &buffer[..size];
588                let _ = terminal.write_all(chunk);
589                let _ = terminal.flush();
590                if let Ok(mut file) = log.lock() {
591                    let _ = file.write_all(chunk);
592                    let _ = file.flush();
593                }
594            }
595        }));
596    }
597
598    Ok(AttachedDockerChild {
599        child,
600        stdout_thread,
601        stderr_thread,
602    })
603}
604
605#[cfg(test)]
606mod tests {
607    use super::*;
608    use crate::docker_post_mortem::POST_MORTEM_HEADER;
609
610    #[test]
611    fn default_policy_keeps_abnormal_containers() {
612        let options = IsolationOptions::default();
613        let policy = get_docker_container_cleanup_policy(&options);
614        assert_eq!(policy, DockerContainerCleanupPolicy::Default);
615        assert!(should_cleanup_docker_container(policy, 0, false));
616        assert!(!should_cleanup_docker_container(policy, 7, false));
617        assert!(!should_cleanup_docker_container(policy, 0, true));
618    }
619
620    #[test]
621    fn keep_on_fail_policy_keeps_oom_killed_containers() {
622        let options = IsolationOptions {
623            keep_container_on_fail: true,
624            ..IsolationOptions::default()
625        };
626        let policy = get_docker_container_cleanup_policy(&options);
627        assert_eq!(policy, DockerContainerCleanupPolicy::KeepOnFail);
628        assert!(should_cleanup_docker_container(policy, 0, false));
629        assert!(!should_cleanup_docker_container(policy, 0, true));
630    }
631
632    #[test]
633    fn explicit_always_policy_cleans_abnormal_containers() {
634        let options = IsolationOptions {
635            always_cleanup_container: true,
636            ..IsolationOptions::default()
637        };
638        let policy = get_docker_container_cleanup_policy(&options);
639        assert_eq!(policy, DockerContainerCleanupPolicy::Always);
640        assert!(should_cleanup_docker_container(policy, 7, false));
641        assert!(should_cleanup_docker_container(policy, 0, true));
642    }
643
644    #[test]
645    fn detached_watcher_inspects_oom_killed_before_default_cleanup() {
646        let log_path = PathBuf::from("/tmp/issue144.log");
647        let script = build_detached_docker_completion_script(
648            "issue144-container",
649            DockerContainerCleanupPolicy::Default,
650            Some(&log_path),
651            None,
652        );
653        assert!(script.contains(".State.ExitCode"));
654        assert!(script.contains(".State.OOMKilled"));
655        assert!(script.contains("__start_command_oom"));
656        assert!(script.contains("Container kept for investigation"));
657        assert!(script.contains("docker rm -f"));
658        assert!(script.contains("issue144-container"));
659    }
660
661    /// Evaluate the reason snippet the way the watcher does, in a real shell.
662    #[cfg(unix)]
663    fn evaluate_reason(exit_code: &str, oom_killed: &str) -> String {
664        let output = Command::new("sh")
665            .arg("-c")
666            .arg(format!(
667                "__start_command_exit={}; __start_command_oom={}; {}; printf '%s' \"$__start_command_reason\"",
668                exit_code,
669                oom_killed,
670                build_docker_kept_reason_snippet()
671            ))
672            .output()
673            .expect("sh");
674        assert!(output.status.success());
675        String::from_utf8_lossy(&output.stdout).into_owned()
676    }
677
678    #[test]
679    #[cfg(unix)]
680    fn kept_footer_does_not_assert_a_bare_oom_false_for_self_aborts() {
681        // The footer is printed a few lines below the runtime's own
682        // `FATAL ERROR: Reached heap limit ...`; it must not contradict it.
683        for exit_code in SELF_ABORT_EXIT_CODES {
684            let reason = evaluate_reason(&exit_code.to_string(), "false");
685            assert!(reason.contains(&format!("exitCode={} oomKilled=false", exit_code)));
686            assert!(reason.contains("invisible to this flag"));
687        }
688    }
689
690    #[test]
691    #[cfg(unix)]
692    fn kept_footer_stays_plain_for_other_exits_and_real_oom_kills() {
693        assert_eq!(evaluate_reason("1", "false"), "exitCode=1 oomKilled=false");
694        assert_eq!(
695            evaluate_reason("137", "true"),
696            "exitCode=137 oomKilled=true"
697        );
698    }
699
700    #[test]
701    fn detached_watcher_computes_the_kept_reason() {
702        let log_path = PathBuf::from("/tmp/issue165.log");
703        let script = build_detached_docker_completion_script(
704            "issue165-container",
705            DockerContainerCleanupPolicy::Default,
706            Some(&log_path),
707            None,
708        );
709        assert!(script.contains("__start_command_reason="));
710        assert!(script.contains("Reason: %s"));
711    }
712
713    /// Issue #170.1: the watcher must hand the terminal state to the finalizer,
714    /// so a detached record stops being `executing` forever.
715    #[test]
716    fn detached_watcher_invokes_the_finalizer_with_the_inspected_facts() {
717        let log_path = PathBuf::from("/tmp/issue170.log");
718        let script = build_detached_docker_completion_script(
719            "issue170-container",
720            DockerContainerCleanupPolicy::Default,
721            Some(&log_path),
722            Some("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"),
723        );
724        assert!(script.contains(crate::detached_finalize::INTERNAL_FINALIZE_FLAG));
725        assert!(script.contains("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"));
726        assert!(script.contains("$__start_command_exit"));
727        assert!(script.contains("$__start_command_finished"));
728        // Bookkeeping runs last: a record only becomes terminal once the log is
729        // complete, and a failed finalization can never abort the cleanup.
730        let finalize_at = script
731            .find(crate::detached_finalize::INTERNAL_FINALIZE_FLAG)
732            .unwrap();
733        let footer_at = script.find("Exit Code: %s").unwrap();
734        assert!(finalize_at > footer_at);
735    }
736
737    /// Without an execution id (an un-tracked run) nothing is finalized.
738    #[test]
739    fn detached_watcher_stays_backward_compatible_without_an_execution_id() {
740        let log_path = PathBuf::from("/tmp/issue170.log");
741        let script = build_detached_docker_completion_script(
742            "issue170-container",
743            DockerContainerCleanupPolicy::Default,
744            Some(&log_path),
745            None,
746        );
747        assert!(!script.contains(crate::detached_finalize::INTERNAL_FINALIZE_FLAG));
748    }
749
750    /// Issue #171.1/.2/.3: every cleanup path states the post-mortem facts.
751    #[test]
752    fn detached_watcher_writes_the_post_mortem_on_both_paths() {
753        let log_path = PathBuf::from("/tmp/issue171.log");
754        let script = build_detached_docker_completion_script(
755            "issue171-container",
756            DockerContainerCleanupPolicy::KeepOnFail,
757            Some(&log_path),
758            None,
759        );
760        assert!(script.contains("{{.State.StartedAt}}"));
761        assert!(script.contains("{{.State.FinishedAt}}"));
762        assert!(script.contains("{{.State.Error}}"));
763        assert!(script.contains("=== Container post-mortem ==="));
764        assert!(script.contains("Container removed:"));
765        assert!(script.contains("Container kept for investigation"));
766    }
767
768    /// A container that is always kept used to produce no completion output at
769    /// all; it must still get its post-mortem (issue #171.2).
770    #[test]
771    fn always_kept_containers_still_get_a_post_mortem() {
772        let log_path = PathBuf::from("/tmp/issue171.log");
773        let script = build_detached_docker_completion_script(
774            "issue171-container",
775            DockerContainerCleanupPolicy::Keep,
776            Some(&log_path),
777            None,
778        );
779        assert!(script.contains("=== Container post-mortem ==="));
780        // The only `docker rm -f` left is the copy-paste hint in the kept message.
781        assert!(!script.contains("docker rm -f 'issue171-container' >>"));
782    }
783
784    /// The removal path is the one issue #171.3 is about: at least one line.
785    #[test]
786    fn always_removed_containers_get_the_removal_note() {
787        let log_path = PathBuf::from("/tmp/issue171.log");
788        let script = build_detached_docker_completion_script(
789            "issue171-container",
790            DockerContainerCleanupPolicy::Always,
791            Some(&log_path),
792            None,
793        );
794        assert!(script.contains("Container removed:"));
795        assert!(script.contains("docker rm -f 'issue171-container' >>"));
796    }
797
798    #[test]
799    fn attached_runs_read_every_documented_fact_in_one_inspect() {
800        let facts = parse_docker_container_state(
801            "demo",
802            "137 false 2026-09-15T22:21:40.942007645Z 2026-09-15T22:21:46.740817278Z",
803            Some(""),
804        );
805
806        assert_eq!(facts.container_name, "demo");
807        assert_eq!(facts.exit_code, Some(137));
808        assert_eq!(facts.oom_killed, Some(false));
809        assert_eq!(
810            facts.started_at.as_deref(),
811            Some("2026-09-15T22:21:40.942007645Z")
812        );
813        assert_eq!(
814            facts.finished_at.as_deref(),
815            Some("2026-09-15T22:21:46.740817278Z")
816        );
817        assert_eq!(facts.error, None);
818    }
819
820    #[test]
821    fn attached_runs_reject_the_zero_time_of_a_container_that_never_started() {
822        let facts = parse_docker_container_state(
823            "demo",
824            "125 false 0001-01-01T00:00:00Z 0001-01-01T00:00:00Z",
825            Some("no such file or directory"),
826        );
827
828        assert_eq!(facts.started_at, None);
829        assert_eq!(facts.finished_at, None);
830        assert_eq!(facts.error.as_deref(), Some("no such file or directory"));
831    }
832
833    #[test]
834    fn attached_runs_survive_an_inspect_that_answered_nothing_useful() {
835        let facts = parse_docker_container_state("demo", "", None);
836
837        assert_eq!(facts.exit_code, None);
838        assert_eq!(facts.oom_killed, None);
839        assert_eq!(facts.started_at, None);
840        assert_eq!(facts.error, None);
841    }
842
843    #[test]
844    fn attached_kept_containers_get_the_post_mortem_block_in_their_log() {
845        let dir =
846            std::env::temp_dir().join(format!("start-attached-kept-171-{}", std::process::id()));
847        std::fs::create_dir_all(&dir).unwrap();
848        let log_path = dir.join("run.log");
849        std::fs::write(&log_path, "output\n").unwrap();
850
851        let facts = ContainerPostMortem {
852            container_name: "demo".to_string(),
853            exit_code: Some(137),
854            oom_killed: Some(false),
855            started_at: Some("2026-09-15T22:21:40.942007645Z".to_string()),
856            finished_at: Some("2026-09-15T22:21:46.740817278Z".to_string()),
857            error: None,
858        };
859        let message = record_attached_docker_post_mortem(Some(&facts), Some(&log_path), false);
860
861        let log = std::fs::read_to_string(&log_path).unwrap();
862        assert!(log.contains(POST_MORTEM_HEADER));
863        assert!(log.contains("Exit Code:  137 (SIGKILL - 128+9)"));
864        assert!(log.contains("Lifetime:   5.798s"));
865        assert!(message.contains(POST_MORTEM_HEADER));
866        let _ = std::fs::remove_dir_all(&dir);
867    }
868
869    #[test]
870    fn attached_removed_containers_get_the_one_line_note() {
871        let dir =
872            std::env::temp_dir().join(format!("start-attached-removed-171-{}", std::process::id()));
873        std::fs::create_dir_all(&dir).unwrap();
874        let log_path = dir.join("run.log");
875        std::fs::write(&log_path, "output\n").unwrap();
876
877        let facts = ContainerPostMortem {
878            container_name: "demo".to_string(),
879            exit_code: Some(0),
880            oom_killed: Some(false),
881            started_at: Some("2026-09-15T22:21:40.942007645Z".to_string()),
882            finished_at: Some("2026-09-15T22:21:46.740817278Z".to_string()),
883            error: None,
884        };
885        let message = record_attached_docker_post_mortem(Some(&facts), Some(&log_path), true);
886
887        assert!(std::fs::read_to_string(&log_path)
888            .unwrap()
889            .contains("Container removed: demo (exit 0, lifetime 5.798s, oomKilled=false)"));
890        assert_eq!(
891            message,
892            "\nContainer removed: demo (exit 0, lifetime 5.798s, oomKilled=false)"
893        );
894        let _ = std::fs::remove_dir_all(&dir);
895    }
896
897    #[test]
898    fn attached_runs_stay_silent_when_docker_could_not_be_inspected() {
899        let dir =
900            std::env::temp_dir().join(format!("start-attached-silent-171-{}", std::process::id()));
901        std::fs::create_dir_all(&dir).unwrap();
902        let log_path = dir.join("run.log");
903        std::fs::write(&log_path, "output\n").unwrap();
904
905        assert_eq!(
906            record_attached_docker_post_mortem(None, Some(&log_path), false),
907            ""
908        );
909        assert_eq!(std::fs::read_to_string(&log_path).unwrap(), "output\n");
910        let _ = std::fs::remove_dir_all(&dir);
911    }
912}