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_with_attempt;
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) and the START_* execution identity.
54pub fn build_docker_runtime_args(options: &IsolationOptions) -> Vec<String> {
55    let mut args: Vec<String> = Vec::new();
56    let mut push = |flag: &str, value: &str| {
57        args.push(flag.into());
58        args.push(value.into());
59    };
60    for env_var in &options.env {
61        push("-e", env_var);
62    }
63    // Execution identity (issue #230); last, so a user `--env` cannot spoof it.
64    for env_var in crate::execution_env::execution_env_assignments(
65        &crate::execution_env::execution_env_from_options(options),
66    ) {
67        push("-e", &env_var);
68    }
69    for label in &options.labels {
70        push("--label", label);
71    }
72    for volume in &options.volumes {
73        push("-v", volume);
74    }
75    for mount in &options.mounts {
76        push("--mount", mount);
77    }
78    if let Some(network) = docker_networks(options).first() {
79        push("--network", network);
80    }
81    for alias in &options.network_aliases {
82        push("--network-alias", alias);
83    }
84    if options.privileged {
85        args.insert(0, "--privileged".into());
86    }
87    // Already in `--flag=value` form (issue #176).
88    args.extend(options.resource_limits.iter().cloned());
89    args
90}
91
92pub(crate) fn docker_networks(options: &IsolationOptions) -> Vec<&str> {
93    if options.networks.is_empty() {
94        options.network.iter().map(String::as_str).collect()
95    } else {
96        options.networks.iter().map(String::as_str).collect()
97    }
98}
99
100#[derive(Clone, Copy, Debug, Eq, PartialEq)]
101pub enum DockerContainerCleanupPolicy {
102    Default,
103    Always,
104    Keep,
105    KeepOnFail,
106}
107
108pub fn docker_command() -> std::ffi::OsString {
109    std::env::var_os("START_DOCKER_BIN").unwrap_or_else(|| std::ffi::OsString::from("docker"))
110}
111
112pub(crate) fn get_docker_container_cleanup_policy(
113    options: &IsolationOptions,
114) -> DockerContainerCleanupPolicy {
115    if options.keep_container {
116        DockerContainerCleanupPolicy::Keep
117    } else if options.keep_container_on_fail {
118        DockerContainerCleanupPolicy::KeepOnFail
119    } else if options.always_cleanup_container || options.auto_remove_docker_container {
120        DockerContainerCleanupPolicy::Always
121    } else {
122        DockerContainerCleanupPolicy::Default
123    }
124}
125
126pub(crate) fn is_abnormal_docker_exit(exit_code: i32, oom_killed: bool) -> bool {
127    exit_code != 0 || oom_killed
128}
129
130pub(crate) fn should_cleanup_docker_container(
131    policy: DockerContainerCleanupPolicy,
132    exit_code: i32,
133    oom_killed: bool,
134) -> bool {
135    match policy {
136        DockerContainerCleanupPolicy::Default => !is_abnormal_docker_exit(exit_code, oom_killed),
137        DockerContainerCleanupPolicy::Always => true,
138        DockerContainerCleanupPolicy::Keep => false,
139        DockerContainerCleanupPolicy::KeepOnFail => !is_abnormal_docker_exit(exit_code, oom_killed),
140    }
141}
142
143pub(crate) fn docker_container_cleanup_instructions(container_name: &str) -> String {
144    format!(
145        "Container kept for investigation: {}\nRe-enter while running: $ --attach {}\nContinue the stored command: $ --resume {}\nRun another command (legacy containers snapshot the filesystem into a new container): $ --resume {} -- <command>\nRemove when done: docker rm -f {}",
146        container_name, container_name, container_name, container_name, container_name
147    )
148}
149
150pub(crate) fn append_docker_container_cleanup_policy_message(
151    message: &mut String,
152    container_name: &str,
153    policy: DockerContainerCleanupPolicy,
154) {
155    match policy {
156        DockerContainerCleanupPolicy::Always => {
157            message.push_str("\nContainer will be removed after command completes.");
158        }
159        DockerContainerCleanupPolicy::Default => {
160            message.push_str("\nContainer will be removed after successful completion.");
161            message.push_str(
162                "\nContainer will be kept if the command fails or Docker reports OOMKilled.",
163            );
164            message.push_str(&format!(
165                "\nRemove when done: docker rm -f {}",
166                container_name
167            ));
168        }
169        DockerContainerCleanupPolicy::Keep => {
170            message.push('\n');
171            message.push_str(&docker_container_cleanup_instructions(container_name));
172        }
173        DockerContainerCleanupPolicy::KeepOnFail => {
174            message.push_str("\nContainer will be removed after successful completion.");
175            message.push_str(
176                "\nContainer will be kept if the command fails or Docker reports OOMKilled.",
177            );
178            message.push_str(&format!(
179                "\nRemove when done: docker rm -f {}",
180                container_name
181            ));
182        }
183    }
184}
185
186/// Read a finished container's post-mortem facts in a single `docker inspect`.
187///
188/// The attached path only ever inspected `State.OOMKilled`, which left its
189/// "Container kept for investigation" message unable to say *why* the container
190/// died — the same gap the detached watcher had (issue #171.1). Reading the
191/// facts once, before any `docker rm`, also makes them available to the removal
192/// path, where the container is about to stop existing (issue #171.3).
193pub(crate) fn read_docker_container_state(container_name: &str) -> Option<ContainerPostMortem> {
194    let inspect = |format: &str| -> Option<String> {
195        let output = Command::new(docker_command())
196            .args(["inspect", "-f", format, container_name])
197            .output()
198            .ok()?;
199        if !output.status.success() {
200            return None;
201        }
202        Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
203    };
204    let state = inspect(DOCKER_STATE_INSPECT_FORMAT)?;
205    // `State.Error` is free-form text that may contain spaces, so it cannot
206    // ride along in the whitespace-separated template above.
207    let error = inspect("{{.State.Error}}");
208    Some(parse_docker_container_state(
209        container_name,
210        &state,
211        error.as_deref(),
212    ))
213}
214
215/// Turn the raw output of [`DOCKER_STATE_INSPECT_FORMAT`] into facts.
216///
217/// Split from the `docker inspect` call so the mapping — including the
218/// rejection of docker's zero time — is testable without a docker daemon or a
219/// process-wide `START_DOCKER_BIN` override.
220pub(crate) fn parse_docker_container_state(
221    container_name: &str,
222    state: &str,
223    error: Option<&str>,
224) -> ContainerPostMortem {
225    let mut parts = state.split_whitespace();
226    let exit_code = parts.next().and_then(|value| value.parse::<i32>().ok());
227    let oom_killed = match parts.next() {
228        Some("true") => Some(true),
229        Some("false") => Some(false),
230        _ => None,
231    };
232    let started_at = normalize_docker_timestamp(parts.next());
233    let finished_at = normalize_docker_timestamp(parts.next());
234    ContainerPostMortem {
235        container_name: container_name.to_string(),
236        exit_code,
237        oom_killed,
238        started_at,
239        finished_at,
240        error: error
241            .map(str::trim)
242            .filter(|text| !text.is_empty())
243            .map(str::to_string),
244    }
245}
246
247/// Write the post-mortem for an attached run into its log and return the same
248/// text for the console message.
249///
250/// Kept and removed containers both get their facts recorded — a kept container
251/// gets the full block, a removed one the single line — so the log of an
252/// attached run carries exactly what the detached watcher writes (issues
253/// #171.2 and #171.3).
254pub(crate) fn record_attached_docker_post_mortem(
255    facts: Option<&ContainerPostMortem>,
256    log_path: Option<&PathBuf>,
257    removed: bool,
258) -> String {
259    let Some(facts) = facts else {
260        return String::new();
261    };
262    let text = if removed {
263        format_container_removal_note(facts)
264    } else {
265        format_container_post_mortem(facts)
266    };
267    if let Some(path) = log_path {
268        append_log_file(path, &format!("\n{}", text));
269    }
270    format!("\n{}", text.trim_end())
271}
272
273pub(crate) fn read_docker_container_status(container_name: &str) -> Option<String> {
274    let output = Command::new(docker_command())
275        .args(["inspect", "-f", "{{.State.Status}}", container_name])
276        .output()
277        .ok()?;
278    if !output.status.success() {
279        return None;
280    }
281    let status = String::from_utf8_lossy(&output.stdout).trim().to_string();
282    if status.is_empty() {
283        None
284    } else {
285        Some(status)
286    }
287}
288
289/// Why an attached container was kept. The OOM flag is container-wide (#180):
290/// a child OOM-killed under a command that exited on its own is not the
291/// command being OOM-killed.
292pub(crate) fn attached_docker_kept_reason(exit_code: i32, oom_killed: bool) -> &'static str {
293    if !oom_killed {
294        "\nContainer kept because the command failed."
295    } else if is_oom_kill_of_command(Some(exit_code), Some(true), None) {
296        "\nContainer kept because Docker reports it was OOM-killed."
297    } else {
298        "\nContainer kept because Docker reports a process in it was OOM-killed."
299    }
300}
301
302pub(crate) fn append_attached_docker_cleanup_message(
303    message: &mut String,
304    container_name: &str,
305    policy: DockerContainerCleanupPolicy,
306    exit_code: i32,
307    log_path: Option<&PathBuf>,
308    container_existed_before_launch: bool,
309) -> bool {
310    let state = read_docker_container_state(container_name);
311    let oom_killed = state
312        .as_ref()
313        .and_then(|facts| facts.oom_killed)
314        .unwrap_or(false);
315    let post_mortem =
316        |removed: bool| record_attached_docker_post_mortem(state.as_ref(), log_path, removed);
317    if !container_existed_before_launch
318        && read_docker_container_status(container_name).as_deref() == Some("created")
319    {
320        if remove_docker_container(container_name, log_path) {
321            message.push_str("\nContainer removed after launch failure.");
322            return true;
323        } else {
324            message.push_str("\nWarning: failed to remove container after launch failure.");
325        }
326    } else if should_cleanup_docker_container(policy, exit_code, oom_killed) {
327        if remove_docker_container(container_name, log_path) {
328            message.push_str("\nContainer removed after completion.");
329            message.push_str(&post_mortem(true));
330            return true;
331        } else {
332            message.push_str("\nWarning: failed to remove container automatically.");
333            message.push_str(&format!(
334                "\nRemove when done: docker rm -f {container_name}"
335            ));
336        }
337    } else if policy == DockerContainerCleanupPolicy::Keep {
338        message.push('\n');
339        message.push_str(&docker_container_cleanup_instructions(container_name));
340        message.push_str(&post_mortem(false));
341    } else {
342        let tail = log_path
343            .and_then(|path| read_log_tail(&path.to_string_lossy(), FATAL_MARKER_TAIL_BYTES));
344        if crate::exit_reason::resolve_exit_reason(
345            Some(exit_code),
346            tail.as_deref(),
347            Some(oom_killed),
348            None,
349        )
350        .as_deref()
351            == Some(crate::exit_reason::CGROUP_OOM_EXIT_REASON)
352        {
353            message.push_str("\nContainer kept because Docker reports it was OOM-killed.");
354        } else {
355            message.push_str(attached_docker_kept_reason(exit_code, oom_killed));
356        }
357        // A runtime that aborts on its own memory limit never trips the
358        // container flag, so `oomKilled false` alone would contradict the
359        // `FATAL ERROR` the runtime just printed into this very log (issue
360        // #165). Best effort: the tail is read right after the child exits.
361        if let Some(memory) =
362            resolve_memory_exhaustion(Some(exit_code), tail.as_deref(), Some(oom_killed), None)
363        {
364            message.push_str(&format!(
365                "\nMemory exhaustion detected in the log: {}",
366                memory.memory_exhausted_reason
367            ));
368        }
369        message.push_str(&format!(
370            "\nRemove when done: docker rm -f {container_name}"
371        ));
372        message.push_str(&post_mortem(false));
373    }
374    false
375}
376
377pub(crate) fn remove_docker_container(container_name: &str, log_path: Option<&PathBuf>) -> bool {
378    let output = Command::new(docker_command())
379        .args(["rm", "-f", container_name])
380        .output();
381    match output {
382        Ok(output) => {
383            if let Some(path) = log_path {
384                let mut combined = String::new();
385                combined.push_str(&String::from_utf8_lossy(&output.stdout));
386                combined.push_str(&String::from_utf8_lossy(&output.stderr));
387                if !combined.is_empty() {
388                    let content = if combined.ends_with('\n') {
389                        combined
390                    } else {
391                        format!("{}\n", combined)
392                    };
393                    append_log_file(path, &content);
394                }
395            }
396            output.status.success()
397        }
398        Err(_) => false,
399    }
400}
401
402fn build_docker_kept_log_snippet(container_name: &str, quoted_log_path: &str) -> String {
403    let quoted_name = shell_quote(container_name);
404    format!(
405        "{}; printf '\\nContainer kept for investigation: %s\\nReason: %s\\nRe-enter while running: $ --attach %s\\nContinue the stored command: $ --resume %s\\nRun another command (legacy containers snapshot the filesystem into a new container): $ --resume %s -- <command>\\nRemove when done: docker rm -f %s\\n' {} \"$__start_command_reason\" {} {} {} {} >> {}",
406        build_docker_kept_reason_snippet(),
407        quoted_name, quoted_name, quoted_name, quoted_name, quoted_name, quoted_log_path
408    )
409}
410
411fn successful_non_oom_condition() -> &'static str {
412    "[ \"$__start_command_exit\" -eq 0 ] 2>/dev/null && [ \"$__start_command_oom\" != true ]"
413}
414
415/// Build the shell the detached completion watcher runs after the container is
416/// gone.
417///
418/// Order matters. The post-mortem block and the removal note go in *before* the
419/// footer, so the `Finished:`/`Exit Code:` pair stays the last thing in the log
420/// (issue #171.2); the store is finalized *after* the footer, so a record only
421/// becomes terminal once its log is complete (issue #170.1).
422///
423/// It only does any of that once the container has really exited: the return of
424/// `docker logs -f` / `docker wait` is not proof of exit (issue #174). A
425/// container that is somehow still running afterwards is never removed, gets no
426/// `Exit Code:` footer and is never finalized — its record stays `executing`.
427pub fn build_detached_docker_completion_script(
428    container_name: &str,
429    policy: DockerContainerCleanupPolicy,
430    log_path: Option<&PathBuf>,
431    execution_id: Option<&str>,
432) -> String {
433    build_detached_docker_completion_script_with(
434        container_name,
435        policy,
436        log_path,
437        execution_id,
438        &DockerWatcherOptions::default(),
439    )
440}
441
442/// Extra behaviour of a detached completion watcher (issue #176).
443#[derive(Clone, Debug, Default, PartialEq, Eq)]
444pub struct DockerWatcherOptions {
445    /// Limits `docker logs` to output after a restart, so a resumed run does
446    /// not copy the previous run's output into the log again.
447    pub since: Option<String>,
448    pub attempt_number: Option<u64>,
449    /// Hand a killed main process (exit 137, or `OOMKilled` without a usable
450    /// exit code, issue #178) to the recovery entry point first. When it
451    /// resumes the container, this watcher stops there — a new one follows
452    /// the resumed run — and cleanup, footer and finalization are left to
453    /// whichever watcher sees the last run end.
454    pub recover_on_kill: bool,
455}
456
457/// [`build_detached_docker_completion_script`] with [`DockerWatcherOptions`].
458pub fn build_detached_docker_completion_script_with(
459    container_name: &str,
460    policy: DockerContainerCleanupPolicy,
461    log_path: Option<&PathBuf>,
462    execution_id: Option<&str>,
463    watcher: &DockerWatcherOptions,
464) -> String {
465    let quoted_name = shell_quote(container_name);
466    // The container's cgroup disappears with it, so its memory counters are
467    // sampled while it runs (issue #182).
468    let mut parts = vec![build_cgroup_sampler_start_snippet(container_name)];
469    let cpu_start = crate::cpu_penalty_monitor::start_snippet(
470        execution_id,
471        container_name,
472        watcher.attempt_number,
473    );
474    if !cpu_start.is_empty() {
475        parts.push(cpu_start);
476    }
477    // Everything that assumes the container has exited: cleanup, footer and
478    // finalization. Guarded as a whole by `.State.Running` below.
479    // `removed` tells the finalizer whether the cleanup policy removed the
480    // container (issue #225).
481    let removed = crate::docker_post_mortem::shell_vars::REMOVED;
482    let mut exited = vec![format!("{}=false", removed)];
483    let quoted_log_path = log_path.map(|path| shell_quote(&path.to_string_lossy()));
484
485    if let Some(quoted_log_path) = quoted_log_path.as_deref() {
486        let since = watcher
487            .since
488            .as_deref()
489            .map(|since| format!(" --since {}", shell_quote(since)))
490            .unwrap_or_default();
491        if let Some(number) = watcher.attempt_number {
492            let executable = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("start"));
493            parts.push(format!(
494                "docker logs -f --timestamps{} {} 2>&1 | {} {} {} {} {}",
495                since,
496                quoted_name,
497                shell_quote(&executable.to_string_lossy()),
498                crate::detached_output::INTERNAL_OUTPUT_FLAG,
499                quoted_log_path,
500                number,
501                shell_quote(watcher.since.as_deref().unwrap_or(""))
502            ));
503        } else {
504            parts.push(format!(
505                "docker logs -f{} {} >> {} 2>&1",
506                since, quoted_name, quoted_log_path
507            ));
508        }
509        parts.push(build_docker_wait_for_exit_snippet(
510            container_name,
511            Some(quoted_log_path),
512        ));
513        parts.push(crate::cpu_penalty_monitor::stop_snippet());
514        parts.push(build_cgroup_sampler_stop_snippet());
515        parts.push(build_docker_state_snippet(container_name));
516        parts.push(crate::exit_evidence::snippet(
517            container_name,
518            quoted_log_path,
519        ));
520
521        let memory = build_cgroup_memory_log_snippet(quoted_log_path);
522        let (capture_image, remove_image) =
523            crate::docker_snapshot_safety::snapshot_image_cleanup_snippets(
524                container_name,
525                &format!(">> {} 2>&1", quoted_log_path),
526            );
527        let remove = format!(
528            "{}; if docker rm -f {} >> {} 2>&1; then {}=true; {}; fi; {}; {}",
529            capture_image,
530            quoted_name,
531            quoted_log_path,
532            removed,
533            remove_image,
534            build_docker_removal_note_snippet(container_name, quoted_log_path),
535            memory
536        );
537        // A kept container is exactly the case the user will investigate, so it
538        // gets the full post-mortem before the copy-paste instructions.
539        let keep = format!(
540            "{}; {}; {}",
541            build_docker_post_mortem_snippet(container_name, quoted_log_path),
542            memory,
543            build_docker_kept_log_snippet(container_name, quoted_log_path)
544        );
545        match policy {
546            DockerContainerCleanupPolicy::Always => exited.push(remove),
547            DockerContainerCleanupPolicy::Default | DockerContainerCleanupPolicy::KeepOnFail => {
548                exited.push(format!(
549                    "if {}; then {}; else {}; fi",
550                    successful_non_oom_condition(),
551                    remove,
552                    keep
553                ))
554            }
555            // Previously wrote nothing at all: the container is always kept, so
556            // the log said nothing about why it stopped (issue #171.2).
557            DockerContainerCleanupPolicy::Keep => exited.push(keep),
558        }
559        exited.push(format!(
560            "{} >> {}",
561            create_shell_log_footer_snippet(),
562            quoted_log_path
563        ));
564    } else {
565        parts.push(format!("docker wait {} >/dev/null 2>&1", quoted_name));
566        parts.push(build_docker_wait_for_exit_snippet(container_name, None));
567        parts.push(crate::cpu_penalty_monitor::stop_snippet());
568        parts.push(build_cgroup_sampler_stop_snippet());
569        parts.push(build_docker_state_snippet(container_name));
570        let (capture_image, remove_image) =
571            crate::docker_snapshot_safety::snapshot_image_cleanup_snippets(
572                container_name,
573                ">/dev/null 2>&1",
574            );
575        match policy {
576            DockerContainerCleanupPolicy::Always => exited.push(format!(
577                "{}; if docker rm -f {} >/dev/null 2>&1; then {}=true; {}; fi",
578                capture_image, quoted_name, removed, remove_image
579            )),
580            DockerContainerCleanupPolicy::Default | DockerContainerCleanupPolicy::KeepOnFail => {
581                exited.push(format!(
582                    "if {}; then {}; if docker rm -f {} >/dev/null 2>&1; then {}=true; {}; fi; fi",
583                    successful_non_oom_condition(),
584                    capture_image,
585                    quoted_name,
586                    removed,
587                    remove_image
588                ))
589            }
590            DockerContainerCleanupPolicy::Keep => {}
591        }
592    }
593
594    if let Some(execution_id) = execution_id {
595        // Last, so the record is only marked terminal once the log is complete.
596        exited.push(build_detached_finalize_snippet_with_attempt(
597            execution_id,
598            watcher.attempt_number,
599        ));
600    }
601
602    let still_running = quoted_log_path
603        .as_deref()
604        .map(|log| build_docker_still_running_note_snippet(container_name, log))
605        .unwrap_or_else(|| ":".to_string());
606    let exited = if exited.is_empty() {
607        ":".to_string()
608    } else {
609        exited.join("; ")
610    };
611    let recovery = match execution_id {
612        Some(execution_id) if watcher.recover_on_kill => format!(
613            "elif {}; then :; ",
614            crate::execution_recovery::build_recovery_snippet(execution_id)
615        ),
616        _ => String::new(),
617    };
618    parts.push(format!(
619        "if [ \"${}\" = true ]; then {}; {}else {}; fi",
620        shell_vars::RUNNING,
621        still_running,
622        recovery,
623        exited
624    ));
625
626    parts.join("; ")
627}
628
629pub(crate) fn start_detached_docker_completion_watcher_with(
630    container_name: &str,
631    policy: DockerContainerCleanupPolicy,
632    log_path: Option<&PathBuf>,
633    execution_id: Option<&str>,
634    watcher: &DockerWatcherOptions,
635) -> Result<(), String> {
636    let script = build_detached_docker_completion_script_with(
637        container_name,
638        policy,
639        log_path,
640        execution_id,
641        watcher,
642    );
643    Command::new("sh")
644        .args(["-c", &script])
645        .stdout(Stdio::null())
646        .stderr(Stdio::null())
647        .spawn()
648        .map(|_| ())
649        .map_err(|error| error.to_string())
650}
651
652pub(crate) struct AttachedDockerChild {
653    child: Child,
654    stdout_thread: Option<thread::JoinHandle<()>>,
655    stderr_thread: Option<thread::JoinHandle<()>>,
656}
657
658impl AttachedDockerChild {
659    pub(crate) fn wait(mut self) -> std::io::Result<ExitStatus> {
660        let status = self.child.wait();
661        if let Some(handle) = self.stdout_thread.take() {
662            let _ = handle.join();
663        }
664        if let Some(handle) = self.stderr_thread.take() {
665            let _ = handle.join();
666        }
667        status
668    }
669}
670
671pub(crate) fn spawn_attached_docker(
672    args: &[&str],
673    log_path: Option<&PathBuf>,
674) -> std::io::Result<AttachedDockerChild> {
675    if log_path.is_none() {
676        let child = Command::new(docker_command())
677            .args(args)
678            .stdin(Stdio::inherit())
679            .stdout(Stdio::inherit())
680            .stderr(Stdio::inherit())
681            .spawn()?;
682        return Ok(AttachedDockerChild {
683            child,
684            stdout_thread: None,
685            stderr_thread: None,
686        });
687    }
688
689    let mut child = Command::new(docker_command())
690        .args(args)
691        .stdin(Stdio::inherit())
692        .stdout(Stdio::piped())
693        .stderr(Stdio::piped())
694        .spawn()?;
695
696    let file = OpenOptions::new()
697        .create(true)
698        .append(true)
699        .open(log_path.unwrap())?;
700    let shared_log = Arc::new(Mutex::new(file));
701
702    let mut stdout_thread = None;
703    let mut stderr_thread = None;
704
705    if let Some(mut stdout) = child.stdout.take() {
706        let log = Arc::clone(&shared_log);
707        stdout_thread = Some(thread::spawn(move || {
708            let mut buffer = [0_u8; 8192];
709            let mut terminal = std::io::stdout();
710            while let Ok(size) = stdout.read(&mut buffer) {
711                if size == 0 {
712                    break;
713                }
714                let chunk = &buffer[..size];
715                let _ = terminal.write_all(chunk);
716                let _ = terminal.flush();
717                if let Ok(mut file) = log.lock() {
718                    let _ = file.write_all(chunk);
719                    let _ = file.flush();
720                }
721            }
722        }));
723    }
724
725    if let Some(mut stderr) = child.stderr.take() {
726        let log = Arc::clone(&shared_log);
727        stderr_thread = Some(thread::spawn(move || {
728            let mut buffer = [0_u8; 8192];
729            let mut terminal = std::io::stderr();
730            while let Ok(size) = stderr.read(&mut buffer) {
731                if size == 0 {
732                    break;
733                }
734                let chunk = &buffer[..size];
735                let _ = terminal.write_all(chunk);
736                let _ = terminal.flush();
737                if let Ok(mut file) = log.lock() {
738                    let _ = file.write_all(chunk);
739                    let _ = file.flush();
740                }
741            }
742        }));
743    }
744
745    Ok(AttachedDockerChild {
746        child,
747        stdout_thread,
748        stderr_thread,
749    })
750}
751
752#[cfg(test)]
753#[path = "docker_cleanup_cases.rs"]
754mod tests;