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, resource limits). 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    // Already in `--flag=value` form (issue #176).
77    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
175/// Read a finished container's post-mortem facts in a single `docker inspect`.
176///
177/// The attached path only ever inspected `State.OOMKilled`, which left its
178/// "Container kept for investigation" message unable to say *why* the container
179/// died — the same gap the detached watcher had (issue #171.1). Reading the
180/// facts once, before any `docker rm`, also makes them available to the removal
181/// path, where the container is about to stop existing (issue #171.3).
182pub(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    // `State.Error` is free-form text that may contain spaces, so it cannot
195    // ride along in the whitespace-separated template above.
196    let error = inspect("{{.State.Error}}");
197    Some(parse_docker_container_state(
198        container_name,
199        &state,
200        error.as_deref(),
201    ))
202}
203
204/// Turn the raw output of [`DOCKER_STATE_INSPECT_FORMAT`] into facts.
205///
206/// Split from the `docker inspect` call so the mapping — including the
207/// rejection of docker's zero time — is testable without a docker daemon or a
208/// process-wide `START_DOCKER_BIN` override.
209pub(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
236/// Write the post-mortem for an attached run into its log and return the same
237/// text for the console message.
238///
239/// Kept and removed containers both get their facts recorded — a kept container
240/// gets the full block, a removed one the single line — so the log of an
241/// attached run carries exactly what the detached watcher writes (issues
242/// #171.2 and #171.3).
243pub(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        // A runtime that aborts on its own memory limit never trips the
322        // container flag, so `oomKilled false` alone would contradict the
323        // `FATAL ERROR` the runtime just printed into this very log (issue
324        // #165). Best effort: the tail is read right after the child exits.
325        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
380/// Build the shell the detached completion watcher runs after the container is
381/// gone.
382///
383/// Order matters. The post-mortem block and the removal note go in *before* the
384/// footer, so the `Finished:`/`Exit Code:` pair stays the last thing in the log
385/// (issue #171.2); the store is finalized *after* the footer, so a record only
386/// becomes terminal once its log is complete (issue #170.1).
387///
388/// It only does any of that once the container has really exited: the return of
389/// `docker logs -f` / `docker wait` is not proof of exit (issue #174). A
390/// container that is somehow still running afterwards is never removed, gets no
391/// `Exit Code:` footer and is never finalized — its record stays `executing`.
392pub 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/// Extra behaviour of a detached completion watcher (issue #176).
408#[derive(Clone, Debug, Default, PartialEq, Eq)]
409pub struct DockerWatcherOptions {
410    /// Limits `docker logs` to output after a restart, so a resumed run does
411    /// not copy the previous run's output into the log again.
412    pub since: Option<String>,
413    /// Hand a killed main process (exit 137, or `OOMKilled` without a usable
414    /// exit code, issue #178) to the recovery entry point first. When it
415    /// resumes the container, this watcher stops there — a new one follows
416    /// the resumed run — and cleanup, footer and finalization are left to
417    /// whichever watcher sees the last run end.
418    pub recover_on_kill: bool,
419}
420
421/// [`build_detached_docker_completion_script`] with [`DockerWatcherOptions`].
422pub 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    // Everything that assumes the container has exited: cleanup, footer and
432    // finalization. Guarded as a whole by `.State.Running` below.
433    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        // A kept container is exactly the case the user will investigate, so it
459        // gets the full post-mortem before the copy-paste instructions.
460        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            // Previously wrote nothing at all: the container is always kept, so
476            // the log said nothing about why it stopped (issue #171.2).
477            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        // Last, so the record is only marked terminal once the log is complete.
506        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    /// Evaluate the reason snippet the way the watcher does, in a real shell.
714    #[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        // The footer is printed a few lines below the runtime's own
734        // `FATAL ERROR: Reached heap limit ...`; it must not contradict it.
735        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    /// Issue #170.1: the watcher must hand the terminal state to the finalizer,
766    /// so a detached record stops being `executing` forever.
767    #[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        // Bookkeeping runs last: a record only becomes terminal once the log is
781        // complete, and a failed finalization can never abort the cleanup.
782        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    /// Without an execution id (an un-tracked run) nothing is finalized.
790    #[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    /// Issue #171.1/.2/.3: every cleanup path states the post-mortem facts.
803    #[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    /// A container that is always kept used to produce no completion output at
821    /// all; it must still get its post-mortem (issue #171.2).
822    #[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        // The only `docker rm -f` left is the copy-paste hint in the kept message.
833        assert!(!script.contains("docker rm -f 'issue171-container' >>"));
834    }
835
836    /// The removal path is the one issue #171.3 is about: at least one line.
837    #[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}