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