Skip to main content

rightkit_child_process/
run.rs

1//! One call that runs a program to completion with a deadline, bounded output capture, and no
2//! console window on Windows: the shape of the hand-rolled timeout loops that apps wrote around
3//! [`OwnedCommand`](crate::OwnedCommand) and [`NativeOutputCapture`](crate::NativeOutputCapture).
4//!
5//! The child is owned: it runs in its own process group (or session on Unix) inside a Job
6//! Object on Windows, and when the deadline passes its whole tree is terminated, not only the
7//! leader. Stdin is null. Output beyond each cap is drained and discarded, so a chatty child
8//! never blocks on a full pipe, and [`CapturedOutput::truncated`] says when that happened.
9//!
10//! [`run_command`] is the same runner for a command the caller configured (environment, working
11//! directory), and [`run_streaming`] is the variant that shows output while it runs, with a cancel
12//! predicate and tail-only retention.
13
14mod failure;
15mod streaming;
16
17pub(crate) use failure::{failure, stop_after_failure, terminate_runner};
18pub use failure::{LeaderTermination, RunnerFailure, RunnerPhase};
19pub use streaming::{run_streaming, StreamOptions, StreamOutput};
20
21use crate::{CapturedOutput, NativeOutputCapture, OwnedCommand};
22use std::{
23    ffi::OsStr,
24    io,
25    process::{ChildStdin, ExitStatus},
26    sync::{
27        atomic::{AtomicBool, Ordering},
28        mpsc, Arc,
29    },
30    thread::JoinHandle,
31    time::{Duration, Instant},
32};
33
34/// How long to wait for a pipe to end after the child has exited, before returning what was
35/// captured (a grandchild that inherited the pipe can keep it open). A capture that fails to
36/// reach EOF within this bound makes the runner return an error.
37const DRAIN_WAIT: Duration = Duration::from_secs(2);
38pub(crate) const RUNNER_REAP_WAIT: Duration = Duration::from_secs(5);
39
40/// How long the stdin writer gets to end by itself once the child is gone, before it is
41/// cancelled. A writer facing a closed pipe ends at once; one still blocked is facing a reader
42/// that outlived the tree (a grandchild that kept stdin and does not read it).
43const STDIN_WAIT: Duration = Duration::from_millis(500);
44
45/// How long a cancelled writer gets to report; the cancel is repeated every [`CANCEL_RETRY`]
46/// within it. Past this `run` stops waiting and fails: with `TimedOut`, or with the earlier
47/// error on an early return. See [`run_with_stdin`] for what may remain on Windows.
48const STDIN_CANCEL_WAIT: Duration = Duration::from_secs(2);
49
50/// Pause between repeated cancels.
51const CANCEL_RETRY: Duration = Duration::from_millis(25);
52
53/// Most bytes a Windows writer hands to one blocking write, so cancellation is also checked
54/// between chunks.
55#[cfg(windows)]
56const WRITE_CHUNK: usize = 64 * 1024;
57
58/// How often a Unix writer waiting for pipe space re-checks its cancel flag.
59#[cfg(unix)]
60const WRITE_POLL: Duration = Duration::from_millis(50);
61
62/// Limits for [`run`].
63#[derive(Clone, Copy, Debug, Eq, PartialEq)]
64pub struct RunOptions {
65    /// How long the program may run before its tree is terminated.
66    pub timeout: Duration,
67    /// Most bytes kept from stdout (the rest is drained and discarded).
68    pub stdout_cap: usize,
69    /// Most bytes kept from stderr.
70    pub stderr_cap: usize,
71    /// Hide the console window on Windows. The default. `false` leaves it visible.
72    pub no_window: bool,
73}
74
75impl RunOptions {
76    /// A deadline with 1 MiB caps on each stream and no window.
77    pub fn new(timeout: Duration) -> Self {
78        Self {
79            timeout,
80            stdout_cap: 1024 * 1024,
81            stderr_cap: 1024 * 1024,
82            no_window: true,
83        }
84    }
85
86    pub fn stdout_cap(mut self, bytes: usize) -> Self {
87        self.stdout_cap = bytes;
88        self
89    }
90
91    pub fn stderr_cap(mut self, bytes: usize) -> Self {
92        self.stderr_cap = bytes;
93        self
94    }
95
96    pub fn no_window(mut self, hidden: bool) -> Self {
97        self.no_window = hidden;
98        self
99    }
100}
101
102/// Options for raw first-N capture, including commands with no execution deadline.
103#[derive(Clone, Debug, Eq, PartialEq)]
104#[non_exhaustive]
105pub struct CaptureOptions {
106    /// Execution deadline; `None` waits for natural leader exit. Cleanup still has finite bounds.
107    pub timeout: Option<Duration>,
108    /// First raw bytes retained from stdout; remaining bytes are drained through EOF.
109    pub stdout_cap: usize,
110    /// First raw bytes retained from stderr; remaining bytes are drained through EOF.
111    pub stderr_cap: usize,
112    /// Hide the Windows console window (default true).
113    pub no_window: bool,
114    /// Post-exit EOF wait per stream (default 2 s), separate from execution timeout.
115    pub drain_grace: Duration,
116}
117
118impl Default for CaptureOptions {
119    fn default() -> Self {
120        Self {
121            timeout: None,
122            stdout_cap: 1024 * 1024,
123            stderr_cap: 1024 * 1024,
124            no_window: true,
125            drain_grace: DRAIN_WAIT,
126        }
127    }
128}
129
130impl CaptureOptions {
131    pub fn new() -> Self {
132        Self::default()
133    }
134    pub fn timeout(mut self, value: Duration) -> Self {
135        self.timeout = Some(value);
136        self
137    }
138    pub fn stdout_cap(mut self, value: usize) -> Self {
139        self.stdout_cap = value;
140        self
141    }
142    pub fn stderr_cap(mut self, value: usize) -> Self {
143        self.stderr_cap = value;
144        self
145    }
146    pub fn no_window(mut self, value: bool) -> Self {
147        self.no_window = value;
148        self
149    }
150    pub fn drain_grace(mut self, value: Duration) -> Self {
151        self.drain_grace = value;
152        self
153    }
154}
155
156/// What [`run`] observed.
157#[derive(Clone, Debug, Eq, PartialEq)]
158pub struct RunOutput {
159    /// The program's exit status. When `timed_out` is true, the status of the terminated tree's
160    /// leader (on Unix, usually a signal).
161    pub status: ExitStatus,
162    /// True when the deadline passed and the tree was terminated.
163    pub timed_out: bool,
164    pub stdout: CapturedOutput,
165    pub stderr: CapturedOutput,
166}
167
168/// [`run`] with `input` written to the child's stdin. The bytes are written on their own
169/// thread while output is drained concurrently, so input larger than a pipe buffer cannot
170/// deadlock against a chatty child; the writer closes stdin when done, delivering EOF. A
171/// child that exits before consuming the input closes the pipe: that broken-pipe outcome is
172/// a normal early exit, not an error, and the child's status/output are returned as usual.
173/// Any other stdin write failure is returned as `Err` after the tree is down. A process that
174/// outlived the tree and still holds stdin without reading it cannot hold `run` past its
175/// deadline: once the tree is down the writer gets a short grace, then it is cancelled (Unix:
176/// non-blocking write end; Windows: `CancelIoEx`) and joined.
177///
178/// What a return guarantees about the writer:
179/// - Every `Ok` return: the writer thread has been joined and the write end is closed.
180/// - A writer that cannot be stopped within its bound makes the call fail with
181///   `ErrorKind::TimedOut`.
182/// - Any other `Err` (an early failure, such as output capture or waiting on the child) still
183///   cancels the writer with the same bound before returning, but reports that original error,
184///   not the cleanup outcome.
185/// - Unix, every return: the parent has closed the write end, so a writer thread that is
186///   still alive can deliver nothing more and ends on its next step.
187/// - Windows, after `TimedOut` or an early `Err`: a writer that did not stop within the bound
188///   may still be alive and blocked in a write, owning the write end, with its cancel flag set.
189///   This needs a pending `CancelIoEx` that fails to abort the write, which is not expected.
190pub fn run_with_stdin<I, S>(
191    program: impl AsRef<OsStr>,
192    args: I,
193    input: &[u8],
194    options: &RunOptions,
195) -> io::Result<RunOutput>
196where
197    I: IntoIterator<Item = S>,
198    S: AsRef<OsStr>,
199{
200    run_inner(program, args, Some(input.to_vec()), options)
201}
202
203/// Runs `program` with `args` to completion or until `options.timeout`, then returns its status
204/// and bounded output. Fails when the program or native output capture cannot start, or
205/// lifecycle waiting fails. A non-zero exit is a normal `Ok` with `status` set, provided capture
206/// reaches EOF & cleanup succeeds. Drain expiry is `Err(TimedOut)` carrying
207/// [`RunnerPhase::Drain`], including after an execution timeout or successful leader exit.
208/// Errors carry [`RunnerFailure`]; use its source for the original OS error code.
209pub fn run<I, S>(program: impl AsRef<OsStr>, args: I, options: &RunOptions) -> io::Result<RunOutput>
210where
211    I: IntoIterator<Item = S>,
212    S: AsRef<OsStr>,
213{
214    run_inner(program, args, None, options)
215}
216
217fn run_inner<I, S>(
218    program: impl AsRef<OsStr>,
219    args: I,
220    input: Option<Vec<u8>>,
221    options: &RunOptions,
222) -> io::Result<RunOutput>
223where
224    I: IntoIterator<Item = S>,
225    S: AsRef<OsStr>,
226{
227    let mut command = OwnedCommand::new(program);
228    command.command_mut().args(args);
229    run_owned(command, input, options)
230}
231
232/// [`run`] / [`run_with_stdin`] for a command the caller configured: its program, arguments,
233/// environment (including a cleared or allow-listed one), working directory and any other
234/// [`OwnedCommand`] setting are used as given. The crate sets stdin, stdout and stderr (pipes;
235/// stdin is null when `input` is `None`) and the window mode from `options.no_window`, replacing
236/// whatever the caller set for those, including the stdio of an explicit or automatic
237/// `WindowsSpawnConfig` on a Windows `inherit_handles_only` command (its environment, raw
238/// arguments and security settings are kept). If a capture's pipe read fails the call returns
239/// that error instead of an `Ok` with partial output.
240///
241/// `input` of `Some(bytes)` is written to the child's stdin on its own thread while output is
242/// drained concurrently, so input larger than a pipe buffer cannot deadlock against a chatty
243/// child; the writer closes stdin when done, delivering EOF (`Some(&[])` delivers EOF at once).
244/// A child that exits before consuming the input closes the pipe: that broken-pipe outcome is
245/// a normal early exit, not an error, and the child's status/output are returned as usual.
246/// Any other stdin write failure is returned as `Err` after the tree is down. A process that
247/// outlived the tree and still holds stdin without reading it cannot hold the call past its
248/// deadline: once the tree is down the writer gets a short grace, then it is cancelled (Unix:
249/// non-blocking write end; Windows: `CancelIoEx`) and joined.
250///
251/// What a return guarantees about the writer:
252/// - Every `Ok` return: the writer thread has been joined and the write end is closed.
253/// - A writer that cannot be stopped within its bound makes the call fail with
254///   `ErrorKind::TimedOut`.
255/// - Any other `Err` (an early failure, such as output capture or waiting on the child) still
256///   cancels the writer with the same bound before returning, but reports that original error,
257///   not the cleanup outcome.
258/// - Unix, every return: the parent has closed the write end, so a writer thread that is
259///   still alive can deliver nothing more and ends on its next step.
260/// - Windows, after `TimedOut` or an early `Err`: a writer that did not stop within the bound
261///   may still be alive and blocked in a write, owning the write end, with its cancel flag set.
262///   This needs a pending `CancelIoEx` that fails to abort the write, which is not expected.
263///
264/// Everything else (the deadline, the tree kill, the output caps, what `Err` means before the
265/// program starts) is as documented on [`run`], which delegates here.
266pub fn run_command(
267    command: OwnedCommand,
268    input: Option<&[u8]>,
269    options: &RunOptions,
270) -> io::Result<RunOutput> {
271    run_owned(command, input.map(<[u8]>::to_vec), options)
272}
273
274fn run_owned(
275    command: OwnedCommand,
276    input: Option<Vec<u8>>,
277    options: &RunOptions,
278) -> io::Result<RunOutput> {
279    run_captured(
280        command,
281        input,
282        &CaptureOptions {
283            timeout: Some(options.timeout),
284            stdout_cap: options.stdout_cap,
285            stderr_cap: options.stderr_cap,
286            no_window: options.no_window,
287            drain_grace: DRAIN_WAIT,
288        },
289    )
290}
291
292/// Configured command capture with an optional deadline. Retains the first N raw bytes per
293/// stream & drains discarded bytes to EOF. `None` timeout waits for natural leader exit;
294/// cleanup & post-exit output draining remain bounded. Errors carry [`RunnerFailure`].
295pub fn run_command_capture(
296    command: OwnedCommand,
297    input: Option<&[u8]>,
298    options: &CaptureOptions,
299) -> io::Result<RunOutput> {
300    run_captured(command, input.map(<[u8]>::to_vec), options)
301}
302
303fn run_captured(
304    mut command: OwnedCommand,
305    input: Option<Vec<u8>>,
306    options: &CaptureOptions,
307) -> io::Result<RunOutput> {
308    // Recorded, not applied to `Command` here: `spawn` applies it to the std `Command` and to a
309    // native Windows spawn config alike, after every caller edit.
310    command.pipe_stdio(input.is_some());
311    command.bound_runner_cleanup(RUNNER_REAP_WAIT);
312    if options.no_window {
313        command.windows_hide();
314    } else {
315        command.windows_visible();
316    }
317    let mut child = command
318        .spawn()
319        .map_err(|error| failure(RunnerPhase::Spawn, LeaderTermination::NotStarted, error))?;
320    // Written concurrently with the output drains below; closing stdin when the writer is done
321    // is the EOF. Spawn failure returns before any wait is armed. If an early `?` return skips
322    // `finish`, `StdinWriter`'s `Drop` cancels the writer with the same bound.
323    let writer = match (input, child.take_stdin()) {
324        (Some(bytes), Some(stdin)) => Some(
325            StdinWriter::spawn(stdin, bytes)
326                .map_err(|error| stop_after_failure(&mut child, RunnerPhase::Setup, error))?,
327        ),
328        _ => None,
329    };
330    let mut stdout = child
331        .take_stdout()
332        .map(|pipe| NativeOutputCapture::stdout(pipe, options.stdout_cap))
333        .transpose()
334        .map_err(|error| stop_after_failure(&mut child, RunnerPhase::Setup, error))?;
335    let mut stderr = child
336        .take_stderr()
337        .map(|pipe| NativeOutputCapture::stderr(pipe, options.stderr_cap))
338        .transpose()
339        .map_err(|error| stop_after_failure(&mut child, RunnerPhase::Setup, error))?;
340    // The command runs to completion within the deadline (`Exited`, no signal sent); only at
341    // the deadline is the tree killed and the outcome `Terminated`.
342    let (status, timed_out) = match options.timeout {
343        Some(timeout) => match child
344            .wait_exit_then_sweep(timeout)
345            .map_err(|error| stop_after_failure(&mut child, RunnerPhase::Wait, error))?
346        {
347            Some(status) => (status, false),
348            None => (terminate_runner(&mut child, RUNNER_REAP_WAIT)?, true),
349        },
350        None => loop {
351            if let Some(status) = child
352                .wait_exit_then_sweep(Duration::from_millis(100))
353                .map_err(|error| stop_after_failure(&mut child, RunnerPhase::Wait, error))?
354            {
355                break (status, false);
356            }
357        },
358    };
359    let stdout_output = stdout
360        .as_mut()
361        .map_or_else(CapturedOutput::default, |capture| {
362            capture.finish_within(options.drain_grace)
363        });
364    let stderr_output = stderr
365        .as_mut()
366        .map_or_else(CapturedOutput::default, |capture| {
367            capture.finish_within(options.drain_grace)
368        });
369    // The tree is down, but a process that escaped it (a grandchild that left the group or the
370    // job) may still hold the read end of stdin without reading, so the writer is not
371    // guaranteed to end on its own. Wait a bounded time, then cancel it; never join unbounded.
372    // BrokenPipe is the child leaving early, not a failure of this run.
373    let writer_result = writer.map(StdinWriter::finish).transpose();
374    // Both readers are joined, so a read or shutdown failure they stored is final. Taken for
375    // both streams before any return, so neither is left behind.
376    let stdout_failure = capture_failure("stdout", stdout.as_ref());
377    let stderr_failure = capture_failure("stderr", stderr.as_ref());
378    stdout_failure?;
379    stderr_failure?;
380    writer_result
381        .map_err(|error| failure(RunnerPhase::Stdin, LeaderTermination::Confirmed, error))?;
382    Ok(RunOutput {
383        status,
384        timed_out,
385        stdout: stdout_output,
386        stderr: stderr_output,
387    })
388}
389
390/// Turns a stored failure of a joined capture into the call's error: its output is incomplete,
391/// so returning `Ok` would pass partial bytes off as the child's whole output. The error keeps
392/// its [`io::ErrorKind`] and names the stream. Call only after the capture has been joined
393/// (`finish_within` / `cancel`).
394pub(crate) fn capture_failure(
395    stream: &str,
396    capture: Option<&NativeOutputCapture>,
397) -> io::Result<()> {
398    match capture.and_then(NativeOutputCapture::take_error) {
399        Some(error) => Err(failure(
400            if capture.is_some_and(NativeOutputCapture::cleanup_failed) { RunnerPhase::Cleanup }
401            else if stream == "stdout" { RunnerPhase::Stdout } else { RunnerPhase::Stderr },
402            LeaderTermination::Confirmed,
403            error,
404        )),
405        None if capture.is_some_and(|capture| !capture.is_complete()) => Err(failure(
406            RunnerPhase::Drain, LeaderTermination::Confirmed,
407            io::Error::new(io::ErrorKind::TimedOut,
408                format!("{stream} capture did not reach EOF within its drain bound, output is incomplete")),
409        )),
410        None => Ok(()),
411    }
412}
413
414/// State shared between the thread running [`run_inner`] and the stdin writer thread.
415struct WriterState {
416    cancel: AtomicBool,
417    /// Windows: the parent's write handle while the writer may have a write pending on it. The
418    /// writer clears it (under this lock) before it drops the pipe, so a cancel that sees
419    /// `Some` always acts on an open handle.
420    #[cfg(windows)]
421    handle: std::sync::Mutex<Option<usize>>,
422    /// Unix: the parent's write end. It lives here, not in the writer thread, so the parent can
423    /// close it (`close_pipe`) even if the thread never comes back. Writes happen only while
424    /// holding this lock and seeing `Some`, so no write can target a closed or reused
425    /// descriptor.
426    #[cfg(unix)]
427    pipe: std::sync::Mutex<Option<ChildStdin>>,
428}
429
430impl WriterState {
431    fn new() -> Self {
432        Self {
433            cancel: AtomicBool::new(false),
434            #[cfg(windows)]
435            handle: std::sync::Mutex::new(None),
436            #[cfg(unix)]
437            pipe: std::sync::Mutex::new(None),
438        }
439    }
440
441    #[cfg(unix)]
442    fn pipe(&self) -> std::sync::MutexGuard<'_, Option<ChildStdin>> {
443        self.pipe
444            .lock()
445            .unwrap_or_else(std::sync::PoisonError::into_inner)
446    }
447
448    /// Closes the write end (the EOF). Idempotent; callable from any thread.
449    #[cfg(unix)]
450    fn close_pipe(&self) {
451        drop(self.pipe().take());
452    }
453
454    fn cancelled(&self) -> bool {
455        self.cancel.load(Ordering::Acquire)
456    }
457
458    /// Asks the writer to stop. Safe to repeat, and it must be: a cancel that lands between two
459    /// writes finds nothing pending, so the caller repeats it until the writer reports.
460    fn cancel(&self) {
461        self.cancel.store(true, Ordering::Release);
462        #[cfg(windows)]
463        {
464            let held = self
465                .handle
466                .lock()
467                .unwrap_or_else(std::sync::PoisonError::into_inner);
468            if let Some(raw) = *held {
469                // SAFETY: `raw` is the writer's still-open pipe handle (it is cleared under
470                // this lock before the pipe is dropped). CancelIoEx with no OVERLAPPED cancels
471                // every pending operation on that handle, which is only the writer's write.
472                unsafe { crate::native_capture::platform::cancel_all_io(raw as _) };
473            }
474        }
475    }
476}
477
478/// Counts writer threads between spawn and the end of their closure, so tests can prove that
479/// none is left once `run` returns.
480#[cfg(test)]
481static LIVE_WRITERS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
482
483#[cfg(test)]
484struct LiveWriter;
485
486#[cfg(test)]
487impl LiveWriter {
488    fn start() -> Self {
489        LIVE_WRITERS.fetch_add(1, Ordering::SeqCst);
490        Self
491    }
492}
493
494#[cfg(test)]
495impl Drop for LiveWriter {
496    fn drop(&mut self) {
497        LIVE_WRITERS.fetch_sub(1, Ordering::SeqCst);
498    }
499}
500
501/// The thread that feeds `input` to the child's stdin. Its shutdown is bounded and verified:
502/// when [`finish`](Self::finish) or `Drop` returns `Ok` (or any error but `TimedOut`), the
503/// thread has been joined and the write end is closed. On `TimedOut` see
504/// [`settle`](Self::settle) for exactly what may remain.
505struct StdinWriter {
506    state: Arc<WriterState>,
507    done: mpsc::Receiver<io::Result<()>>,
508    handle: Option<JoinHandle<()>>,
509}
510
511impl StdinWriter {
512    fn spawn(stdin: ChildStdin, bytes: Vec<u8>) -> io::Result<Self> {
513        let state = Arc::new(WriterState::new());
514        let (sender, done) = mpsc::channel();
515        let shared = Arc::clone(&state);
516        #[cfg(unix)]
517        {
518            *state.pipe() = Some(stdin);
519        }
520        #[cfg(test)]
521        let live = LiveWriter::start();
522        let handle = std::thread::Builder::new()
523            .name("rightkit-run-stdin".into())
524            .spawn(move || {
525                #[cfg(test)]
526                let _live = live;
527                // Closing the pipe is the EOF, and closes the write end before the result
528                // below is reported.
529                #[cfg(unix)]
530                let result = {
531                    let result = write_input(&bytes, &shared);
532                    shared.close_pipe();
533                    result
534                };
535                #[cfg(windows)]
536                let result = {
537                    let mut stdin = stdin;
538                    let result = write_input(&mut stdin, &bytes, &shared);
539                    drop(stdin);
540                    result
541                };
542                let _ = sender.send(result);
543            })?;
544        Ok(Self {
545            state,
546            done,
547            handle: Some(handle),
548        })
549    }
550
551    /// Waits [`STDIN_WAIT`] for the writer to end by itself, then cancels it (repeatedly, for
552    /// up to [`STDIN_CANCEL_WAIT`]) and joins it. A writer that cannot be stopped within that
553    /// bound is an error: `run` fails with `TimedOut` instead of returning success. On Unix the
554    /// write end is closed first, so nothing more is delivered; on Windows the thread may remain
555    /// (see `settle`).
556    fn finish(mut self) -> io::Result<()> {
557        let result = match self.done.recv_timeout(STDIN_WAIT) {
558            Err(mpsc::RecvTimeoutError::Timeout) => self.cancel_and_wait(),
559            other => other,
560        };
561        self.settle(result)
562    }
563
564    /// Cancels until the writer reports or [`STDIN_CANCEL_WAIT`] passes.
565    fn cancel_and_wait(&self) -> Result<io::Result<()>, mpsc::RecvTimeoutError> {
566        let deadline = Instant::now() + STDIN_CANCEL_WAIT;
567        loop {
568            self.state.cancel();
569            match self.done.recv_timeout(CANCEL_RETRY) {
570                Err(mpsc::RecvTimeoutError::Timeout) if Instant::now() < deadline => {}
571                other => return other,
572            }
573        }
574    }
575
576    fn settle(&mut self, result: Result<io::Result<()>, mpsc::RecvTimeoutError>) -> io::Result<()> {
577        let handle = self.handle.take();
578        match result {
579            Ok(result) => {
580                if let Some(handle) = handle {
581                    let _ = handle.join();
582                }
583                match result {
584                    Err(error) if error.kind() != io::ErrorKind::BrokenPipe => Err(error),
585                    _ => Ok(()),
586                }
587            }
588            Err(mpsc::RecvTimeoutError::Timeout) => {
589                // The one path where the thread may outlive this call, and the handle is
590                // dropped here rather than joined. The cancel flag stays set. Unix: the
591                // parent closes the write end now, so the thread can deliver nothing more and
592                // ends on its next step. Windows: the thread may still be blocked in a write
593                // and still owns the write end; it is not expected to survive repeated
594                // `CancelIoEx`, but nothing here can close the handle safely under it.
595                #[cfg(unix)]
596                self.state.close_pipe();
597                Err(failure(
598                    RunnerPhase::Cleanup,
599                    LeaderTermination::Confirmed,
600                    io::Error::new(
601                        io::ErrorKind::TimedOut,
602                        "the stdin writer did not stop within its cancellation bound",
603                    ),
604                ))
605            }
606            Err(mpsc::RecvTimeoutError::Disconnected) => {
607                if let Some(handle) = handle {
608                    let _ = handle.join();
609                }
610                Err(failure(
611                    RunnerPhase::Cleanup,
612                    LeaderTermination::Confirmed,
613                    io::Error::other("the stdin writer thread panicked"),
614                ))
615            }
616        }
617    }
618}
619
620impl Drop for StdinWriter {
621    fn drop(&mut self) {
622        // Reached without `finish` (an early `?` return in `run_inner`): stop the writer with
623        // the same bound. The cleanup result is discarded and the caller gets the original
624        // error. A cleanup timeout leaves exactly what `settle` describes: on Unix the write end
625        // is closed; on Windows the writer may still be blocked in a write.
626        if self.handle.is_some() {
627            let result = self.cancel_and_wait();
628            let _ = self.settle(result);
629        }
630    }
631}
632
633/// Writes all of `bytes`, or stops early (returning `Ok`) once the state is cancelled. Unix puts
634/// the parent's write end in non-blocking mode and polls, so a pipe whose reader never reads
635/// cannot pin the thread; the flag is checked at least every [`WRITE_POLL`]. If the mode cannot
636/// be switched the write fails closed: a blocking write could not be cancelled.
637#[cfg(unix)]
638fn write_input(bytes: &[u8], state: &WriterState) -> io::Result<()> {
639    use std::os::fd::AsRawFd;
640    let fd = {
641        let guard = state.pipe();
642        let Some(pipe) = guard.as_ref() else {
643            return Ok(());
644        };
645        let fd = pipe.as_raw_fd();
646        // SAFETY: fcntl on a descriptor the guarded `pipe` owns. O_NONBLOCK is a property of
647        // this open file description (the write end only); the child's read end is unaffected.
648        unsafe {
649            let flags = libc::fcntl(fd, libc::F_GETFL);
650            if flags < 0 || libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) < 0 {
651                return Err(io::Error::last_os_error());
652            }
653        }
654        fd
655    };
656    let mut written = 0;
657    while written < bytes.len() {
658        if state.cancelled() {
659            return Ok(());
660        }
661        let rest = &bytes[written..];
662        let chunk = rest.len().min(1 << 20);
663        // The write happens only while the lock shows the pipe still open, and its errno is
664        // read before the lock is released.
665        let outcome = {
666            let guard = state.pipe();
667            let Some(pipe) = guard.as_ref() else {
668                // The parent closed the write end: stop, deliver nothing more.
669                return Ok(());
670            };
671            // SAFETY: `rest` is valid for `chunk` bytes and the pipe's descriptor is open
672            // while `guard` holds it.
673            let count = unsafe { libc::write(pipe.as_raw_fd(), rest.as_ptr().cast(), chunk) };
674            if count >= 0 {
675                Ok(count as usize)
676            } else {
677                Err(io::Error::last_os_error())
678            }
679        };
680        match outcome {
681            Ok(count) => written += count,
682            Err(error) => match error.kind() {
683                io::ErrorKind::Interrupted => {}
684                io::ErrorKind::WouldBlock => {
685                    let mut entry = libc::pollfd {
686                        fd,
687                        events: libc::POLLOUT,
688                        revents: 0,
689                    };
690                    // SAFETY: one valid pollfd, as counted; it only observes readiness, so a
691                    // closed or reused number is harmless. A negative result (EINTR) loops.
692                    unsafe { libc::poll(&mut entry, 1, WRITE_POLL.as_millis() as libc::c_int) };
693                }
694                _ => return Err(error),
695            },
696        }
697    }
698    Ok(())
699}
700
701/// Windows: writes in chunks, checking the state between chunks, and publishes the pipe handle
702/// so [`WriterState::cancel`] can abort a write that is blocked on a reader that never reads
703/// (`CancelIoEx`). An error caused by that cancel is a normal stop, not a failure.
704#[cfg(windows)]
705fn write_input(stdin: &mut ChildStdin, bytes: &[u8], state: &WriterState) -> io::Result<()> {
706    use std::{io::Write, os::windows::io::AsRawHandle};
707    let publish = |value: Option<usize>| {
708        *state
709            .handle
710            .lock()
711            .unwrap_or_else(std::sync::PoisonError::into_inner) = value;
712    };
713    publish(Some(stdin.as_raw_handle() as usize));
714    let mut result = Ok(());
715    for chunk in bytes.chunks(WRITE_CHUNK) {
716        if state.cancelled() {
717            break;
718        }
719        if let Err(error) = stdin.write_all(chunk) {
720            result = Err(error);
721            break;
722        }
723    }
724    // Taking the lock waits out any cancel in progress; after this the handle may be closed.
725    publish(None);
726    match result {
727        Err(_) if state.cancelled() => Ok(()),
728        other => other,
729    }
730}
731
732#[cfg(test)]
733mod tests {
734    use super::*;
735    use std::process::Stdio;
736    use std::sync::atomic::Ordering::SeqCst;
737
738    /// The writer-thread counter is process-wide, so tests that start a writer run one at a
739    /// time and can assert it returns to zero.
740    static WRITER_TESTS: std::sync::Mutex<()> = std::sync::Mutex::new(());
741
742    fn serial() -> std::sync::MutexGuard<'static, ()> {
743        WRITER_TESTS
744            .lock()
745            .unwrap_or_else(std::sync::PoisonError::into_inner)
746    }
747
748    /// Owns a child for the test and kills it on drop.
749    struct KillOnDrop(std::process::Child);
750
751    impl Drop for KillOnDrop {
752        fn drop(&mut self) {
753            let _ = self.0.kill();
754            let _ = self.0.wait();
755        }
756    }
757
758    #[test]
759    fn defaults_hide_the_window_and_cap_both_streams() {
760        let options = RunOptions::new(Duration::from_secs(1));
761        assert!(options.no_window);
762        assert_eq!(options.stdout_cap, 1024 * 1024);
763        assert_eq!(options.stderr_cap, 1024 * 1024);
764        let options = options.stdout_cap(4).stderr_cap(8).no_window(false);
765        assert_eq!((options.stdout_cap, options.stderr_cap), (4, 8));
766        assert!(!options.no_window);
767    }
768
769    #[cfg(unix)]
770    #[test]
771    fn a_quick_program_returns_its_output_and_status() {
772        let options = RunOptions::new(Duration::from_secs(10));
773        let output = run("sh", ["-c", "printf hi; exit 3"], &options).unwrap();
774        assert!(!output.timed_out);
775        assert_eq!(output.status.code(), Some(3));
776        assert_eq!(output.stdout.bytes, b"hi".to_vec());
777    }
778
779    #[cfg(unix)]
780    #[test]
781    fn a_program_running_within_the_deadline_is_not_signalled() {
782        let options = RunOptions::new(Duration::from_secs(10));
783        let output = run("sh", ["-c", "sleep 1; printf done"], &options).unwrap();
784        assert!(!output.timed_out);
785        assert!(output.status.success());
786        assert_eq!(output.stdout.bytes, b"done".to_vec());
787    }
788
789    #[cfg(unix)]
790    #[test]
791    fn a_slow_program_is_terminated_at_the_deadline() {
792        let options = RunOptions::new(Duration::from_millis(200));
793        let output = run("sh", ["-c", "sleep 30"], &options).unwrap();
794        assert!(output.timed_out);
795    }
796
797    #[cfg(unix)]
798    #[test]
799    fn stdin_input_reaches_the_child_and_early_exit_is_not_an_error() {
800        let _serial = serial();
801        let options = RunOptions::new(Duration::from_secs(10));
802        let output = run_with_stdin("sh", ["-c", "cat"], b"hello", &options).unwrap();
803        assert!(output.status.success());
804        assert_eq!(output.stdout.bytes, b"hello".to_vec());
805        // The child exits at once without reading: input far larger than a pipe buffer
806        // breaks the writer's pipe, which is a normal early exit with the child's status.
807        let big = vec![b'x'; 1 << 20];
808        let output = run_with_stdin("sh", ["-c", "exit 7"], &big, &options).unwrap();
809        assert_eq!(output.status.code(), Some(7));
810        assert_eq!(LIVE_WRITERS.load(SeqCst), 0);
811    }
812
813    /// A reader that stays alive and never reads (the shape of a grandchild that kept stdin
814    /// past the tree kill) cannot pin the writer: `finish` cancels it, joins it and has closed
815    /// the write end, within its bound. Runs on Unix and Windows.
816    #[test]
817    fn a_reader_that_never_reads_cannot_pin_the_writer() {
818        let _serial = serial();
819        #[cfg(unix)]
820        let mut command = {
821            let mut command = std::process::Command::new("sleep");
822            command.arg("30");
823            command
824        };
825        #[cfg(windows)]
826        let mut command = {
827            let mut command = std::process::Command::new("ping");
828            command.args(["-n", "30", "127.0.0.1"]);
829            command
830        };
831        let mut child = KillOnDrop(
832            command
833                .stdin(Stdio::piped())
834                .stdout(Stdio::null())
835                .stderr(Stdio::null())
836                .spawn()
837                .unwrap(),
838        );
839        let stdin = child.0.stdin.take().unwrap();
840        let writer = StdinWriter::spawn(stdin, vec![b'x'; 2 << 20]).unwrap();
841        // Let it fill the pipe and block.
842        std::thread::sleep(Duration::from_millis(200));
843        assert_eq!(LIVE_WRITERS.load(SeqCst), 1, "the writer should be blocked");
844        let started = std::time::Instant::now();
845        writer.finish().unwrap();
846        let elapsed = started.elapsed();
847        assert_eq!(
848            LIVE_WRITERS.load(SeqCst),
849            0,
850            "the writer thread must be gone"
851        );
852        assert!(
853            elapsed < STDIN_WAIT + STDIN_CANCEL_WAIT + Duration::from_secs(3),
854            "finish took {elapsed:?}"
855        );
856        assert!(
857            child.0.try_wait().unwrap().is_none(),
858            "the reader must have stayed alive throughout"
859        );
860    }
861
862    /// The cancellation-timeout branch, forced deterministically: a writer thread that never
863    /// reports. `finish` fails with `TimedOut` within its bound and leaves the cancel flag set,
864    /// and the parent has closed the write end itself, so the reader sees EOF although the
865    /// writer thread is still alive.
866    #[cfg(unix)]
867    #[test]
868    fn a_writer_that_never_reports_times_out_and_the_parent_closes_the_write_end() {
869        let _serial = serial();
870        let mut child = KillOnDrop(
871            std::process::Command::new("cat")
872                .stdin(Stdio::piped())
873                .stdout(Stdio::null())
874                .stderr(Stdio::null())
875                .spawn()
876                .unwrap(),
877        );
878        let state = Arc::new(WriterState::new());
879        *state.pipe() = Some(child.0.stdin.take().unwrap());
880        // The sender stays here, so the writer neither reports nor disconnects.
881        let (_sender, done) = mpsc::channel::<io::Result<()>>();
882        let (release, parked) = mpsc::channel::<()>();
883        let stuck = std::thread::spawn(move || {
884            let _ = parked.recv();
885        });
886        let writer = StdinWriter {
887            state: Arc::clone(&state),
888            done,
889            handle: Some(stuck),
890        };
891        let started = Instant::now();
892        let error = writer.finish().unwrap_err();
893        let elapsed = started.elapsed();
894        assert_eq!(error.kind(), io::ErrorKind::TimedOut);
895        assert!(
896            elapsed >= STDIN_WAIT + STDIN_CANCEL_WAIT
897                && elapsed < STDIN_WAIT + STDIN_CANCEL_WAIT + Duration::from_secs(3),
898            "finish took {elapsed:?}"
899        );
900        assert!(state.cancelled(), "the cancel flag must stay set");
901        assert!(
902            state.pipe().is_none(),
903            "the parent must have closed the write end"
904        );
905        // The write end is closed, so `cat` reads EOF and exits by itself.
906        let deadline = Instant::now() + Duration::from_secs(5);
907        let status = loop {
908            if let Some(status) = child.0.try_wait().unwrap() {
909                break status;
910            }
911            assert!(Instant::now() < deadline, "the reader never saw EOF");
912            std::thread::sleep(Duration::from_millis(20));
913        };
914        assert!(status.success());
915        let _ = release.send(());
916    }
917
918    /// Records the pid of the setsid grandchild in a file and kills it on drop, so a failed
919    /// assertion cannot leak the 30-second sleeper this fixture starts.
920    #[cfg(unix)]
921    struct Grandchild(std::path::PathBuf);
922
923    #[cfg(unix)]
924    impl Grandchild {
925        fn new(label: &str) -> Self {
926            let nanos = std::time::SystemTime::now()
927                .duration_since(std::time::UNIX_EPOCH)
928                .map_or(0, |elapsed| elapsed.as_nanos());
929            Self(std::env::temp_dir().join(format!(
930                "rk-run-stdin-{label}-{}-{nanos}.pid",
931                std::process::id()
932            )))
933        }
934
935        fn pid(&self) -> Option<i32> {
936            let pid: i32 = std::fs::read_to_string(&self.0).ok()?.trim().parse().ok()?;
937            (pid > 1).then_some(pid)
938        }
939
940        /// Where the `reads` grandchild records how many bytes it read before EOF.
941        fn count_path(&self) -> std::path::PathBuf {
942            let mut path = self.0.clone().into_os_string();
943            path.push(".count");
944            path.into()
945        }
946
947        fn alive(&self) -> bool {
948            // SAFETY: signal 0 only checks existence.
949            self.pid()
950                .is_some_and(|pid| unsafe { libc::kill(pid, 0) } == 0)
951        }
952    }
953
954    #[cfg(unix)]
955    impl Drop for Grandchild {
956        fn drop(&mut self) {
957            if let Some(pid) = self.pid() {
958                // SAFETY: the recorded pid is the fixture's own `sleep`, killed once here.
959                unsafe { libc::kill(pid, libc::SIGKILL) };
960            }
961            let _ = std::fs::remove_file(&self.0);
962            let _ = std::fs::remove_file(self.count_path());
963        }
964    }
965
966    /// Shell fixture: starts a grandchild in a new session (so neither the group kill nor the
967    /// tree kill reaches it) that keeps this shell's stdin open and never reads it, waits for
968    /// it to record its pid, then runs `tail`. The grandchild runs `body` (`exec sleep 30` to
969    /// hold stdin unread, or a delayed read-to-EOF that proves the write end was closed). `$1` is the pid file. Exits 99 when the host has
970    /// neither `setsid` nor `perl` to leave the session.
971    #[cfg(unix)]
972    fn holder_script(body: &str, tail: &str) -> String {
973        format!(
974            "if command -v setsid >/dev/null 2>&1; then \
975               setsid sh -c 'echo $$ > \"$1\"; {body}' sh \"$1\" <&0 >/dev/null 2>&1 & \
976             elif command -v perl >/dev/null 2>&1; then \
977               perl -MPOSIX -e 'POSIX::setsid(); exec @ARGV' sh -c 'echo $$ > \"$1\"; {body}' sh \"$1\" <&0 >/dev/null 2>&1 & \
978             else exit 99; fi; \
979             i=0; while [ ! -s \"$1\" ] && [ $i -lt 100 ]; do sleep 0.05; i=$((i+1)); done; {tail}"
980        )
981    }
982
983    #[cfg(unix)]
984    #[test]
985    fn a_session_escaped_stdin_holder_does_not_hold_run_past_its_exit() {
986        let _serial = serial();
987        let holder = Grandchild::new("exit");
988        // After `run` has returned, the grandchild reads stdin to EOF and records how much it
989        // got. A writer left running (or a write end left open) would deliver all 2 MiB, or
990        // leave it blocked; a closed write end leaves only what was already in the pipe.
991        let script = holder_script("sleep 2; wc -c > \"$1.count\"; exec sleep 30", "exit 0");
992        let big = vec![b'x'; 2 << 20];
993        let options = RunOptions::new(Duration::from_secs(10));
994        let started = std::time::Instant::now();
995        let output = run_with_stdin(
996            "sh",
997            ["-c", &script, "sh", holder.0.to_str().unwrap()],
998            &big,
999            &options,
1000        )
1001        .unwrap();
1002        let elapsed = started.elapsed();
1003        if output.status.code() == Some(99) {
1004            eprintln!("skipped: no setsid or perl to leave the session");
1005            return;
1006        }
1007        assert!(
1008            holder.alive(),
1009            "the fixture's grandchild must still hold stdin"
1010        );
1011        assert!(!output.timed_out);
1012        assert!(output.status.success(), "{:?}", output.status);
1013        // Exit is immediate; the writer gets STDIN_WAIT + STDIN_CANCEL_WAIT at most.
1014        assert!(elapsed < Duration::from_secs(6), "run took {elapsed:?}");
1015        assert_eq!(
1016            LIVE_WRITERS.load(SeqCst),
1017            0,
1018            "the writer thread must be gone"
1019        );
1020        let deadline = std::time::Instant::now() + Duration::from_secs(10);
1021        let received = loop {
1022            if let Some(count) = std::fs::read_to_string(holder.count_path())
1023                .ok()
1024                .and_then(|text| text.trim().parse::<usize>().ok())
1025            {
1026                break count;
1027            }
1028            assert!(
1029                std::time::Instant::now() < deadline,
1030                "the grandchild never reached EOF: the write end is still open"
1031            );
1032            std::thread::sleep(Duration::from_millis(50));
1033        };
1034        assert!(
1035            received < big.len() / 2,
1036            "{received} bytes reached the grandchild after run returned"
1037        );
1038    }
1039
1040    #[cfg(unix)]
1041    #[test]
1042    fn a_session_escaped_stdin_holder_does_not_hold_run_past_its_timeout() {
1043        let _serial = serial();
1044        let holder = Grandchild::new("timeout");
1045        // The leader blocks past the deadline, so the tree kill (not an exit) ends it.
1046        let script = holder_script("exec sleep 30", "sleep 30");
1047        let big = vec![b'x'; 2 << 20];
1048        let timeout = Duration::from_secs(1);
1049        let options = RunOptions::new(timeout);
1050        let started = std::time::Instant::now();
1051        let output = run_with_stdin(
1052            "sh",
1053            ["-c", &script, "sh", holder.0.to_str().unwrap()],
1054            &big,
1055            &options,
1056        )
1057        .unwrap();
1058        let elapsed = started.elapsed();
1059        if !holder.alive() && !output.timed_out {
1060            eprintln!("skipped: no setsid or perl to leave the session");
1061            return;
1062        }
1063        assert!(
1064            holder.alive(),
1065            "the fixture's grandchild must still hold stdin"
1066        );
1067        assert!(output.timed_out);
1068        assert!(
1069            elapsed < timeout + Duration::from_secs(6),
1070            "run took {elapsed:?}"
1071        );
1072        assert_eq!(
1073            LIVE_WRITERS.load(SeqCst),
1074            0,
1075            "the writer thread must be gone"
1076        );
1077    }
1078
1079    #[cfg(unix)]
1080    #[test]
1081    fn output_past_the_cap_is_truncated_not_blocking() {
1082        let options = RunOptions::new(Duration::from_secs(10)).stdout_cap(4);
1083        let output = run("sh", ["-c", "head -c 100000 /dev/zero"], &options).unwrap();
1084        assert_eq!(output.stdout.bytes.len(), 4);
1085        assert!(output.stdout.truncated);
1086    }
1087
1088    /// `sh -c script sh args...` (the first argument is `$1`).
1089    #[cfg(unix)]
1090    fn sh(script: &str, args: &[&str]) -> OwnedCommand {
1091        let mut command = OwnedCommand::new("sh");
1092        command.command_mut().args(["-c", script, "sh"]).args(args);
1093        command
1094    }
1095
1096    /// A program that outlives any test deadline.
1097    #[cfg(unix)]
1098    fn sleeper() -> OwnedCommand {
1099        let mut command = OwnedCommand::new("sleep");
1100        command.command_mut().arg("30");
1101        command
1102    }
1103
1104    #[cfg(windows)]
1105    fn sleeper() -> OwnedCommand {
1106        let mut command = OwnedCommand::new("ping");
1107        command.command_mut().args(["-n", "30", "127.0.0.1"]);
1108        command
1109    }
1110
1111    #[test]
1112    fn refused_reap_returns_bounded_cleanup_failure() {
1113        let mut child = sleeper().spawn().unwrap();
1114        crate::owned::cleanup_test_fault::arm();
1115        let started = Instant::now();
1116        let error = terminate_runner(&mut child, Duration::from_millis(20)).unwrap_err();
1117        assert!(started.elapsed() < Duration::from_secs(2));
1118        assert_eq!(error.kind(), io::ErrorKind::TimedOut);
1119        let detail = RunnerFailure::from_io(&error).unwrap();
1120        assert_eq!(detail.phase(), RunnerPhase::Cleanup);
1121        assert_eq!(detail.termination(), LeaderTermination::Unconfirmed);
1122    }
1123
1124    /// Runs `command` through `run_streaming`, collecting what the callbacks saw. With
1125    /// `cancel_on_first_chunk` the predicate turns true once the first stdout chunk arrived.
1126    fn stream_outcome(
1127        command: OwnedCommand,
1128        options: &StreamOptions,
1129        cancel_on_first_chunk: bool,
1130    ) -> (io::Result<StreamOutput>, Vec<u8>, Vec<String>) {
1131        let flag = std::cell::Cell::new(false);
1132        let mut chunks = Vec::new();
1133        let mut lines = Vec::new();
1134        let output = run_streaming(
1135            command,
1136            options,
1137            &|| flag.get(),
1138            &mut |chunk: &[u8]| {
1139                chunks.extend_from_slice(chunk);
1140                if cancel_on_first_chunk {
1141                    flag.set(true);
1142                }
1143            },
1144            &mut |line: &str| lines.push(line.to_owned()),
1145        );
1146        (output, chunks, lines)
1147    }
1148
1149    /// Success-only convenience; failure fixtures use `stream_outcome` to inspect metadata.
1150    fn stream(
1151        command: OwnedCommand,
1152        options: &StreamOptions,
1153        cancel_on_first_chunk: bool,
1154    ) -> (StreamOutput, Vec<u8>, Vec<String>) {
1155        let (output, chunks, lines) = stream_outcome(command, options, cancel_on_first_chunk);
1156        (
1157            output.expect("successful streaming fixture must reach EOF"),
1158            chunks,
1159            lines,
1160        )
1161    }
1162
1163    #[cfg(windows)]
1164    fn assert_incomplete_drain(error: &io::Error) {
1165        assert_eq!(error.kind(), io::ErrorKind::TimedOut);
1166        let detail = RunnerFailure::from_io(error).expect("runner failure metadata");
1167        assert_eq!(detail.phase(), RunnerPhase::Drain);
1168        assert_eq!(detail.termination(), LeaderTermination::Confirmed);
1169    }
1170
1171    #[test]
1172    fn run_command_passes_a_caller_set_env_var_to_the_child() {
1173        #[cfg(unix)]
1174        let mut command = sh("printf %s \"$RK_RUN_COMMAND_VAR\"", &[]);
1175        #[cfg(windows)]
1176        let mut command = {
1177            let mut command = OwnedCommand::new("cmd");
1178            command
1179                .command_mut()
1180                .args(["/C", "echo %RK_RUN_COMMAND_VAR%"]);
1181            command
1182        };
1183        command
1184            .command_mut()
1185            .env("RK_RUN_COMMAND_VAR", "from-the-caller");
1186        let output = run_command(command, None, &RunOptions::new(Duration::from_secs(20))).unwrap();
1187        assert!(output.status.success());
1188        assert!(!output.timed_out);
1189        assert!(
1190            String::from_utf8_lossy(&output.stdout.bytes).contains("from-the-caller"),
1191            "{:?}",
1192            output.stdout
1193        );
1194    }
1195
1196    #[cfg(unix)]
1197    #[test]
1198    fn run_command_with_input_reaches_the_child() {
1199        let _serial = serial();
1200        let options = RunOptions::new(Duration::from_secs(10));
1201        let output = run_command(sh("cat", &[]), Some(b"hello"), &options).unwrap();
1202        assert!(output.status.success());
1203        assert_eq!(output.stdout.bytes, b"hello".to_vec());
1204        assert_eq!(LIVE_WRITERS.load(SeqCst), 0);
1205    }
1206
1207    #[cfg(unix)]
1208    #[test]
1209    fn a_session_escaped_stdin_holder_does_not_hold_run_command_past_its_timeout() {
1210        let _serial = serial();
1211        let holder = Grandchild::new("command-timeout");
1212        let script = holder_script("exec sleep 30", "sleep 30");
1213        let big = vec![b'x'; 2 << 20];
1214        let timeout = Duration::from_secs(1);
1215        let options = RunOptions::new(timeout);
1216        let started = std::time::Instant::now();
1217        let output = run_command(
1218            sh(script.as_str(), &[holder.0.to_str().unwrap()]),
1219            Some(&big),
1220            &options,
1221        )
1222        .unwrap();
1223        let elapsed = started.elapsed();
1224        if !holder.alive() && !output.timed_out {
1225            eprintln!("skipped: no setsid or perl to leave the session");
1226            return;
1227        }
1228        assert!(
1229            holder.alive(),
1230            "the fixture's grandchild must still hold stdin"
1231        );
1232        assert!(output.timed_out);
1233        assert!(
1234            elapsed < timeout + Duration::from_secs(6),
1235            "run_command took {elapsed:?}"
1236        );
1237        assert_eq!(
1238            LIVE_WRITERS.load(SeqCst),
1239            0,
1240            "the writer thread must be gone"
1241        );
1242    }
1243
1244    #[cfg(unix)]
1245    #[test]
1246    fn run_streaming_forwards_stdout_chunks_in_order_including_cr() {
1247        let command = sh(
1248            "printf 'a\\r'; sleep 0.1; printf 'b\\r'; sleep 0.1; printf 'c\\n'",
1249            &[],
1250        );
1251        let (output, chunks, lines) = stream(command, &StreamOptions::new(), false);
1252        assert!(output.status.success());
1253        assert!(!output.cancelled && !output.timed_out);
1254        assert_eq!(chunks, b"a\rb\rc\n".to_vec());
1255        assert_eq!(output.stdout_tail, b"a\rb\rc\n".to_vec());
1256        assert!(lines.is_empty());
1257        assert_eq!(output.stderr_tail, "");
1258    }
1259
1260    #[cfg(unix)]
1261    #[test]
1262    fn run_streaming_splits_stderr_lines_on_cr_and_lf() {
1263        let command = sh("printf 'x\\ry\\nz\\r\\nw' >&2; exit 3", &[]);
1264        let (output, chunks, lines) = stream(command, &StreamOptions::new(), false);
1265        assert_eq!(output.status.code(), Some(3));
1266        assert!(chunks.is_empty());
1267        assert_eq!(lines, ["x", "y", "z", "w"]);
1268        assert_eq!(output.stderr_tail, "x\ny\nz\nw");
1269    }
1270
1271    #[cfg(unix)]
1272    #[test]
1273    fn run_streaming_tails_keep_the_last_bytes() {
1274        let command = sh(
1275            "head -c 100000 /dev/zero | tr '\\0' 'x'; printf END; \
1276             printf 'l1\\nl2\\nl3\\nl4\\n' >&2",
1277            &[],
1278        );
1279        let options = StreamOptions::new().stdout_tail_cap(10).stderr_tail_cap(6);
1280        let (output, chunks, lines) = stream(command, &options, false);
1281        assert!(output.status.success());
1282        // The callbacks saw everything; only the tails are cut.
1283        assert_eq!(chunks.len(), 100_003);
1284        assert_eq!(lines, ["l1", "l2", "l3", "l4"]);
1285        assert_eq!(output.stdout_tail, b"xxxxxxxEND".to_vec());
1286        assert_eq!(output.stderr_tail, "l3\nl4");
1287    }
1288
1289    #[cfg(unix)]
1290    #[test]
1291    fn run_streaming_cancel_after_the_first_chunk_kills_the_tree_and_a_grandchild() {
1292        let _serial = serial();
1293        let holder = Grandchild::new("stream-cancel");
1294        // The grandchild shares the leader's group and both pipes; it records its pid before
1295        // the first byte is written.
1296        let command = sh(
1297            "sleep 30 & echo $! > \"$1\"; printf go; sleep 30",
1298            &[holder.0.to_str().unwrap()],
1299        );
1300        let options = StreamOptions::new().poll(Duration::from_millis(50));
1301        let started = Instant::now();
1302        let (output, chunks, _lines) = stream(command, &options, true);
1303        let elapsed = started.elapsed();
1304        assert!(output.cancelled);
1305        assert!(!output.timed_out);
1306        assert!(!output.status.success());
1307        assert_eq!(chunks, b"go".to_vec());
1308        assert_eq!(output.stdout_tail, b"go".to_vec());
1309        assert!(
1310            elapsed < Duration::from_secs(4),
1311            "cancel took {elapsed:?} to take effect"
1312        );
1313        assert!(holder.pid().is_some(), "the grandchild never started");
1314        let deadline = Instant::now() + Duration::from_secs(5);
1315        while holder.alive() {
1316            assert!(
1317                Instant::now() < deadline,
1318                "the grandchild survived the kill"
1319            );
1320            std::thread::sleep(Duration::from_millis(20));
1321        }
1322    }
1323
1324    #[test]
1325    fn run_streaming_cancel_before_any_output_ends_the_call() {
1326        let started = Instant::now();
1327        let output = run_streaming(
1328            sleeper(),
1329            &StreamOptions::new(),
1330            &|| true,
1331            &mut |_chunk: &[u8]| {},
1332            &mut |_line: &str| {},
1333        )
1334        .unwrap();
1335        assert!(output.cancelled);
1336        assert!(!output.timed_out);
1337        assert!(started.elapsed() < Duration::from_secs(8));
1338    }
1339
1340    #[test]
1341    fn run_streaming_timeout_sets_timed_out() {
1342        let options = StreamOptions::new().timeout(Duration::from_millis(300));
1343        let started = Instant::now();
1344        let (output, _chunks, lines) = stream(sleeper(), &options, false);
1345        let elapsed = started.elapsed();
1346        assert!(output.timed_out);
1347        assert!(!output.cancelled);
1348        assert!(lines.is_empty());
1349        assert!(elapsed >= Duration::from_millis(300), "{elapsed:?}");
1350        assert!(elapsed < Duration::from_secs(8), "{elapsed:?}");
1351    }
1352
1353    #[cfg(unix)]
1354    #[test]
1355    fn run_streaming_with_input_round_trips_more_than_a_pipe_buffer() {
1356        let _serial = serial();
1357        let big = vec![b'x'; 1 << 20];
1358        let options = StreamOptions::new()
1359            .timeout(Duration::from_secs(30))
1360            .input(big.clone());
1361        let (output, chunks, _lines) = stream(sh("cat", &[]), &options, false);
1362        assert!(output.status.success());
1363        assert!(!output.timed_out);
1364        assert_eq!(chunks, big);
1365        assert_eq!(output.stdout_tail.len(), 64 * 1024);
1366        assert_eq!(LIVE_WRITERS.load(SeqCst), 0);
1367        // A child that never reads its input is a normal early exit.
1368        let options = StreamOptions::new().input(big);
1369        let (output, _chunks, _lines) = stream(sh("exit 7", &[]), &options, false);
1370        assert_eq!(output.status.code(), Some(7));
1371        assert_eq!(LIVE_WRITERS.load(SeqCst), 0);
1372    }
1373
1374    #[cfg(unix)]
1375    #[test]
1376    fn a_session_escaped_stdin_holder_does_not_hold_run_streaming_past_its_timeout() {
1377        let _serial = serial();
1378        let holder = Grandchild::new("stream-timeout");
1379        let script = holder_script("exec sleep 30", "sleep 30");
1380        let timeout = Duration::from_secs(1);
1381        let options = StreamOptions::new()
1382            .timeout(timeout)
1383            .input(vec![b'x'; 2 << 20]);
1384        let started = Instant::now();
1385        let (output, _chunks, _lines) = stream(
1386            sh(script.as_str(), &[holder.0.to_str().unwrap()]),
1387            &options,
1388            false,
1389        );
1390        let elapsed = started.elapsed();
1391        if !holder.alive() && !output.timed_out {
1392            eprintln!("skipped: no setsid or perl to leave the session");
1393            return;
1394        }
1395        assert!(
1396            holder.alive(),
1397            "the fixture's grandchild must still hold stdin"
1398        );
1399        assert!(output.timed_out);
1400        assert!(
1401            elapsed < timeout + Duration::from_secs(8),
1402            "run_streaming took {elapsed:?}"
1403        );
1404        assert_eq!(
1405            LIVE_WRITERS.load(SeqCst),
1406            0,
1407            "the writer thread must be gone"
1408        );
1409    }
1410
1411    // ---- Capture failures surface as errors (not an Ok with partial output). ----
1412
1413    /// A child that writes one line to each of stdout and stderr.
1414    fn noisy() -> OwnedCommand {
1415        #[cfg(unix)]
1416        {
1417            sh("printf out; printf err >&2", &[])
1418        }
1419        #[cfg(windows)]
1420        {
1421            use std::os::windows::process::CommandExt;
1422            let mut command = OwnedCommand::new("cmd");
1423            command.command_mut().raw_arg("/C echo out & echo err 1>&2");
1424            command
1425        }
1426    }
1427
1428    #[test]
1429    fn a_capture_read_error_is_an_error_from_run_command() {
1430        use crate::native_capture::{inject_read_fault, Stream};
1431        let options = RunOptions::new(Duration::from_secs(20));
1432        for (stream, name) in [(Stream::Stdout, "stdout"), (Stream::Stderr, "stderr")] {
1433            inject_read_fault(stream, io::ErrorKind::PermissionDenied);
1434            let error = run_command(noisy(), None, &options)
1435                .expect_err("a stored capture error must not become an Ok with partial output");
1436            assert_eq!(error.kind(), io::ErrorKind::PermissionDenied, "{error}");
1437            assert!(error.to_string().contains(name), "{error}");
1438        }
1439        // Control: with nothing armed the same command is a plain Ok.
1440        let output = run_command(noisy(), None, &options).unwrap();
1441        assert!(output.status.success());
1442        assert!(!output.stdout.bytes.is_empty() && !output.stderr.bytes.is_empty());
1443    }
1444
1445    /// Program and arguments for `run`/`run_with_stdin`, which take no `OwnedCommand`.
1446    fn noisy_argv() -> (&'static str, Vec<&'static str>) {
1447        #[cfg(unix)]
1448        {
1449            ("sh", vec!["-c", "printf out; printf err >&2"])
1450        }
1451        #[cfg(windows)]
1452        {
1453            ("cmd", vec!["/C", "echo out & echo err 1>&2"])
1454        }
1455    }
1456
1457    #[test]
1458    fn a_capture_read_error_is_an_error_from_run_and_run_with_stdin() {
1459        use crate::native_capture::{inject_read_fault, Stream};
1460        let options = RunOptions::new(Duration::from_secs(20));
1461        let (program, args) = noisy_argv();
1462        for (stream, name) in [(Stream::Stdout, "stdout"), (Stream::Stderr, "stderr")] {
1463            inject_read_fault(stream, io::ErrorKind::PermissionDenied);
1464            let error = run(program, &args, &options).expect_err(
1465                "run: a stored capture error must not become an Ok with partial output",
1466            );
1467            assert_eq!(error.kind(), io::ErrorKind::PermissionDenied, "{error}");
1468            assert!(error.to_string().contains(name), "{error}");
1469
1470            inject_read_fault(stream, io::ErrorKind::PermissionDenied);
1471            let error = run_with_stdin(program, &args, b"input", &options).expect_err(
1472                "run_with_stdin: a stored capture error must not become an Ok with partial output",
1473            );
1474            assert_eq!(error.kind(), io::ErrorKind::PermissionDenied, "{error}");
1475            assert!(error.to_string().contains(name), "{error}");
1476        }
1477        // Control: with nothing armed both runners return a plain Ok with both streams.
1478        let output = run(program, &args, &options).unwrap();
1479        assert!(output.status.success());
1480        assert!(!output.stdout.bytes.is_empty() && !output.stderr.bytes.is_empty());
1481        let output = run_with_stdin(program, &args, b"input", &options).unwrap();
1482        assert!(output.status.success());
1483    }
1484
1485    #[test]
1486    fn configured_capture_without_deadline_keeps_raw_first_bytes() {
1487        let options = CaptureOptions::new().stdout_cap(2).stderr_cap(2);
1488        assert_eq!(options.timeout, None);
1489        let output = run_command_capture(noisy(), None, &options).unwrap();
1490        assert!(output.status.success());
1491        assert!(!output.timed_out);
1492        assert_eq!(output.stdout.bytes, b"ou");
1493        assert_eq!(output.stderr.bytes, b"er");
1494        assert!(output.stdout.truncated && output.stderr.truncated);
1495        assert!(output.stdout.total_bytes >= 3 && output.stderr.total_bytes >= 3);
1496    }
1497
1498    #[test]
1499    fn missing_program_is_typed_not_started_for_both_runners() {
1500        let missing = std::env::current_exe()
1501            .unwrap()
1502            .with_extension("rightkit-missing-program");
1503        let error = run_command_capture(OwnedCommand::new(&missing), None, &CaptureOptions::new())
1504            .unwrap_err();
1505        let failure = RunnerFailure::from_io(&error).unwrap();
1506        assert_eq!(failure.phase(), RunnerPhase::Spawn);
1507        assert_eq!(failure.termination(), LeaderTermination::NotStarted);
1508        assert_eq!(failure.source_io_error().kind(), io::ErrorKind::NotFound);
1509        let error = run_streaming(
1510            OwnedCommand::new(&missing),
1511            &StreamOptions::new(),
1512            &|| false,
1513            &mut |_| {},
1514            &mut |_| {},
1515        )
1516        .unwrap_err();
1517        let failure = RunnerFailure::from_io(&error).unwrap();
1518        assert_eq!(failure.phase(), RunnerPhase::Spawn);
1519        assert_eq!(failure.termination(), LeaderTermination::NotStarted);
1520    }
1521
1522    #[test]
1523    fn a_cancelled_capture_without_eof_is_never_successful_output() {
1524        let mut command = sleeper();
1525        command.pipe_stdio(false);
1526        let mut child = command.spawn().unwrap();
1527        let mut stdout = NativeOutputCapture::stdout(child.take_stdout().unwrap(), 128).unwrap();
1528        let mut stderr = NativeOutputCapture::stderr(child.take_stderr().unwrap(), 128).unwrap();
1529        stdout.cancel();
1530        stderr.cancel();
1531        assert!(matches!(
1532            child
1533                .terminate_tree_bounded(Duration::from_secs(5))
1534                .unwrap(),
1535            crate::ReapOutcome::Exited(_)
1536        ));
1537        for (name, capture) in [("stdout", &stdout), ("stderr", &stderr)] {
1538            assert!(!capture.is_complete());
1539            let error = capture_failure(name, Some(capture)).unwrap_err();
1540            assert_eq!(error.kind(), io::ErrorKind::TimedOut);
1541            assert!(error.to_string().contains(name));
1542            assert_eq!(
1543                RunnerFailure::from_io(&error).unwrap().phase(),
1544                RunnerPhase::Drain
1545            );
1546        }
1547    }
1548
1549    #[test]
1550    fn a_capture_read_error_is_an_error_from_run_streaming() {
1551        use crate::native_capture::{inject_read_fault, Stream};
1552        let options = StreamOptions::new().timeout(Duration::from_secs(20));
1553        for (stream, name) in [(Stream::Stdout, "stdout"), (Stream::Stderr, "stderr")] {
1554            inject_read_fault(stream, io::ErrorKind::PermissionDenied);
1555            let error = run_streaming(noisy(), &options, &|| false, &mut |_| {}, &mut |_| {})
1556                .expect_err("a stored capture error must not become an Ok with partial output");
1557            assert_eq!(error.kind(), io::ErrorKind::PermissionDenied, "{error}");
1558            assert!(error.to_string().contains(name), "{error}");
1559            let detail = RunnerFailure::from_io(&error).unwrap();
1560            assert_eq!(detail.termination(), LeaderTermination::Confirmed);
1561            assert_eq!(
1562                detail.phase(),
1563                if name == "stdout" {
1564                    RunnerPhase::Stdout
1565                } else {
1566                    RunnerPhase::Stderr
1567                }
1568            );
1569            assert_eq!(
1570                detail.source_io_error().kind(),
1571                io::ErrorKind::PermissionDenied
1572            );
1573        }
1574        let output = run_streaming(noisy(), &options, &|| false, &mut |_| {}, &mut |_| {}).unwrap();
1575        assert!(output.status.success());
1576    }
1577
1578    // ---- One piped-stdio setup for both spawn paths. ----
1579
1580    /// `command_mut` after the runners' stdio request cannot undo it: the request is applied at
1581    /// spawn, after every caller edit.
1582    #[test]
1583    fn command_mut_after_the_piped_setup_does_not_lose_it() {
1584        let mut command = sleeper();
1585        command.pipe_stdio(true);
1586        command
1587            .command_mut()
1588            .stdin(Stdio::null())
1589            .stdout(Stdio::inherit())
1590            .stderr(Stdio::null());
1591        let mut child = command.spawn().unwrap();
1592        assert!(child.take_stdin().is_some(), "stdin must stay piped");
1593        assert!(child.take_stdout().is_some(), "stdout must stay piped");
1594        assert!(child.take_stderr().is_some(), "stderr must stay piped");
1595    }
1596
1597    /// Windows: the same, on the native spawn path, where an explicit config (here with every
1598    /// stream null) is what drives stdio and `command_mut` runs after it was set.
1599    #[cfg(windows)]
1600    #[test]
1601    fn command_mut_after_an_explicit_config_does_not_lose_the_piped_setup() {
1602        use crate::{WindowsSpawnConfig, WindowsStdio};
1603        let mut command = sleeper();
1604        command
1605            .inherit_handles_only([])
1606            .windows_spawn_config(WindowsSpawnConfig {
1607                stdin: WindowsStdio::Null,
1608                stdout: WindowsStdio::Null,
1609                stderr: WindowsStdio::Null,
1610                ..Default::default()
1611            });
1612        command.pipe_stdio(true);
1613        command
1614            .command_mut()
1615            .stdin(Stdio::null())
1616            .stdout(Stdio::null())
1617            .stderr(Stdio::null());
1618        let mut child = command.spawn().unwrap();
1619        assert!(child.take_stdin().is_some(), "stdin must stay piped");
1620        assert!(child.take_stdout().is_some(), "stdout must stay piped");
1621        assert!(child.take_stderr().is_some(), "stderr must stay piped");
1622    }
1623
1624    /// Windows: the automatic config of `OwnedCommand::new` survives the runners' stdio request
1625    /// (it used to be cleared by their `command_mut`, failing an allowlisted spawn).
1626    #[cfg(windows)]
1627    #[test]
1628    fn the_piped_setup_keeps_the_automatic_windows_config() {
1629        let mut command = OwnedCommand::new("sort");
1630        command.inherit_handles_only([]);
1631        command.pipe_stdio(true);
1632        let mut child = command.spawn().unwrap();
1633        assert!(child.take_stdin().is_some(), "stdin must be piped");
1634        assert!(child.take_stdout().is_some(), "stdout must be piped");
1635        assert!(child.take_stderr().is_some(), "stderr must be piped");
1636    }
1637
1638    /// `cmd /C findstr . & echo err 1>&2`: copies stdin to stdout, then writes `err` to stderr.
1639    #[cfg(windows)]
1640    fn echo_stdin_and_report_on_stderr() -> OwnedCommand {
1641        use std::os::windows::process::CommandExt;
1642        let mut command = OwnedCommand::new("cmd");
1643        command
1644            .command_mut()
1645            .raw_arg("/C findstr . & echo err 1>&2");
1646        command
1647    }
1648
1649    /// Explicit configs an allowlisted command may carry: the default (all inherited) and one
1650    /// with every stream null. The runners must replace the stdio of both.
1651    #[cfg(windows)]
1652    fn explicit_configs() -> Vec<crate::WindowsSpawnConfig> {
1653        use crate::{WindowsSpawnConfig, WindowsStdio};
1654        vec![
1655            WindowsSpawnConfig::default(),
1656            WindowsSpawnConfig {
1657                stdin: WindowsStdio::Null,
1658                stdout: WindowsStdio::Null,
1659                stderr: WindowsStdio::Null,
1660                ..Default::default()
1661            },
1662        ]
1663    }
1664
1665    #[cfg(windows)]
1666    #[test]
1667    fn a_configured_windows_command_gets_stdin_and_captures_both_streams_through_run_command() {
1668        let _serial = serial();
1669        for config in explicit_configs() {
1670            let mut command = echo_stdin_and_report_on_stderr();
1671            command
1672                .inherit_handles_only([])
1673                .windows_spawn_config(config);
1674            let output = run_command(
1675                command,
1676                Some(b"hello\r\n"),
1677                &RunOptions::new(Duration::from_secs(30)),
1678            )
1679            .unwrap();
1680            assert!(!output.timed_out);
1681            let stdout = String::from_utf8_lossy(&output.stdout.bytes).into_owned();
1682            let stderr = String::from_utf8_lossy(&output.stderr.bytes).into_owned();
1683            assert!(
1684                stdout.contains("hello"),
1685                "stdin was not delivered: {stdout:?}"
1686            );
1687            assert!(
1688                stderr.contains("err"),
1689                "stderr was not captured: {stderr:?}"
1690            );
1691            assert_eq!(LIVE_WRITERS.load(SeqCst), 0);
1692        }
1693    }
1694
1695    #[cfg(windows)]
1696    #[test]
1697    fn a_configured_windows_command_gets_stdin_and_captures_both_streams_through_run_streaming() {
1698        let _serial = serial();
1699        for config in explicit_configs() {
1700            let mut command = echo_stdin_and_report_on_stderr();
1701            command
1702                .inherit_handles_only([])
1703                .windows_spawn_config(config);
1704            let options = StreamOptions::new()
1705                .timeout(Duration::from_secs(30))
1706                .input(b"hello\r\n".to_vec());
1707            let (output, chunks, lines) = stream(command, &options, false);
1708            assert!(!output.timed_out);
1709            let stdout = String::from_utf8_lossy(&chunks).into_owned();
1710            assert!(
1711                stdout.contains("hello"),
1712                "stdin was not delivered: {stdout:?}"
1713            );
1714            assert!(
1715                lines.iter().any(|line| line.contains("err")),
1716                "stderr was not captured: {lines:?}"
1717            );
1718            assert_eq!(LIVE_WRITERS.load(SeqCst), 0);
1719        }
1720    }
1721
1722    /// An automatic config (a `new()` command never touched through `command_mut`) used to be
1723    /// cleared by the runner's own `command_mut`, failing the allowlisted spawn.
1724    #[cfg(windows)]
1725    #[test]
1726    fn an_allowlisted_new_command_with_the_automatic_config_runs_through_both_runners() {
1727        let _serial = serial();
1728        let sorted = |bytes: &[u8]| String::from_utf8_lossy(bytes).replace("\r\n", "\n");
1729        let mut command = OwnedCommand::new("sort");
1730        command.inherit_handles_only([]);
1731        let output = run_command(
1732            command,
1733            Some(b"b\r\na\r\n"),
1734            &RunOptions::new(Duration::from_secs(30)),
1735        )
1736        .unwrap();
1737        assert_eq!(sorted(&output.stdout.bytes), "a\nb\n");
1738
1739        let mut command = OwnedCommand::new("sort");
1740        command.inherit_handles_only([]);
1741        let options = StreamOptions::new()
1742            .timeout(Duration::from_secs(30))
1743            .input(b"b\r\na\r\n".to_vec());
1744        let (_output, chunks, _lines) = stream(command, &options, false);
1745        assert_eq!(sorted(&chunks), "a\nb\n");
1746    }
1747
1748    // ---- Windows: the runners end to end (large stdin, surviving descendants, output caps). ----
1749    //
1750    // The Unix tests above drive `sh`. Windows has no shell fixture that holds a pipe open without
1751    // reading it, so the helper is this test binary run again: a test that does nothing unless it
1752    // is armed (`HELPER_ENV`, or `HELPER_ARG` for `run` and `run_with_stdin`, which take no
1753    // environment), the pattern of `rightkit-fs`' lease kill helper. The child under test is the
1754    // "leader". It starts a "holder", another run of the helper that inherits the leader's three
1755    // stdio handles, never reads stdin and sleeps: inside the job (an ordinary descendant, which
1756    // the runners must take down) or, when the job allows it, outside the job with
1757    // `CREATE_BREAKAWAY_FROM_JOB` (the Windows counterpart of the Unix setsid holder: it survives
1758    // the tree kill and keeps both pipe ends open). `run` and `run_with_stdin` build their command
1759    // with `OwnedCommand::new`, whose job refuses breakaway, so nothing can outlive them: the
1760    // surviving-holder cases use `run_command` and `run_streaming`, which take an `OwnedCommand`
1761    // and can call `windows_allow_breakaway(true)`.
1762    //
1763    // libtest writes its own lines to the stdout it inherits, so echoed bytes are framed by
1764    // markers, and the leader waits until the holder is ready (its libtest header is written
1765    // before it signals) before it writes anything of its own. Every wait is bounded and every
1766    // survivor is killed (by an identity-pinned pid) when its fixture drops.
1767
1768    /// Arms `windows_runner_helper_child`: the value is the spec of the role to play.
1769    #[cfg(windows)]
1770    const HELPER_ENV: &str = "RK_RUN_HELPER";
1771    /// The other way to arm it, for the runners that cannot set an environment variable: an
1772    /// extra libtest filter (it matches no test) that carries the spec.
1773    #[cfg(windows)]
1774    const HELPER_ARG: &str = "rk-run-helper=";
1775    #[cfg(windows)]
1776    const BEGIN: &[u8] = b"<<RK-BEGIN>>";
1777    #[cfg(windows)]
1778    const END: &[u8] = b"<<RK-END>>";
1779    /// Written by a leader to say the holder exists and it is about to block.
1780    #[cfg(windows)]
1781    const GO: &[u8] = b"<<RK-GO>>";
1782    /// Written to stdout by the flood roles when they have written everything: a child that
1783    /// blocked on a full stderr pipe never gets here.
1784    #[cfg(windows)]
1785    const DONE: &[u8] = b"<<RK-DONE>>";
1786    /// Longest a holder or a blocked leader lives if nothing kills it, so a leak ends by itself.
1787    #[cfg(windows)]
1788    const HOLD_SECONDS: u64 = 120;
1789
1790    /// Printable bytes that do not repeat over a short period, so a dropped, duplicated or
1791    /// reordered stretch shows up.
1792    #[cfg(windows)]
1793    fn pattern(offset: usize, len: usize) -> Vec<u8> {
1794        (offset..offset + len)
1795            .map(|index| b'!' + ((index + index / 95) % 90) as u8)
1796            .collect()
1797    }
1798
1799    #[cfg(windows)]
1800    fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
1801        haystack
1802            .windows(needle.len())
1803            .position(|window| window == needle)
1804    }
1805
1806    /// What lies between the first `BEGIN` and the `END` after it.
1807    #[cfg(windows)]
1808    fn framed(output: &[u8]) -> &[u8] {
1809        let begin = find(output, BEGIN).expect("the begin marker is missing") + BEGIN.len();
1810        let end = begin + find(&output[begin..], END).expect("the end marker is missing");
1811        &output[begin..end]
1812    }
1813
1814    #[cfg(windows)]
1815    fn helper_spec() -> Option<String> {
1816        if let Ok(spec) = std::env::var(HELPER_ENV) {
1817            return Some(spec);
1818        }
1819        std::env::args().find_map(|arg| arg.strip_prefix(HELPER_ARG).map(str::to_owned))
1820    }
1821
1822    /// The libtest name of the helper, for `--exact`.
1823    #[cfg(windows)]
1824    fn helper_test_name() -> String {
1825        let module = module_path!();
1826        let path = module.split_once("::").map_or(module, |(_, rest)| rest);
1827        format!("{path}::windows_runner_helper_child")
1828    }
1829
1830    /// The helper armed through the environment, for `run_command` and `run_streaming`.
1831    #[cfg(windows)]
1832    fn helper_command(spec: &str) -> OwnedCommand {
1833        let mut command = OwnedCommand::new(std::env::current_exe().unwrap());
1834        command
1835            .command_mut()
1836            .args([
1837                "--exact",
1838                helper_test_name().as_str(),
1839                "--nocapture",
1840                "--test-threads=1",
1841            ])
1842            .env(HELPER_ENV, spec);
1843        command
1844    }
1845
1846    /// The helper armed through an argument: the arguments for `run` and `run_with_stdin` (the
1847    /// program is `std::env::current_exe()`).
1848    #[cfg(windows)]
1849    fn helper_argv(spec: &str) -> Vec<String> {
1850        vec![
1851            "--exact".to_owned(),
1852            helper_test_name(),
1853            "--nocapture".to_owned(),
1854            "--test-threads=1".to_owned(),
1855            format!("{HELPER_ARG}{spec}"),
1856        ]
1857    }
1858
1859    #[cfg(windows)]
1860    fn wait_for_file(path: &std::path::Path, limit: Duration) -> bool {
1861        let deadline = Instant::now() + limit;
1862        while !path.exists() {
1863            if Instant::now() >= deadline {
1864                return false;
1865            }
1866            std::thread::sleep(Duration::from_millis(20));
1867        }
1868        true
1869    }
1870
1871    /// Writes through a temporary name and renames, so a reader never sees half a file.
1872    #[cfg(windows)]
1873    fn write_atomic(path: &std::path::Path, text: &str) {
1874        let mut temporary = path.as_os_str().to_owned();
1875        temporary.push(".tmp");
1876        std::fs::write(&temporary, text).unwrap();
1877        std::fs::rename(&temporary, path).unwrap();
1878    }
1879
1880    #[cfg(windows)]
1881    fn hold_for_a_while() {
1882        for _ in 0..HOLD_SECONDS {
1883            std::thread::sleep(Duration::from_secs(1));
1884        }
1885    }
1886
1887    /// Role `echo`: copies stdin to stdout between the markers, then ends.
1888    #[cfg(windows)]
1889    fn echo_stdin_framed() {
1890        use std::io::{Read, Write};
1891        let mut stdin = std::io::stdin();
1892        let mut stdout = std::io::stdout();
1893        stdout.write_all(BEGIN).unwrap();
1894        let mut buffer = vec![0u8; 64 * 1024];
1895        loop {
1896            let read = stdin.read(&mut buffer).unwrap();
1897            if read == 0 {
1898                break;
1899            }
1900            stdout.write_all(&buffer[..read]).unwrap();
1901        }
1902        stdout.write_all(END).unwrap();
1903        stdout.flush().unwrap();
1904    }
1905
1906    /// Role `flood`: `total` pattern bytes on stderr after the begin marker, then `DONE` on
1907    /// stdout.
1908    #[cfg(windows)]
1909    fn flood_stderr(total: usize) {
1910        use std::io::Write;
1911        let mut stderr = std::io::stderr();
1912        stderr.write_all(BEGIN).unwrap();
1913        let mut written = 0;
1914        while written < total {
1915            let chunk = (total - written).min(8 * 1024);
1916            stderr.write_all(&pattern(written, chunk)).unwrap();
1917            written += chunk;
1918        }
1919        let mut stdout = std::io::stdout();
1920        stdout.write_all(DONE).unwrap();
1921        stdout.flush().unwrap();
1922    }
1923
1924    /// Role `flood-lines`: `count` stderr lines `L0000000`, `L0000001`, ..., then `DONE` on
1925    /// stdout.
1926    #[cfg(windows)]
1927    fn flood_stderr_lines(count: usize) {
1928        use std::io::Write;
1929        let mut stderr = std::io::stderr();
1930        let mut next = 0;
1931        while next < count {
1932            let upto = (next + 1000).min(count);
1933            let mut block = String::new();
1934            for index in next..upto {
1935                block.push_str(&format!("L{index:07}\n"));
1936            }
1937            stderr.write_all(block.as_bytes()).unwrap();
1938            next = upto;
1939        }
1940        let mut stdout = std::io::stdout();
1941        stdout.write_all(DONE).unwrap();
1942        stdout.flush().unwrap();
1943    }
1944
1945    /// Role `hold-read`: signals ready, waits for the gate file, then reads stdin to its end and
1946    /// records how many bytes it got. With the write end closed by the parent that is only what
1947    /// was already in the pipe; a write end left open would deliver everything.
1948    #[cfg(windows)]
1949    fn hold_then_count_stdin(
1950        ready: &std::path::Path,
1951        gate: &std::path::Path,
1952        count_file: &std::path::Path,
1953    ) {
1954        use std::io::Read;
1955        write_atomic(ready, "ready");
1956        assert!(
1957            wait_for_file(gate, Duration::from_secs(HOLD_SECONDS)),
1958            "the gate never opened"
1959        );
1960        let mut stdin = std::io::stdin();
1961        let mut buffer = vec![0u8; 64 * 1024];
1962        let mut total = 0usize;
1963        loop {
1964            match stdin.read(&mut buffer) {
1965                Ok(0) => break,
1966                Ok(read) => total += read,
1967                Err(error) if error.kind() == io::ErrorKind::BrokenPipe => break,
1968                Err(error) => panic!("reading stdin failed: {error}"),
1969            }
1970        }
1971        write_atomic(count_file, &total.to_string());
1972        hold_for_a_while();
1973    }
1974
1975    /// Role `leader`: records its pid, starts the holder (outside the job when `breakaway`),
1976    /// waits for it to be ready, records its pid, then does `then`: `echo` the input, `exit`
1977    /// without reading it, `block` until killed, or say `GO` and block. A holder that cannot be
1978    /// started is reported in `holder.err` (the raw OS error, 5 when the job refused breakaway)
1979    /// and the leader ends.
1980    #[cfg(windows)]
1981    #[allow(clippy::zombie_processes)] // The holder is meant to outlive the leader.
1982    fn run_leader(dir: &std::path::Path, holder: &str, breakaway: bool, then: &str) {
1983        use std::io::Write;
1984        use std::os::windows::process::CommandExt;
1985        write_atomic(&dir.join("leader.pid"), &std::process::id().to_string());
1986        let ready = dir.join("holder.ready");
1987        let holder_spec = match holder {
1988            "hold" => format!("hold|{}", ready.display()),
1989            "hold-read" => format!(
1990                "hold-read|{}|{}|{}",
1991                ready.display(),
1992                dir.join("gate").display(),
1993                dir.join("count").display()
1994            ),
1995            other => panic!("unknown holder {other}"),
1996        };
1997        let mut command = std::process::Command::new(std::env::current_exe().unwrap());
1998        command
1999            .args([
2000                "--exact",
2001                helper_test_name().as_str(),
2002                "--nocapture",
2003                "--test-threads=1",
2004            ])
2005            .env(HELPER_ENV, holder_spec)
2006            .stdin(std::process::Stdio::inherit())
2007            .stdout(std::process::Stdio::inherit())
2008            .stderr(std::process::Stdio::inherit());
2009        if breakaway {
2010            command.creation_flags(crate::CREATE_BREAKAWAY_FROM_JOB);
2011        }
2012        let child = match command.spawn() {
2013            Ok(child) => child,
2014            Err(error) => {
2015                let report = error
2016                    .raw_os_error()
2017                    .map_or_else(|| error.to_string(), |code| code.to_string());
2018                write_atomic(&dir.join("holder.err"), &report);
2019                return;
2020            }
2021        };
2022        if !wait_for_file(&ready, Duration::from_secs(30)) {
2023            write_atomic(&dir.join("holder.err"), "the holder never became ready");
2024            return;
2025        }
2026        write_atomic(&dir.join("holder.pid"), &child.id().to_string());
2027        drop(child);
2028        match then {
2029            "echo" => echo_stdin_framed(),
2030            "exit" => {}
2031            "block" => hold_for_a_while(),
2032            "go" => {
2033                let mut stdout = std::io::stdout();
2034                stdout.write_all(GO).unwrap();
2035                stdout.flush().unwrap();
2036                hold_for_a_while();
2037            }
2038            other => panic!("unknown leader action {other}"),
2039        }
2040    }
2041
2042    /// Not a test by itself: it returns at once unless `HELPER_ENV` or `HELPER_ARG` arms it, which
2043    /// only the Windows runner tests below do. Specs: `echo`, `exit-now`, `flood|<bytes>`,
2044    /// `flood-lines|<count>`, `hold|<ready file>`, `hold-read|<ready>|<gate>|<count file>` and
2045    /// `leader|<dir>|<hold or hold-read>|<1 to break away, else 0>|<echo, exit, block or go>`.
2046    #[cfg(windows)]
2047    #[test]
2048    fn windows_runner_helper_child() {
2049        let Some(spec) = helper_spec() else {
2050            return;
2051        };
2052        let parts: Vec<&str> = spec.split('|').collect();
2053        match parts.as_slice() {
2054            ["echo"] => echo_stdin_framed(),
2055            ["exit-now"] => std::process::exit(7),
2056            ["flood", total] => flood_stderr(total.parse().unwrap()),
2057            ["flood-lines", count] => flood_stderr_lines(count.parse().unwrap()),
2058            ["hold", ready] => {
2059                write_atomic(std::path::Path::new(ready), "ready");
2060                hold_for_a_while();
2061            }
2062            ["hold-read", ready, gate, count] => hold_then_count_stdin(
2063                std::path::Path::new(ready),
2064                std::path::Path::new(gate),
2065                std::path::Path::new(count),
2066            ),
2067            ["leader", dir, holder, breakaway, then] => {
2068                run_leader(std::path::Path::new(dir), holder, *breakaway == "1", then)
2069            }
2070            other => panic!("unknown helper spec {other:?}"),
2071        }
2072    }
2073
2074    /// A scratch directory for one test's pid, ready and gate files, and the identity of the
2075    /// holder once it is known to be alive. Dropping it kills that holder (pinned by its start
2076    /// time, so a recycled pid is never signalled) and removes the directory.
2077    #[cfg(windows)]
2078    struct HelperFixture {
2079        dir: std::path::PathBuf,
2080        holder: Option<crate::AdoptedIdentity>,
2081    }
2082
2083    #[cfg(windows)]
2084    impl HelperFixture {
2085        fn new(label: &str) -> Self {
2086            let nanos = std::time::SystemTime::now()
2087                .duration_since(std::time::UNIX_EPOCH)
2088                .map_or(0, |elapsed| elapsed.as_nanos());
2089            let dir = std::env::temp_dir()
2090                .join(format!("rk-run-win-{label}-{}-{nanos}", std::process::id()));
2091            std::fs::create_dir_all(&dir).unwrap();
2092            Self { dir, holder: None }
2093        }
2094
2095        fn read(&self, name: &str) -> Option<String> {
2096            std::fs::read_to_string(self.dir.join(name)).ok()
2097        }
2098
2099        fn pid(&self, name: &str) -> Option<u32> {
2100            self.read(name)?.trim().parse().ok()
2101        }
2102
2103        fn leader_spec(&self, holder: &str, breakaway: bool, then: &str) -> String {
2104            format!(
2105                "leader|{}|{holder}|{}|{then}",
2106                self.dir.display(),
2107                u8::from(breakaway)
2108            )
2109        }
2110
2111        /// The leader as an `OwnedCommand` whose job allows breakaway exactly when the leader
2112        /// asks for it.
2113        fn command(&self, holder: &str, breakaway: bool, then: &str) -> OwnedCommand {
2114            let mut command = helper_command(&self.leader_spec(holder, breakaway, then));
2115            command.windows_allow_breakaway(breakaway);
2116            command
2117        }
2118
2119        /// The leader's arguments for `run` / `run_with_stdin`.
2120        fn argv(&self, holder: &str, breakaway: bool, then: &str) -> Vec<String> {
2121            helper_argv(&self.leader_spec(holder, breakaway, then))
2122        }
2123
2124        /// The pid the leader reported for its holder, or a failure that says why there is none.
2125        fn holder_pid(&self) -> u32 {
2126            if let Some(error) = self.read("holder.err") {
2127                panic!(
2128                    "the leader could not start its holder: {error} (5 means the job refused \
2129                     CREATE_BREAKAWAY_FROM_JOB)"
2130                );
2131            }
2132            self.pid("holder.pid").expect(
2133                "the leader never reported its holder before the call ended (a very slow host?)",
2134            )
2135        }
2136
2137        /// Asserts the holder is still alive and remembers its identity so `Drop` can kill it.
2138        fn track_holder(&mut self) -> u32 {
2139            let pid = self.holder_pid();
2140            let identity = crate::AdoptedIdentity::capture(std::num::NonZeroU32::new(pid).unwrap())
2141                .unwrap()
2142                .unwrap_or_else(|| {
2143                    panic!("the holder (pid {pid}) is gone: it did not outlive the call")
2144                });
2145            assert!(
2146                identity.is_running().unwrap(),
2147                "the holder (pid {pid}) must still hold the pipes"
2148            );
2149            self.holder = Some(identity);
2150            pid
2151        }
2152    }
2153
2154    #[cfg(windows)]
2155    impl Drop for HelperFixture {
2156        fn drop(&mut self) {
2157            if let Some(identity) = self.holder.take() {
2158                let _ = identity.signal(crate::AdoptedSignal::Force);
2159                let _ = identity.wait_confirmed_gone(Duration::from_secs(5));
2160            }
2161            let _ = std::fs::remove_dir_all(&self.dir);
2162        }
2163    }
2164
2165    /// Waits (bounded) until no process has `pid`; fails if one still does.
2166    #[cfg(windows)]
2167    fn assert_gone(pid: u32, what: &str) {
2168        let process = crate::AdoptedProcess::new(std::num::NonZeroU32::new(pid).unwrap());
2169        let deadline = Instant::now() + Duration::from_secs(10);
2170        while process.is_running().unwrap() {
2171            assert!(
2172                Instant::now() < deadline,
2173                "{what} (pid {pid}) is still running"
2174            );
2175            std::thread::sleep(Duration::from_millis(20));
2176        }
2177    }
2178
2179    /// 1 MiB through stdin of a leader that echoes it back while an in-job holder keeps all
2180    /// three pipes open: both entry points that take input return the whole echo (neither
2181    /// direction deadlocks), the leader's own status, and no holder is left behind.
2182    #[cfg(windows)]
2183    #[test]
2184    fn run_with_stdin_and_run_command_round_trip_one_mib_and_reap_an_in_job_holder_windows() {
2185        let _serial = serial();
2186        let input = pattern(0, 1 << 20);
2187        let options = RunOptions::new(Duration::from_secs(60)).stdout_cap(4 << 20);
2188        for entry in ["run_with_stdin", "run_command"] {
2189            let fixture = HelperFixture::new(entry);
2190            let started = Instant::now();
2191            let result = if entry == "run_with_stdin" {
2192                run_with_stdin(
2193                    std::env::current_exe().unwrap(),
2194                    fixture.argv("hold", false, "echo"),
2195                    &input,
2196                    &options,
2197                )
2198            } else {
2199                run_command(
2200                    fixture.command("hold", false, "echo"),
2201                    Some(&input),
2202                    &options,
2203                )
2204            };
2205            let output = result.unwrap();
2206            let elapsed = started.elapsed();
2207            assert!(output.status.success(), "{entry}: {:?}", output.status);
2208            assert!(!output.timed_out, "{entry}: the call ran to its deadline");
2209            assert!(!output.stdout.truncated, "{entry}: stdout hit its cap");
2210            let echoed = framed(&output.stdout.bytes);
2211            assert!(
2212                echoed == input.as_slice(),
2213                "{entry}: echoed {} bytes, expected {}",
2214                echoed.len(),
2215                input.len()
2216            );
2217            assert!(
2218                elapsed < Duration::from_secs(30),
2219                "{entry} took {elapsed:?}"
2220            );
2221            assert_eq!(
2222                LIVE_WRITERS.load(SeqCst),
2223                0,
2224                "{entry}: the writer thread must be gone"
2225            );
2226            assert_gone(fixture.holder_pid(), "the in-job holder");
2227        }
2228    }
2229
2230    /// The same through `run_streaming`: the callbacks see the whole echo, the stdout tail is
2231    /// full, and the in-job holder is gone when the call returns.
2232    #[cfg(windows)]
2233    #[test]
2234    fn run_streaming_with_input_round_trips_one_mib_and_reaps_an_in_job_holder_windows() {
2235        let _serial = serial();
2236        let input = pattern(0, 1 << 20);
2237        let fixture = HelperFixture::new("stream-round-trip");
2238        let options = StreamOptions::new()
2239            .timeout(Duration::from_secs(60))
2240            .input(input.clone());
2241        let started = Instant::now();
2242        let command = fixture.command("hold", false, "echo");
2243        let (output, chunks, _lines) = stream(command, &options, false);
2244        let elapsed = started.elapsed();
2245        assert!(output.status.success(), "{:?}", output.status);
2246        assert!(!output.timed_out && !output.cancelled);
2247        let echoed = framed(&chunks);
2248        assert!(
2249            echoed == input.as_slice(),
2250            "echoed {} bytes, expected {}",
2251            echoed.len(),
2252            input.len()
2253        );
2254        assert_eq!(output.stdout_tail.len(), 64 * 1024);
2255        assert!(
2256            elapsed < Duration::from_secs(30),
2257            "run_streaming took {elapsed:?}"
2258        );
2259        assert_eq!(
2260            LIVE_WRITERS.load(SeqCst),
2261            0,
2262            "the writer thread must be gone"
2263        );
2264        assert_gone(fixture.holder_pid(), "the in-job holder");
2265    }
2266
2267    /// Windows counterpart of the Unix early-exit cases: a child that exits at once without
2268    /// reading 1 MiB of input is a normal early exit (the broken pipe is not an error) through
2269    /// every runner that takes input, with the child's own status.
2270    #[cfg(windows)]
2271    #[test]
2272    fn a_child_that_exits_without_reading_its_input_is_a_normal_early_exit_through_every_runner_windows(
2273    ) {
2274        let _serial = serial();
2275        let big = pattern(0, 1 << 20);
2276        let options = RunOptions::new(Duration::from_secs(60));
2277        let output = run_with_stdin(
2278            std::env::current_exe().unwrap(),
2279            helper_argv("exit-now"),
2280            &big,
2281            &options,
2282        )
2283        .unwrap();
2284        assert_eq!(output.status.code(), Some(7));
2285        assert!(!output.timed_out);
2286        let output = run_command(helper_command("exit-now"), Some(&big), &options).unwrap();
2287        assert_eq!(output.status.code(), Some(7));
2288        assert!(!output.timed_out);
2289        let options = StreamOptions::new()
2290            .timeout(Duration::from_secs(60))
2291            .input(big.clone());
2292        let (output, _chunks, _lines) = stream(helper_command("exit-now"), &options, false);
2293        assert_eq!(output.status.code(), Some(7));
2294        assert!(!output.timed_out && !output.cancelled);
2295        assert_eq!(
2296            LIVE_WRITERS.load(SeqCst),
2297            0,
2298            "the writer thread must be gone"
2299        );
2300    }
2301
2302    /// Windows counterpart of `a_session_escaped_stdin_holder_does_not_hold_run_past_its_exit`:
2303    /// the leader exits at once without reading 2 MiB of input while a holder that broke away
2304    /// from the job keeps stdin, stdout and stderr open and never reads. `run_command` returns
2305    /// well inside its deadline with Drain/Confirmed (EOF is unavailable) and no writer thread; the holder is
2306    /// still alive; and the write end really is closed: once the test opens the gate the holder
2307    /// reads stdin to its end and got only what was already in the pipe.
2308    #[cfg(windows)]
2309    #[test]
2310    fn a_breakaway_stdin_holder_does_not_hold_run_command_past_its_exit_windows() {
2311        let _serial = serial();
2312        let mut fixture = HelperFixture::new("command-exit");
2313        let big = pattern(0, 2 << 20);
2314        let options = RunOptions::new(Duration::from_secs(60));
2315        let started = Instant::now();
2316        let error = run_command(
2317            fixture.command("hold-read", true, "exit"),
2318            Some(&big),
2319            &options,
2320        )
2321        .unwrap_err();
2322        let elapsed = started.elapsed();
2323        fixture.track_holder();
2324        assert_incomplete_drain(&error);
2325        // The leader's exit is immediate; the drains get 2 s each and the writer 0.5 s + 2 s.
2326        assert!(
2327            elapsed < Duration::from_secs(20),
2328            "run_command took {elapsed:?}"
2329        );
2330        assert_eq!(
2331            LIVE_WRITERS.load(SeqCst),
2332            0,
2333            "the writer thread must be gone"
2334        );
2335        std::fs::write(fixture.dir.join("gate"), "open").unwrap();
2336        let deadline = Instant::now() + Duration::from_secs(30);
2337        let received = loop {
2338            let count = fixture.read("count");
2339            if let Some(count) = count.and_then(|text| text.trim().parse::<usize>().ok()) {
2340                break count;
2341            }
2342            assert!(
2343                Instant::now() < deadline,
2344                "the holder never reached the end of stdin: the write end is still open"
2345            );
2346            std::thread::sleep(Duration::from_millis(50));
2347        };
2348        assert!(
2349            received < big.len() / 2,
2350            "{received} bytes reached the holder after run_command returned"
2351        );
2352    }
2353
2354    /// Windows counterpart of `a_session_escaped_stdin_holder_does_not_hold_run_command_past_its_timeout`:
2355    /// the leader blocks past the deadline, so the tree kill (not an exit) ends it, while a
2356    /// holder outside the job keeps all three pipes. The call still returns after the drain and
2357    /// writer bounds, reports Drain/Confirmed instead of partial success, leaves no writer thread,
2358    /// and the holder survives. The execution timeout does not make incomplete output successful.
2359    #[cfg(windows)]
2360    #[test]
2361    fn a_breakaway_stdin_holder_does_not_hold_run_command_past_its_timeout_windows() {
2362        let _serial = serial();
2363        let mut fixture = HelperFixture::new("command-timeout");
2364        let big = pattern(0, 2 << 20);
2365        let timeout = Duration::from_secs(8);
2366        let options = RunOptions::new(timeout);
2367        let started = Instant::now();
2368        let error =
2369            run_command(fixture.command("hold", true, "block"), Some(&big), &options).unwrap_err();
2370        let elapsed = started.elapsed();
2371        fixture.track_holder();
2372        assert_incomplete_drain(&error);
2373        assert!(
2374            elapsed < timeout + Duration::from_secs(15),
2375            "run_command took {elapsed:?}"
2376        );
2377        assert_eq!(
2378            LIVE_WRITERS.load(SeqCst),
2379            0,
2380            "the writer thread must be gone"
2381        );
2382    }
2383
2384    /// Windows counterpart of `a_session_escaped_stdin_holder_does_not_hold_run_streaming_past_its_timeout`.
2385    #[cfg(windows)]
2386    #[test]
2387    fn a_breakaway_stdin_holder_does_not_hold_run_streaming_past_its_timeout_windows() {
2388        let _serial = serial();
2389        let mut fixture = HelperFixture::new("stream-timeout");
2390        let timeout = Duration::from_secs(8);
2391        let options = StreamOptions::new()
2392            .timeout(timeout)
2393            .input(pattern(0, 2 << 20));
2394        let started = Instant::now();
2395        let (outcome, _chunks, _lines) =
2396            stream_outcome(fixture.command("hold", true, "block"), &options, false);
2397        let error = outcome.unwrap_err();
2398        let elapsed = started.elapsed();
2399        fixture.track_holder();
2400        assert_incomplete_drain(&error);
2401        assert!(
2402            elapsed < timeout + Duration::from_secs(15),
2403            "run_streaming took {elapsed:?}"
2404        );
2405        assert_eq!(
2406            LIVE_WRITERS.load(SeqCst),
2407            0,
2408            "the writer thread must be gone"
2409        );
2410    }
2411
2412    /// The premise of the two tests above, and of why `run` and `run_with_stdin` have no
2413    /// surviving-holder case: their job refuses breakaway, so a descendant that asks for it is
2414    /// refused (`ERROR_ACCESS_DENIED`, 5) and nothing can outlive the call.
2415    #[cfg(windows)]
2416    #[test]
2417    fn run_with_stdin_contains_a_descendant_that_asks_to_break_away_windows() {
2418        let _serial = serial();
2419        let fixture = HelperFixture::new("no-breakaway");
2420        let output = run_with_stdin(
2421            std::env::current_exe().unwrap(),
2422            fixture.argv("hold", true, "exit"),
2423            b"",
2424            &RunOptions::new(Duration::from_secs(60)),
2425        )
2426        .unwrap();
2427        assert!(output.status.success(), "{:?}", output.status);
2428        assert!(!output.timed_out);
2429        assert_eq!(
2430            fixture.read("holder.err").as_deref(),
2431            Some("5"),
2432            "the job must refuse CREATE_BREAKAWAY_FROM_JOB"
2433        );
2434        assert!(fixture.pid("holder.pid").is_none());
2435        assert_eq!(LIVE_WRITERS.load(SeqCst), 0);
2436    }
2437
2438    /// Windows counterpart of
2439    /// `run_streaming_cancel_after_the_first_chunk_kills_the_tree_and_a_grandchild`: the cancel
2440    /// predicate turns true when the leader's `GO` marker arrives (after its in-job holder is
2441    /// ready and both pids are recorded). The call ends promptly as cancelled, not as a timeout,
2442    /// and by pid the leader and the holder are both gone.
2443    #[cfg(windows)]
2444    #[test]
2445    fn run_streaming_cancel_after_the_first_chunk_kills_the_tree_and_a_grandchild_windows() {
2446        let fixture = HelperFixture::new("stream-cancel");
2447        let options = StreamOptions::new()
2448            .poll(Duration::from_millis(50))
2449            .timeout(Duration::from_secs(60));
2450        let cancel = std::cell::Cell::new(false);
2451        let seen_at = std::cell::Cell::new(None::<Instant>);
2452        let mut chunks = Vec::new();
2453        let output = run_streaming(
2454            fixture.command("hold", false, "go"),
2455            &options,
2456            &|| cancel.get(),
2457            &mut |chunk: &[u8]| {
2458                chunks.extend_from_slice(chunk);
2459                if !cancel.get() && find(&chunks, GO).is_some() {
2460                    seen_at.set(Some(Instant::now()));
2461                    cancel.set(true);
2462                }
2463            },
2464            &mut |_line: &str| {},
2465        )
2466        .unwrap();
2467        let seen = seen_at.get().expect("the leader's GO marker never arrived");
2468        assert!(output.cancelled);
2469        assert!(!output.timed_out);
2470        assert!(!output.status.success(), "{:?}", output.status);
2471        assert!(
2472            seen.elapsed() < Duration::from_secs(8),
2473            "cancel took {:?} to take effect",
2474            seen.elapsed()
2475        );
2476        let leader = fixture
2477            .pid("leader.pid")
2478            .expect("the leader recorded no pid");
2479        let holder = fixture.holder_pid();
2480        assert_ne!(leader, holder);
2481        assert_gone(leader, "the leader");
2482        assert_gone(holder, "the in-job holder");
2483    }
2484
2485    /// What every flood case asserts about a call whose child wrote `flood` bytes to stderr
2486    /// with a `cap`-byte capture: the child finished (it never blocked on a full pipe), the
2487    /// kept bytes are exactly the first `cap`, `truncated` says so, and `total_bytes` counts
2488    /// everything the child wrote.
2489    #[cfg(windows)]
2490    fn assert_flood_capped(output: &RunOutput, cap: usize, flood: usize) {
2491        assert!(
2492            !output.timed_out,
2493            "the flood held the child until the deadline"
2494        );
2495        assert!(output.status.success(), "{:?}", output.status);
2496        assert_eq!(output.stderr.bytes.len(), cap);
2497        assert!(output.stderr.truncated);
2498        assert!(
2499            output.stderr.total_bytes >= (flood + BEGIN.len()) as u64,
2500            "stderr counted {} bytes, the child wrote {}",
2501            output.stderr.total_bytes,
2502            flood + BEGIN.len()
2503        );
2504        let kept = &output.stderr.bytes;
2505        let at = find(kept, BEGIN).expect("the begin marker is not in the kept bytes");
2506        let body = &output.stderr.bytes[at + BEGIN.len()..];
2507        assert!(
2508            body == pattern(0, body.len()).as_slice(),
2509            "the kept bytes are not the first ones the child wrote"
2510        );
2511        assert!(
2512            !output.stdout.truncated,
2513            "the stderr cap must not touch stdout"
2514        );
2515        assert!(
2516            find(&output.stdout.bytes, DONE).is_some(),
2517            "the child never finished writing"
2518        );
2519    }
2520
2521    /// A stderr flood far past the capture cap, through `run` (explicit cap) and `run_command`
2522    /// (the 1 MiB default): the cap is enforced exactly, the call completes instead of
2523    /// stalling on a full pipe, and the truncation is reported.
2524    #[cfg(windows)]
2525    #[test]
2526    fn a_stderr_flood_past_the_cap_is_truncated_not_blocking_through_run_and_run_command_windows() {
2527        let cap = 64 * 1024;
2528        let flood = 4 << 20;
2529        let started = Instant::now();
2530        let output = run(
2531            std::env::current_exe().unwrap(),
2532            helper_argv(&format!("flood|{flood}")),
2533            &RunOptions::new(Duration::from_secs(60)).stderr_cap(cap),
2534        )
2535        .unwrap();
2536        assert_flood_capped(&output, cap, flood);
2537        let flood = 3 << 20;
2538        let output = run_command(
2539            helper_command(&format!("flood|{flood}")),
2540            None,
2541            &RunOptions::new(Duration::from_secs(60)),
2542        )
2543        .unwrap();
2544        assert_flood_capped(&output, 1024 * 1024, flood);
2545        assert!(
2546            started.elapsed() < Duration::from_secs(90),
2547            "the floods took {:?}",
2548            started.elapsed()
2549        );
2550    }
2551
2552    /// A stderr flood through `run_streaming`: every line reaches the callback in order, only
2553    /// the tail is kept (within its cap, ending with the last line), and the call completes.
2554    #[cfg(windows)]
2555    #[test]
2556    fn a_stderr_flood_does_not_block_run_streaming_and_keeps_only_the_tail_windows() {
2557        const LINES: usize = 400_000;
2558        let options = StreamOptions::new()
2559            .timeout(Duration::from_secs(60))
2560            .stderr_tail_cap(1024);
2561        let started = Instant::now();
2562        let (output, _chunks, lines) = stream(
2563            helper_command(&format!("flood-lines|{LINES}")),
2564            &options,
2565            false,
2566        );
2567        assert!(!output.timed_out && !output.cancelled);
2568        assert!(output.status.success(), "{:?}", output.status);
2569        let ours: Vec<&str> = lines
2570            .iter()
2571            .map(String::as_str)
2572            .filter(|line| line.len() == 8 && line.starts_with('L'))
2573            .collect();
2574        assert_eq!(ours.len(), LINES, "lines were lost or never delivered");
2575        for (index, line) in ours.iter().enumerate() {
2576            assert_eq!(*line, format!("L{index:07}"));
2577        }
2578        assert!(
2579            output.stderr_tail.len() <= 1024,
2580            "{}",
2581            output.stderr_tail.len()
2582        );
2583        assert!(
2584            output.stderr_tail.ends_with(&format!("L{:07}", LINES - 1)),
2585            "the tail does not end with the last line"
2586        );
2587        assert!(
2588            find(&output.stdout_tail, DONE).is_some(),
2589            "the child never finished writing"
2590        );
2591        assert!(
2592            started.elapsed() < Duration::from_secs(60),
2593            "run_streaming took {:?}",
2594            started.elapsed()
2595        );
2596    }
2597}