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