Skip to main content

inferlab_runtime/
container.rs

1//! One authority for docker container lifecycle mechanics
2//! ([[RFC-0003:C-RUNTIME-WORKFLOWS]]): bounded client invocation with
3//! concurrently drained pipes, and confirmed container removal on the
4//! launch machine. Callers own their argv assembly, evidence shapes, and
5//! operator-facing messages — this module owns only the mechanics that
6//! have actually failed in the field: the pipe-capacity deadlock, the
7//! deadline kill, and the container that outlives its docker client.
8
9use serde::{Deserialize, Serialize};
10use std::io::{Read, Write};
11use std::os::unix::process::CommandExt;
12use std::path::Path;
13use std::process::{Command, ExitStatus, Stdio};
14use std::time::Duration;
15use wait_timeout::ChildExt;
16
17use crate::operation_bound::{OperationBound, Remaining, duration_millis};
18
19/// How long a removal may take before it is reported as unconfirmed: a
20/// wedged daemon is a plausible cause of the original deadline miss, and a
21/// cleanup path must not hang on its own cleanup.
22pub const REMOVAL_TIMEOUT: Duration = Duration::from_secs(30);
23const INTERRUPT_POLL_INTERVAL: Duration = Duration::from_millis(50);
24const COMMAND_REAP_GRACE: Duration = Duration::from_secs(5);
25const COMMAND_IO_DRAIN_GRACE: Duration = Duration::from_secs(5);
26
27/// The `--gpus` argv pair for a device spec (a single index or a
28/// comma-joined list). The literal quotes are part of the value: docker's
29/// `--gpus` parser splits an unquoted `device=0,1` on the comma and exposes
30/// only the first device (verified on real hardware in v1.5).
31pub fn docker_device_args(spec: &str) -> [String; 2] {
32    ["--gpus".to_owned(), format!("\"device={spec}\"")]
33}
34
35/// The `--mount` argv pair for a read-only host bind. The explicit long
36/// form, not the `-v` shorthand: at least one site docker proxy mis-parses
37/// the shorthand's `:ro` suffix on same-path binds and silently drops the
38/// mount (verified on real hardware), while the long form passes through.
39pub fn docker_bind_mount_readonly(source: &str, target: &str) -> [String; 2] {
40    [
41        "--mount".to_owned(),
42        format!("type=bind,source={source},target={target},readonly"),
43    ]
44}
45
46/// A bounded invocation that ran to an observable end.
47pub enum BoundedWait {
48    Exited {
49        status: ExitStatus,
50        stdout: Vec<u8>,
51        stderr: Vec<u8>,
52    },
53    /// The effective wait bound expired: the client was killed and reaped.
54    /// The caller remains responsible for deciding whether the owning
55    /// operation expired or a shorter subordinate attempt cap fired.
56    Expired {
57        kill: std::io::Result<()>,
58        operation_elapsed_ms: u64,
59        cleanup: Option<CommandCleanupEvidence>,
60    },
61    /// A new operator interruption arrived after this command started. The
62    /// owned process group was terminated and reaped before returning.
63    Interrupted {
64        kill: std::io::Result<()>,
65        operation_elapsed_ms: u64,
66        cleanup: CommandCleanupEvidence,
67    },
68}
69
70#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
71#[serde(deny_unknown_fields)]
72pub struct CommandCleanupEvidence {
73    pub trigger: CommandCleanupTrigger,
74    pub elapsed_ms: u64,
75    pub reap_grace_ms: u64,
76    pub io_drain_grace_ms: u64,
77    pub kill_attempted: bool,
78    pub verified: bool,
79    pub error: Option<String>,
80}
81
82#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
83#[serde(rename_all = "snake_case")]
84pub enum CommandCleanupTrigger {
85    Deadline,
86    Interruption,
87    WaitFailure,
88}
89
90/// Where a bounded invocation failed before producing a wait outcome.
91pub enum BoundedError {
92    Launch(std::io::Error),
93    Stdin(std::io::Error),
94    Wait(std::io::Error),
95    WaitCleanup {
96        source: std::io::Error,
97        operation_elapsed_ms: u64,
98        cleanup: CommandCleanupEvidence,
99    },
100}
101
102/// Spawn `argv`, optionally write `stdin_payload`, and wait under the
103/// operation's remaining budget and optional attempt cap while draining
104/// stdout and stderr concurrently: a child whose
105/// output exceeds the pipe capacity would otherwise block writing and
106/// never exit, turning a large response or traceback into a timeout.
107/// `env_remove` names inherited environment variables the child must not
108/// see (for example [`crate::ssh::SSH_ENV_REMOVE`] for a host SSH client).
109pub fn run_with_bound<S: AsRef<std::ffi::OsStr>>(
110    argv: &[S],
111    env_remove: &[&str],
112    cwd: Option<&Path>,
113    stdin_payload: Option<&[u8]>,
114    bound: &OperationBound,
115    attempt_cap: Option<Duration>,
116) -> Result<BoundedWait, BoundedError> {
117    run_with_bound_mode(
118        argv,
119        env_remove,
120        cwd,
121        stdin_payload,
122        bound,
123        attempt_cap,
124        true,
125        crate::interrupt::received,
126    )
127}
128
129pub fn run_cleanup_with_bound<S: AsRef<std::ffi::OsStr>>(
130    argv: &[S],
131    env_remove: &[&str],
132    cwd: Option<&Path>,
133    stdin_payload: Option<&[u8]>,
134    bound: &OperationBound,
135    attempt_cap: Option<Duration>,
136) -> Result<BoundedWait, BoundedError> {
137    run_with_bound_mode(
138        argv,
139        env_remove,
140        cwd,
141        stdin_payload,
142        bound,
143        attempt_cap,
144        false,
145        crate::interrupt::received,
146    )
147}
148
149#[allow(clippy::too_many_arguments)]
150fn run_with_bound_mode<S: AsRef<std::ffi::OsStr>, F: FnMut() -> bool>(
151    argv: &[S],
152    env_remove: &[&str],
153    cwd: Option<&Path>,
154    stdin_payload: Option<&[u8]>,
155    bound: &OperationBound,
156    attempt_cap: Option<Duration>,
157    interruptible: bool,
158    mut interrupted: F,
159) -> Result<BoundedWait, BoundedError> {
160    let attempt = bound.attempt(attempt_cap);
161    if matches!(attempt.remaining(), Remaining::Expired) {
162        return Ok(BoundedWait::Expired {
163            kill: Ok(()),
164            operation_elapsed_ms: bound.elapsed_ms(),
165            cleanup: None,
166        });
167    }
168    let Some(program) = argv.first() else {
169        return Err(BoundedError::Launch(std::io::Error::new(
170            std::io::ErrorKind::InvalidInput,
171            "external command argv is empty",
172        )));
173    };
174    let mut command = Command::new(program);
175    command
176        .args(&argv[1..])
177        .stdin(if stdin_payload.is_some() {
178            Stdio::piped()
179        } else {
180            Stdio::null()
181        })
182        .stdout(Stdio::piped())
183        .stderr(Stdio::piped())
184        .process_group(0);
185    for variable in env_remove {
186        command.env_remove(variable);
187    }
188    if let Some(cwd) = cwd {
189        command.current_dir(cwd);
190    }
191    let mut child = command.spawn().map_err(BoundedError::Launch)?;
192    let stdin_write = if let Some(payload) = stdin_payload {
193        let mut stdin = child
194            .stdin
195            .take()
196            .ok_or_else(|| BoundedError::Stdin(std::io::Error::other("stdin was not piped")))?;
197        let payload = payload.to_owned();
198        Some(std::thread::spawn(move || {
199            stdin.write_all(&payload).and_then(|()| stdin.flush())
200        }))
201    } else {
202        None
203    };
204    let mut stdout_pipe = child.stdout.take();
205    let stdout_drain = std::thread::spawn(move || {
206        let mut buffer = Vec::new();
207        if let Some(pipe) = stdout_pipe.as_mut() {
208            let _ = pipe.read_to_end(&mut buffer);
209        }
210        buffer
211    });
212    let mut stderr_pipe = child.stderr.take();
213    let stderr_drain = std::thread::spawn(move || {
214        let mut buffer = Vec::new();
215        if let Some(pipe) = stderr_pipe.as_mut() {
216            let _ = pipe.read_to_end(&mut buffer);
217        }
218        buffer
219    });
220    let status = loop {
221        if interruptible && interrupted() {
222            let operation_elapsed_ms = bound.elapsed_ms();
223            let (kill, cleanup) = terminate_group_and_finish_io(
224                &mut child,
225                stdin_write,
226                stdout_drain,
227                stderr_drain,
228                CommandCleanupTrigger::Interruption,
229            );
230            return Ok(BoundedWait::Interrupted {
231                kill,
232                operation_elapsed_ms,
233                cleanup,
234            });
235        }
236        let Some(wait) = wait_slice(&attempt) else {
237            let operation_elapsed_ms = bound.elapsed_ms();
238            let (kill, cleanup) = terminate_group_and_finish_io(
239                &mut child,
240                stdin_write,
241                stdout_drain,
242                stderr_drain,
243                CommandCleanupTrigger::Deadline,
244            );
245            return Ok(BoundedWait::Expired {
246                kill,
247                operation_elapsed_ms,
248                cleanup: Some(cleanup),
249            });
250        };
251        match child.wait_timeout(wait) {
252            Ok(Some(status)) => break status,
253            Ok(None) => {}
254            Err(error) => {
255                let operation_elapsed_ms = bound.elapsed_ms();
256                let (_, cleanup) = terminate_group_and_finish_io(
257                    &mut child,
258                    stdin_write,
259                    stdout_drain,
260                    stderr_drain,
261                    CommandCleanupTrigger::WaitFailure,
262                );
263                return Err(BoundedError::WaitCleanup {
264                    source: error,
265                    operation_elapsed_ms,
266                    cleanup,
267                });
268            }
269        }
270    };
271    while !io_finished(&stdin_write, &stdout_drain, &stderr_drain) {
272        if interruptible && interrupted() {
273            let operation_elapsed_ms = bound.elapsed_ms();
274            let (kill, cleanup) = terminate_group_and_finish_io(
275                &mut child,
276                stdin_write,
277                stdout_drain,
278                stderr_drain,
279                CommandCleanupTrigger::Interruption,
280            );
281            return Ok(BoundedWait::Interrupted {
282                kill,
283                operation_elapsed_ms,
284                cleanup,
285            });
286        }
287        let Some(wait) = wait_slice(&attempt) else {
288            let operation_elapsed_ms = bound.elapsed_ms();
289            let (kill, cleanup) = terminate_group_and_finish_io(
290                &mut child,
291                stdin_write,
292                stdout_drain,
293                stderr_drain,
294                CommandCleanupTrigger::Deadline,
295            );
296            return Ok(BoundedWait::Expired {
297                kill,
298                operation_elapsed_ms,
299                cleanup: Some(cleanup),
300            });
301        };
302        std::thread::sleep(wait);
303    }
304    let stdin_result = join_writer_result(stdin_write);
305    let stdout = join_drain(stdout_drain);
306    let stderr = join_drain(stderr_drain);
307    stdin_result.map_err(BoundedError::Stdin)?;
308    let stdout = stdout?;
309    let stderr = stderr?;
310    Ok(BoundedWait::Exited {
311        status,
312        stdout,
313        stderr,
314    })
315}
316
317fn wait_slice(attempt: &crate::operation_bound::AttemptBound) -> Option<Duration> {
318    match attempt.remaining() {
319        Remaining::Finite(remaining) => Some(remaining.min(INTERRUPT_POLL_INTERVAL)),
320        Remaining::Expired => None,
321        Remaining::Unbounded => Some(INTERRUPT_POLL_INTERVAL),
322    }
323}
324
325fn io_finished(
326    writer: &Option<std::thread::JoinHandle<std::io::Result<()>>>,
327    stdout: &std::thread::JoinHandle<Vec<u8>>,
328    stderr: &std::thread::JoinHandle<Vec<u8>>,
329) -> bool {
330    writer.as_ref().is_none_or(|writer| writer.is_finished())
331        && stdout.is_finished()
332        && stderr.is_finished()
333}
334
335fn terminate_group_and_finish_io(
336    child: &mut std::process::Child,
337    writer: Option<std::thread::JoinHandle<std::io::Result<()>>>,
338    stdout: std::thread::JoinHandle<Vec<u8>>,
339    stderr: std::thread::JoinHandle<Vec<u8>>,
340    trigger: CommandCleanupTrigger,
341) -> (std::io::Result<()>, CommandCleanupEvidence) {
342    let started = std::time::Instant::now();
343    let kill = terminate_process_group(child);
344    let result = match kill {
345        Ok(()) => finish_io_with_grace(writer, stdout, stderr),
346        Err(error) => Err(error),
347    };
348    let evidence = CommandCleanupEvidence {
349        trigger,
350        elapsed_ms: duration_millis(started.elapsed()),
351        reap_grace_ms: duration_millis(COMMAND_REAP_GRACE),
352        io_drain_grace_ms: duration_millis(COMMAND_IO_DRAIN_GRACE),
353        kill_attempted: true,
354        verified: result.is_ok(),
355        error: result.as_ref().err().map(ToString::to_string),
356    };
357    (result, evidence)
358}
359
360fn terminate_process_group(child: &mut std::process::Child) -> std::io::Result<()> {
361    let pid = rustix::process::Pid::from_raw(child.id() as i32)
362        .ok_or_else(|| std::io::Error::other("child process id is zero"))?;
363    let signal = rustix::process::kill_process_group(pid, rustix::process::Signal::KILL);
364    if signal.is_err() && child.try_wait()?.is_none() {
365        child.kill()?;
366    }
367    match child.wait_timeout(COMMAND_REAP_GRACE)? {
368        Some(_) => Ok(()),
369        None => Err(std::io::Error::new(
370            std::io::ErrorKind::TimedOut,
371            format!(
372                "command child did not reap within {} seconds",
373                COMMAND_REAP_GRACE.as_secs()
374            ),
375        )),
376    }
377}
378
379fn finish_io_with_grace(
380    writer: Option<std::thread::JoinHandle<std::io::Result<()>>>,
381    stdout: std::thread::JoinHandle<Vec<u8>>,
382    stderr: std::thread::JoinHandle<Vec<u8>>,
383) -> std::io::Result<()> {
384    let started = std::time::Instant::now();
385    while !io_finished(&writer, &stdout, &stderr) {
386        if started.elapsed() >= COMMAND_IO_DRAIN_GRACE {
387            return Err(std::io::Error::new(
388                std::io::ErrorKind::TimedOut,
389                format!(
390                    "command I/O did not drain within {} seconds after termination",
391                    COMMAND_IO_DRAIN_GRACE.as_secs()
392                ),
393            ));
394        }
395        std::thread::sleep(INTERRUPT_POLL_INTERVAL);
396    }
397    // A writer blocked when the operation expires normally observes a broken
398    // pipe after the process group is killed. Cleanup needs to prove that the
399    // writer thread finished; its write result still belongs to the terminal
400    // operation and must not reclassify verified termination as cleanup
401    // failure.
402    join_writer_completion(writer)?;
403    let _ = join_drain(stdout).map_err(bounded_error_into_io)?;
404    let _ = join_drain(stderr).map_err(bounded_error_into_io)?;
405    Ok(())
406}
407
408fn join_writer_completion(
409    writer: Option<std::thread::JoinHandle<std::io::Result<()>>>,
410) -> std::io::Result<()> {
411    let Some(writer) = writer else {
412        return Ok(());
413    };
414    writer
415        .join()
416        .map(|_| ())
417        .map_err(|_| std::io::Error::other("stdin writer thread panicked"))
418}
419
420fn bounded_error_into_io(error: BoundedError) -> std::io::Error {
421    match error {
422        BoundedError::Launch(error) | BoundedError::Stdin(error) | BoundedError::Wait(error) => {
423            error
424        }
425        BoundedError::WaitCleanup { source, .. } => source,
426    }
427}
428
429fn join_writer_result(
430    writer: Option<std::thread::JoinHandle<std::io::Result<()>>>,
431) -> std::io::Result<()> {
432    let Some(writer) = writer else {
433        return Ok(());
434    };
435    match writer.join() {
436        Ok(result) => result,
437        Err(_) => Err(std::io::Error::other("stdin writer thread panicked")),
438    }
439}
440
441fn join_drain(drain: std::thread::JoinHandle<Vec<u8>>) -> Result<Vec<u8>, BoundedError> {
442    drain
443        .join()
444        .map_err(|_| BoundedError::Wait(std::io::Error::other("output drain thread panicked")))
445}
446
447/// The outcome of a confirmed-removal attempt.
448pub enum Removal {
449    Confirmed { already_absent: bool },
450    Unconfirmed(RemovalFailure),
451}
452
453/// Why a removal stayed unconfirmed; callers format their own messages.
454pub enum RemovalFailure {
455    Launch(std::io::Error),
456    Wait(std::io::Error),
457    WaitCleanup {
458        source: std::io::Error,
459        operation_elapsed_ms: u64,
460        client_cleanup: CommandCleanupEvidence,
461    },
462    Deadline {
463        operation_elapsed_ms: u64,
464        client_cleanup: Option<CommandCleanupEvidence>,
465    },
466    Exit {
467        status: ExitStatus,
468        stderr: String,
469    },
470    Ssh(String),
471}
472
473/// `docker rm -f` on the container's launch machine, always under
474/// [`REMOVAL_TIMEOUT`]: the SSH client itself runs through the bounded
475/// invocation, because transport liveness configuration cannot bound a live
476/// connection to a wedged remote daemon. A container the daemon no longer
477/// knows is the confirmation, not a failure
478/// ([[RFC-0003:C-RUNTIME-WORKFLOWS]]).
479pub fn remove_container(target: Option<&str>, container: &str) -> Removal {
480    let bound = OperationBound::finite(REMOVAL_TIMEOUT);
481    // The SSH transport is a host tool: strip the stack's dynamic-linker
482    // overrides ([[RFC-0003:C-RUNTIME-WORKFLOWS]]). A local docker client is
483    // not in that scope.
484    let (argv, env_remove): (Vec<String>, &[&str]) = match target {
485        Some(target) => (
486            crate::ssh::ssh_argv(
487                target,
488                &format!("docker rm -f {}", crate::shell::shell_quote(container)),
489            ),
490            crate::ssh::SSH_ENV_REMOVE,
491        ),
492        None => (
493            vec![
494                "docker".to_owned(),
495                "rm".to_owned(),
496                "-f".to_owned(),
497                container.to_owned(),
498            ],
499            &[],
500        ),
501    };
502    let (status, stderr) = match run_cleanup_with_bound(&argv, env_remove, None, None, &bound, None)
503    {
504        Ok(BoundedWait::Exited { status, stderr, .. }) => {
505            (status, String::from_utf8_lossy(&stderr).into_owned())
506        }
507        Ok(BoundedWait::Expired {
508            operation_elapsed_ms,
509            cleanup,
510            ..
511        }) => {
512            return Removal::Unconfirmed(RemovalFailure::Deadline {
513                operation_elapsed_ms,
514                client_cleanup: cleanup,
515            });
516        }
517        Ok(BoundedWait::Interrupted { .. }) => {
518            return Removal::Unconfirmed(RemovalFailure::Wait(std::io::Error::new(
519                std::io::ErrorKind::Interrupted,
520                "container cleanup was interrupted",
521            )));
522        }
523        Err(BoundedError::Launch(error)) => {
524            return Removal::Unconfirmed(match target {
525                Some(target) => {
526                    RemovalFailure::Ssh(format!("failed to launch SSH for {target:?}: {error}"))
527                }
528                None => RemovalFailure::Launch(error),
529            });
530        }
531        Err(BoundedError::Stdin(error)) | Err(BoundedError::Wait(error)) => {
532            return Removal::Unconfirmed(RemovalFailure::Wait(error));
533        }
534        Err(BoundedError::WaitCleanup {
535            source,
536            operation_elapsed_ms,
537            cleanup,
538        }) => {
539            return Removal::Unconfirmed(RemovalFailure::WaitCleanup {
540                source,
541                operation_elapsed_ms,
542                client_cleanup: cleanup,
543            });
544        }
545    };
546    if status.success() {
547        return Removal::Confirmed {
548            already_absent: false,
549        };
550    }
551    if stderr.contains("No such container") {
552        return Removal::Confirmed {
553            already_absent: true,
554        };
555    }
556    // A `--rm` container whose exit races this removal: the daemon owns an
557    // in-flight removal it refuses to double-remove, so confirmation is
558    // observing the container disappear within the same removal deadline.
559    if stderr.contains("is already in progress")
560        && confirm_container_absent(target, container, &bound)
561    {
562        return Removal::Confirmed {
563            already_absent: true,
564        };
565    }
566    Removal::Unconfirmed(RemovalFailure::Exit { status, stderr })
567}
568
569/// Poll the daemon on the container's launch machine until it no longer
570/// knows the container, bounded by [`REMOVAL_TIMEOUT`]. Any answer other
571/// than a definitive not-found keeps polling; the deadline decides.
572fn confirm_container_absent(target: Option<&str>, container: &str, bound: &OperationBound) -> bool {
573    let (argv, env_remove): (Vec<String>, &[&str]) = match target {
574        Some(target) => (
575            crate::ssh::ssh_argv(
576                target,
577                &format!(
578                    "docker container inspect --format {{{{.Id}}}} {}",
579                    crate::shell::shell_quote(container)
580                ),
581            ),
582            crate::ssh::SSH_ENV_REMOVE,
583        ),
584        None => (
585            vec![
586                "docker".to_owned(),
587                "container".to_owned(),
588                "inspect".to_owned(),
589                "--format".to_owned(),
590                "{{.Id}}".to_owned(),
591                container.to_owned(),
592            ],
593            &[],
594        ),
595    };
596    loop {
597        if let Ok(BoundedWait::Exited { status, stderr, .. }) =
598            run_cleanup_with_bound(&argv, env_remove, None, None, bound, None)
599            && !status.success()
600            && String::from_utf8_lossy(&stderr).contains("No such container")
601        {
602            return true;
603        }
604        if bound.is_expired() {
605            return false;
606        }
607        let sleep = match bound.attempt(Some(Duration::from_millis(250))).remaining() {
608            Remaining::Finite(duration) => duration,
609            Remaining::Expired => return false,
610            Remaining::Unbounded => Duration::from_millis(250),
611        };
612        std::thread::sleep(sleep);
613    }
614}
615
616#[cfg(test)]
617mod tests {
618    use super::{BoundedWait, CommandCleanupTrigger, run_with_bound, run_with_bound_mode};
619    use crate::operation_bound::OperationBound;
620    use std::time::Duration;
621
622    #[test]
623    fn unbounded_command_wait_terminates_when_the_process_exits() {
624        let argv = vec!["sh".to_owned(), "-c".to_owned(), "exit 0".to_owned()];
625        let outcome = run_with_bound(&argv, &[], None, None, &OperationBound::unbounded(), None);
626
627        assert!(matches!(
628            outcome,
629            Ok(BoundedWait::Exited { status, .. }) if status.success()
630        ));
631    }
632
633    #[test]
634    fn pending_interruption_terminates_an_unbounded_command() {
635        let argv = vec!["sh".to_owned(), "-c".to_owned(), "sleep 60".to_owned()];
636        let outcome = run_with_bound_mode(
637            &argv,
638            &[],
639            None,
640            None,
641            &OperationBound::unbounded(),
642            None,
643            true,
644            || true,
645        );
646
647        let (operation_elapsed_ms, cleanup) = match outcome {
648            Ok(BoundedWait::Interrupted {
649                kill: Ok(()),
650                operation_elapsed_ms,
651                cleanup,
652            }) => (operation_elapsed_ms, cleanup),
653            other => {
654                assert!(
655                    matches!(other, Ok(BoundedWait::Interrupted { .. })),
656                    "interrupted command did not return cleanup evidence"
657                );
658                return;
659            }
660        };
661        assert!(operation_elapsed_ms < 1_000);
662        assert!(cleanup.verified, "{cleanup:?}");
663        assert_eq!(cleanup.trigger, CommandCleanupTrigger::Interruption);
664        assert!(cleanup.kill_attempted);
665        assert_eq!(cleanup.reap_grace_ms, 5_000);
666        assert_eq!(cleanup.io_drain_grace_ms, 5_000);
667    }
668
669    #[test]
670    fn blocked_stdin_write_cannot_outlive_the_owner_bound() {
671        let argv = vec![
672            "sh".to_owned(),
673            "-c".to_owned(),
674            "while :; do :; done".to_owned(),
675        ];
676        let payload = vec![b'x'; 1024 * 1024];
677        let bound = OperationBound::finite(Duration::from_millis(50));
678
679        let outcome = run_with_bound(&argv, &[], None, Some(&payload), &bound, None);
680
681        assert!(matches!(
682            outcome,
683            Ok(BoundedWait::Expired { kill: Ok(()), .. })
684        ));
685    }
686
687    #[test]
688    fn inherited_output_pipe_cannot_outlive_the_owner_bound() {
689        let argv = vec![
690            "sh".to_owned(),
691            "-c".to_owned(),
692            "sleep 60 & exit 0".to_owned(),
693        ];
694        let bound = OperationBound::finite(Duration::from_millis(50));
695
696        let outcome = run_with_bound(&argv, &[], None, None, &bound, None);
697
698        assert!(matches!(
699            outcome,
700            Ok(BoundedWait::Expired { kill: Ok(()), .. })
701        ));
702    }
703
704    #[test]
705    fn env_remove_strips_inherited_variables_from_the_child() {
706        // `/usr/bin/env` reports the received environment verbatim; a shell
707        // child would re-default an unset PATH and hide the strip.
708        let argv = vec!["/usr/bin/env".to_owned()];
709
710        let inherited = run_with_bound(&argv, &[], None, None, &OperationBound::unbounded(), None);
711        assert!(matches!(
712            inherited,
713            Ok(BoundedWait::Exited { ref stdout, .. }) if stdout.starts_with(b"PATH=")
714                || stdout.windows(6).any(|window| window == b"\nPATH=")
715        ));
716
717        let stripped = run_with_bound(
718            &argv,
719            &["PATH"],
720            None,
721            None,
722            &OperationBound::unbounded(),
723            None,
724        );
725        assert!(matches!(
726            stripped,
727            Ok(BoundedWait::Exited { ref stdout, .. }) if !stdout.starts_with(b"PATH=")
728                && !stdout.windows(6).any(|window| window == b"\nPATH=")
729        ));
730    }
731}