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