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