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