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