Skip to main content

running_process_platform_internal/
lib.rs

1//! Blessed asynchronous process operations.
2//!
3//! This crate is intentionally published as an implementation detail. It is
4//! the only production owner of the Tokio process primitives used by the
5//! async process API. Higher layers receive typed operations and never name
6//! `tokio::process::Command` directly.
7
8use std::ffi::{OsStr, OsString};
9use std::io;
10use std::path::PathBuf;
11use std::process::{ExitStatus, Output, Stdio};
12
13use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
14use tokio::process::{Child, ChildStderr, ChildStdin, ChildStdout, Command};
15
16/// `CREATE_NEW_PROCESS_GROUP`. Spelled out rather than pulled from
17/// `windows-sys` so the constant sits next to the one place that applies it.
18#[cfg(windows)]
19const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
20
21/// Stdio policy for one child stream.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum StreamMode {
24    /// Leave the stream connected to the parent process.
25    Inherit,
26    /// Create an asynchronous pipe owned by the child handle.
27    Piped,
28    /// Connect the stream to the platform null device.
29    Null,
30}
31
32impl StreamMode {
33    fn apply(self) -> Stdio {
34        match self {
35            Self::Inherit => Stdio::inherit(),
36            Self::Piped => Stdio::piped(),
37            Self::Null => Stdio::null(),
38        }
39    }
40}
41
42/// Typed spawn description accepted by the blessed process boundary.
43#[derive(Debug, Clone)]
44pub struct SpawnSpec {
45    program: OsString,
46    args: Vec<OsString>,
47    current_dir: Option<PathBuf>,
48    env: Vec<(OsString, OsString)>,
49    clear_env: bool,
50    stdin: StreamMode,
51    stdout: StreamMode,
52    stderr: StreamMode,
53    create_process_group: bool,
54    kill_when_owner_dies: bool,
55}
56
57impl SpawnSpec {
58    /// Create a direct (non-shell) command description.
59    pub fn new(program: impl Into<OsString>) -> Self {
60        Self {
61            program: program.into(),
62            args: Vec::new(),
63            current_dir: None,
64            env: Vec::new(),
65            clear_env: false,
66            stdin: StreamMode::Inherit,
67            stdout: StreamMode::Inherit,
68            stderr: StreamMode::Inherit,
69            create_process_group: false,
70            kill_when_owner_dies: false,
71        }
72    }
73
74    /// Append one argument without requiring UTF-8.
75    pub fn arg(mut self, arg: impl Into<OsString>) -> Self {
76        self.args.push(arg.into());
77        self
78    }
79
80    /// Set the child working directory.
81    pub fn current_dir(mut self, path: impl Into<PathBuf>) -> Self {
82        self.current_dir = Some(path.into());
83        self
84    }
85
86    /// Add an environment override.
87    pub fn env(mut self, key: impl Into<OsString>, value: impl Into<OsString>) -> Self {
88        self.env.push((key.into(), value.into()));
89        self
90    }
91
92    /// Start with an empty inherited environment before applying overrides.
93    pub fn clear_env(mut self, clear: bool) -> Self {
94        self.clear_env = clear;
95        self
96    }
97
98    /// Configure child stdin.
99    pub fn stdin(mut self, mode: StreamMode) -> Self {
100        self.stdin = mode;
101        self
102    }
103
104    /// Configure child stdout.
105    pub fn stdout(mut self, mode: StreamMode) -> Self {
106        self.stdout = mode;
107        self
108    }
109
110    /// Configure child stderr.
111    pub fn stderr(mut self, mode: StreamMode) -> Self {
112        self.stderr = mode;
113        self
114    }
115
116    /// Put the child in its own process group.
117    ///
118    /// This is what makes a group-wide soft signal addressable at all:
119    /// [`PlatformEmergencySignal::terminate_group_soft`] is a no-op without
120    /// it, because on POSIX the negative-PID signal would otherwise reach the
121    /// caller's own group, and on Windows `GenerateConsoleCtrlEvent` only
122    /// routes to children spawned with `CREATE_NEW_PROCESS_GROUP`. It also
123    /// detaches the child from the parent's console Ctrl+C, so it is opt-in.
124    pub fn create_process_group(mut self, create: bool) -> Self {
125        self.create_process_group = create;
126        self
127    }
128
129    /// Kill this child when the spawning process exits unexpectedly.
130    ///
131    /// Linux uses `PR_SET_PDEATHSIG(SIGTERM)`. Windows assigns the child to a
132    /// process-wide kill-on-close Job Object. macOS retains the modeled
133    /// kqueue-supervisor contract; its concrete supervisor is not part of the
134    /// async platform spawn seam yet.
135    pub fn kill_when_owner_dies(mut self, kill: bool) -> Self {
136        self.kill_when_owner_dies = kill;
137        self
138    }
139
140    /// Spawn using the canonical asynchronous platform operation.
141    pub async fn spawn(self) -> io::Result<PlatformChild> {
142        let mut command = Command::new(&self.program);
143        command.args(&self.args);
144        if let Some(current_dir) = self.current_dir.as_deref() {
145            command.current_dir(current_dir);
146        }
147        if self.clear_env {
148            command.env_clear();
149        }
150        for (key, value) in &self.env {
151            command.env(key, value);
152        }
153        command
154            .stdin(self.stdin.apply())
155            .stdout(self.stdout.apply())
156            .stderr(self.stderr.apply());
157        if self.create_process_group {
158            #[cfg(unix)]
159            command.process_group(0);
160            #[cfg(windows)]
161            command.creation_flags(CREATE_NEW_PROCESS_GROUP);
162        }
163        #[cfg(target_os = "linux")]
164        if self.kill_when_owner_dies {
165            let owner_pid = unsafe { libc::getpid() };
166            // SAFETY: the closure invokes only async-signal-safe libc calls.
167            unsafe {
168                command.pre_exec(move || {
169                    if libc::prctl(
170                        libc::PR_SET_PDEATHSIG,
171                        libc::SIGTERM as libc::c_ulong,
172                        0,
173                        0,
174                        0,
175                    ) == -1
176                    {
177                        return Err(io::Error::last_os_error());
178                    }
179                    // Close the fork/exec race: if the owner died before the
180                    // child installed the death signal, terminate ourselves.
181                    if libc::getppid() != owner_pid {
182                        libc::kill(libc::getpid(), libc::SIGTERM);
183                    }
184                    Ok(())
185                });
186            }
187        }
188        #[cfg(target_os = "macos")]
189        if self.kill_when_owner_dies {
190            let owner_pid = unsafe { libc::getpid() };
191            // SAFETY: the closure only installs the async-signal-safe fork
192            // supervisor before exec. The supervisor owns all kqueue work in
193            // its independent process.
194            unsafe {
195                command.pre_exec(move || {
196                    let supervisor = libc::fork();
197                    if supervisor < 0 {
198                        return Err(io::Error::last_os_error());
199                    }
200                    if supervisor == 0 {
201                        macos_owner_death_supervisor(owner_pid);
202                    }
203                    Ok(())
204                });
205            }
206        }
207        #[cfg(not(any(target_os = "linux", target_os = "macos")))]
208        let _ = self.kill_when_owner_dies;
209
210        let child = command.spawn()?;
211        #[cfg(windows)]
212        if self.kill_when_owner_dies {
213            windows_owner_death_job::assign(child.raw_handle());
214        }
215        Ok(PlatformChild::new(child, self.create_process_group))
216    }
217}
218
219/// Watch the spawning process and the exec child from a short-lived helper.
220///
221/// macOS has no Linux-style parent-death signal. A helper process can register
222/// both PIDs with `EVFILT_PROC` before the target execs: if the owner exits it
223/// sends `SIGTERM` to the target; if the target exits first the helper exits.
224/// This is the concrete implementation of the kqueue supervisor contract in
225/// `broker::lifecycle::process_tree`.
226#[cfg(target_os = "macos")]
227fn macos_owner_death_supervisor(owner_pid: libc::pid_t) -> ! {
228    let target_pid = unsafe { libc::getppid() };
229    unsafe {
230        // `closefrom` is not exposed by every libc target supported by the
231        // `libc` crate. The supervisor has no descriptors to preserve, so a
232        // bounded close sweep is the portable equivalent here.
233        for fd in 3..1024 {
234            libc::close(fd);
235        }
236    }
237
238    let queue = unsafe { libc::kqueue() };
239    if queue < 0 {
240        unsafe { libc::_exit(127) };
241    }
242
243    let mut watches = [
244        libc::kevent {
245            ident: owner_pid as libc::uintptr_t,
246            filter: libc::EVFILT_PROC,
247            flags: libc::EV_ADD | libc::EV_ONESHOT,
248            fflags: libc::NOTE_EXIT,
249            data: 0,
250            udata: std::ptr::null_mut(),
251        },
252        libc::kevent {
253            ident: target_pid as libc::uintptr_t,
254            filter: libc::EVFILT_PROC,
255            flags: libc::EV_ADD | libc::EV_ONESHOT,
256            fflags: libc::NOTE_EXIT,
257            data: 0,
258            udata: std::ptr::null_mut(),
259        },
260    ];
261    let registered = unsafe {
262        libc::kevent(
263            queue,
264            watches.as_mut_ptr(),
265            watches.len() as i32,
266            std::ptr::null_mut(),
267            0,
268            std::ptr::null(),
269        )
270    };
271    if registered < 0 {
272        unsafe {
273            libc::close(queue);
274            libc::_exit(127);
275        }
276    }
277
278    // Close the fork/registration race: if the owner died before the watch
279    // was installed, do not leave the target orphaned.
280    if unsafe { libc::kill(owner_pid, 0) } < 0
281        && io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH)
282    {
283        unsafe {
284            libc::kill(target_pid, libc::SIGTERM);
285            libc::close(queue);
286            libc::_exit(0);
287        }
288    }
289
290    let mut events = [unsafe { std::mem::zeroed::<libc::kevent>() }];
291    loop {
292        let count = unsafe {
293            libc::kevent(
294                queue,
295                std::ptr::null(),
296                0,
297                events.as_mut_ptr(),
298                1,
299                std::ptr::null(),
300            )
301        };
302        if count <= 0 {
303            if count < 0 && io::Error::last_os_error().raw_os_error() == Some(libc::EINTR) {
304                continue;
305            }
306            break;
307        }
308        if events[0].ident == owner_pid as libc::uintptr_t {
309            unsafe {
310                libc::kill(target_pid, libc::SIGTERM);
311            }
312        }
313        break;
314    }
315    unsafe {
316        libc::close(queue);
317        libc::_exit(0);
318    }
319}
320
321#[cfg(windows)]
322mod windows_owner_death_job {
323    use std::sync::OnceLock;
324    use windows_sys::Win32::Foundation::HANDLE;
325    use windows_sys::Win32::System::JobObjects::{
326        AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation,
327        SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
328        JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
329    };
330
331    struct Job(HANDLE);
332    unsafe impl Send for Job {}
333    unsafe impl Sync for Job {}
334
335    static JOB: OnceLock<Option<Job>> = OnceLock::new();
336
337    fn create() -> Option<Job> {
338        unsafe {
339            let handle = CreateJobObjectW(std::ptr::null(), std::ptr::null());
340            if handle.is_null() {
341                return None;
342            }
343            let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed();
344            info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
345            if SetInformationJobObject(
346                handle,
347                JobObjectExtendedLimitInformation,
348                &info as *const _ as *const _,
349                std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
350            ) == 0
351            {
352                return None;
353            }
354            Some(Job(handle))
355        }
356    }
357
358    pub(super) fn assign(child: Option<HANDLE>) {
359        let Some(child) = child else { return };
360        let Some(job) = JOB.get_or_init(create).as_ref() else {
361            return;
362        };
363        unsafe {
364            AssignProcessToJobObject(job.0, child);
365        }
366    }
367}
368
369/// Owned child handle returned by [`SpawnSpec::spawn`].
370pub struct PlatformChild {
371    child: Child,
372    stdin: Option<ChildStdin>,
373    stdout: Option<ChildStdout>,
374    stderr: Option<ChildStderr>,
375    signal: PlatformEmergencySignal,
376}
377
378impl PlatformChild {
379    fn new(mut child: Child, own_process_group: bool) -> Self {
380        let signal = PlatformEmergencySignal {
381            pid: child.id(),
382            own_process_group,
383        };
384        Self {
385            stdin: child.stdin.take(),
386            stdout: child.stdout.take(),
387            stderr: child.stderr.take(),
388            child,
389            signal,
390        }
391    }
392
393    /// Return the operating-system process identifier, if available.
394    pub fn id(&self) -> Option<u32> {
395        self.child.id()
396    }
397
398    /// Wait for completion without capturing output.
399    pub async fn wait(&mut self) -> io::Result<ExitStatus> {
400        self.child.wait().await
401    }
402
403    /// Terminate the child and wait for its exit.
404    pub async fn kill(&mut self) -> io::Result<()> {
405        self.child.kill().await
406    }
407
408    /// Capture piped stdout and stderr while waiting for the child.
409    pub async fn wait_with_output(self) -> io::Result<Output> {
410        let Self {
411            mut child,
412            stdin,
413            stdout,
414            stderr,
415            ..
416        } = self;
417        // Match Tokio's `Child::wait_with_output` contract: one-shot output
418        // closes an owned stdin pipe so a child waiting for EOF can finish.
419        drop(stdin);
420        let (status, stdout, stderr) = tokio::try_join!(
421            child.wait(),
422            read_owned_to_end(stdout),
423            read_owned_to_end(stderr),
424        )?;
425        Ok(Output {
426            status,
427            stdout,
428            stderr,
429        })
430    }
431
432    /// Write bytes to piped stdin and flush them.
433    pub async fn write_stdin(&mut self, bytes: &[u8]) -> io::Result<()> {
434        let stdin = self.stdin.as_mut().ok_or_else(stdin_not_piped)?;
435        stdin.write_all(bytes).await?;
436        stdin.flush().await
437    }
438
439    /// Close the piped stdin handle, delivering EOF to the child.
440    ///
441    /// This operation is idempotent. Closing an inherited or null stdin is
442    /// also a no-op because there is no owned pipe to close.
443    pub fn close_stdin(&mut self) {
444        drop(self.stdin.take());
445    }
446
447    /// Read all bytes from piped stdout without waiting for process exit.
448    pub async fn read_stdout_to_end(&mut self) -> io::Result<Vec<u8>> {
449        let stdout = self.stdout.as_mut().ok_or_else(stdout_not_piped)?;
450        let mut bytes = Vec::new();
451        stdout.read_to_end(&mut bytes).await?;
452        Ok(bytes)
453    }
454
455    /// Read all bytes from piped stderr without waiting for process exit.
456    pub async fn read_stderr_to_end(&mut self) -> io::Result<Vec<u8>> {
457        let stderr = self.stderr.as_mut().ok_or_else(stderr_not_piped)?;
458        let mut bytes = Vec::new();
459        stderr.read_to_end(&mut bytes).await?;
460        Ok(bytes)
461    }
462
463    /// Split this child into sealed actor capabilities.
464    ///
465    /// The lifecycle wait handle, emergency termination handle, input pipe,
466    /// and output readers are deliberately separate so the actor can keep
467    /// accepting control commands while an asynchronous exit wait is pending.
468    pub fn into_actor_parts(
469        self,
470    ) -> (
471        PlatformLifecycle,
472        PlatformEmergencySignal,
473        Option<PlatformStdin>,
474        Option<PlatformOutput>,
475        Option<PlatformOutput>,
476    ) {
477        (
478            PlatformLifecycle { child: self.child },
479            self.signal,
480            self.stdin.map(|stdin| PlatformStdin { stdin }),
481            self.stdout.map(PlatformOutput::stdout),
482            self.stderr.map(PlatformOutput::stderr),
483        )
484    }
485}
486
487/// Opaque exit-wait capability owned by a process actor.
488pub struct PlatformLifecycle {
489    child: Child,
490}
491
492impl PlatformLifecycle {
493    /// Wait asynchronously for the child to exit.
494    pub async fn wait(&mut self) -> io::Result<ExitStatus> {
495        self.child.wait().await
496    }
497}
498
499/// Opaque, non-reap-capable emergency termination capability.
500///
501/// It can be used while the actor has a pending wait on
502/// [`PlatformLifecycle`], but it cannot observe or consume the exit result.
503pub struct PlatformEmergencySignal {
504    pid: Option<u32>,
505    own_process_group: bool,
506}
507
508impl PlatformEmergencySignal {
509    /// Request immediate termination without waiting for process reaping.
510    pub fn kill(&self) -> io::Result<()> {
511        signal_process(self.target()?)
512    }
513
514    /// Ask the child's whole process group to shut down gracefully.
515    ///
516    /// Returns `Ok(false)` when the child was not spawned with
517    /// [`SpawnSpec::create_process_group`]: there is no group to address, and
518    /// signalling anyway would hit the caller's own group on POSIX or the
519    /// caller's console on Windows. A child that has already exited is also
520    /// `Ok` -- the soft step's only job is to give a live child a chance to
521    /// clean up before a hard kill, so a dead target is a success.
522    pub fn terminate_group_soft(&self) -> io::Result<bool> {
523        if !self.own_process_group {
524            return Ok(false);
525        }
526        signal_process_group(self.target()?).map(|()| true)
527    }
528
529    fn target(&self) -> io::Result<u32> {
530        self.pid.ok_or_else(|| {
531            io::Error::new(
532                io::ErrorKind::BrokenPipe,
533                "child process no longer has an emergency signal target",
534            )
535        })
536    }
537}
538
539/// Opaque piped stdin capability owned by a process actor.
540pub struct PlatformStdin {
541    stdin: ChildStdin,
542}
543
544impl PlatformStdin {
545    /// Write and flush bytes to the child stdin pipe.
546    pub async fn write(&mut self, bytes: &[u8]) -> io::Result<()> {
547        self.stdin.write_all(bytes).await?;
548        self.stdin.flush().await
549    }
550}
551
552/// Opaque stdout or stderr reader owned by a process actor.
553pub struct PlatformOutput {
554    reader: OutputReader,
555}
556
557enum OutputReader {
558    Stdout(ChildStdout),
559    Stderr(ChildStderr),
560}
561
562impl PlatformOutput {
563    fn stdout(stdout: ChildStdout) -> Self {
564        Self {
565            reader: OutputReader::Stdout(stdout),
566        }
567    }
568
569    fn stderr(stderr: ChildStderr) -> Self {
570        Self {
571            reader: OutputReader::Stderr(stderr),
572        }
573    }
574
575    /// Drain this output endpoint to EOF without blocking a runtime worker.
576    pub async fn read_to_end(self) -> io::Result<Vec<u8>> {
577        match self.reader {
578            OutputReader::Stdout(stdout) => read_owned_to_end(Some(stdout)).await,
579            OutputReader::Stderr(stderr) => read_owned_to_end(Some(stderr)).await,
580        }
581    }
582
583    /// Read the next asynchronous chunk from this output endpoint.
584    ///
585    /// The caller owns the buffer and therefore controls the amount of data
586    /// retained at each read. EOF is reported as `Ok(0)`.
587    pub async fn read_chunk(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
588        match &mut self.reader {
589            OutputReader::Stdout(stdout) => stdout.read(buffer).await,
590            OutputReader::Stderr(stderr) => stderr.read(buffer).await,
591        }
592    }
593}
594
595fn stdin_not_piped() -> io::Error {
596    io::Error::new(io::ErrorKind::BrokenPipe, "child stdin is not piped")
597}
598
599fn stdout_not_piped() -> io::Error {
600    io::Error::new(io::ErrorKind::BrokenPipe, "child stdout is not piped")
601}
602
603fn stderr_not_piped() -> io::Error {
604    io::Error::new(io::ErrorKind::BrokenPipe, "child stderr is not piped")
605}
606
607async fn read_owned_to_end<R>(reader: Option<R>) -> io::Result<Vec<u8>>
608where
609    R: AsyncRead + Unpin,
610{
611    let Some(mut reader) = reader else {
612        return Ok(Vec::new());
613    };
614    let mut bytes = Vec::new();
615    reader.read_to_end(&mut bytes).await?;
616    Ok(bytes)
617}
618
619#[cfg(unix)]
620fn signal_process(pid: u32) -> io::Result<()> {
621    unix_kill(pid as i32, libc::SIGKILL)
622}
623
624/// SIGTERM the whole group. The negative PID is the group selector, which is
625/// only safe because the child was spawned into a group of its own.
626#[cfg(unix)]
627fn signal_process_group(pid: u32) -> io::Result<()> {
628    unix_kill(-(pid as i32), libc::SIGTERM)
629}
630
631#[cfg(unix)]
632fn unix_kill(target: i32, signal: i32) -> io::Result<()> {
633    // SAFETY: `kill` takes plain integers and borrows no Rust state.
634    let result = unsafe { libc::kill(target, signal) };
635    if result == 0 {
636        return Ok(());
637    }
638    let error = io::Error::last_os_error();
639    if error.raw_os_error() == Some(libc::ESRCH) {
640        Ok(())
641    } else {
642        Err(error)
643    }
644}
645
646#[cfg(windows)]
647fn signal_process(pid: u32) -> io::Result<()> {
648    use windows_sys::Win32::Foundation::{CloseHandle, ERROR_INVALID_PARAMETER};
649    use windows_sys::Win32::System::Threading::{OpenProcess, TerminateProcess, PROCESS_TERMINATE};
650
651    let handle = unsafe { OpenProcess(PROCESS_TERMINATE, 0, pid) };
652    if handle.is_null() {
653        let error = io::Error::last_os_error();
654        return if error.raw_os_error() == Some(ERROR_INVALID_PARAMETER as i32) {
655            Ok(())
656        } else {
657            Err(error)
658        };
659    }
660    let terminated = unsafe { TerminateProcess(handle, 1) };
661    let termination_error = if terminated == 0 {
662        Some(io::Error::last_os_error())
663    } else {
664        None
665    };
666    unsafe { CloseHandle(handle) };
667    termination_error.map_or(Ok(()), Err)
668}
669
670/// Deliver Ctrl+Break to the child's process group.
671///
672/// `GenerateConsoleCtrlEvent` addresses a group id, and the child's group id
673/// is its own pid because it was spawned with `CREATE_NEW_PROCESS_GROUP`.
674#[cfg(windows)]
675fn signal_process_group(pid: u32) -> io::Result<()> {
676    use windows_sys::Win32::Foundation::ERROR_INVALID_HANDLE;
677    use windows_sys::Win32::System::Console::{GenerateConsoleCtrlEvent, CTRL_BREAK_EVENT};
678
679    // SAFETY: the FFI call takes plain integers and borrows no Rust state.
680    if unsafe { GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, pid) } != 0 {
681        return Ok(());
682    }
683    let error = io::Error::last_os_error();
684    // The child already exited or detached from the console. The soft step
685    // only exists to offer a live child a graceful exit, so this is success.
686    if error.raw_os_error() == Some(ERROR_INVALID_HANDLE as i32) {
687        Ok(())
688    } else {
689        Err(error)
690    }
691}
692
693/// Build a shell command using the host platform's supported shell.
694pub fn shell_spec(command: impl AsRef<OsStr>) -> SpawnSpec {
695    #[cfg(windows)]
696    {
697        SpawnSpec::new("cmd.exe").arg("/C").arg(command.as_ref())
698    }
699    #[cfg(not(windows))]
700    {
701        SpawnSpec::new("/bin/sh").arg("-c").arg(command.as_ref())
702    }
703}
704
705#[cfg(test)]
706mod tests {
707    use super::{shell_spec, SpawnSpec, StreamMode};
708
709    fn fixture_command() -> SpawnSpec {
710        #[cfg(windows)]
711        {
712            shell_spec("echo async-platform-internal")
713        }
714        #[cfg(not(windows))]
715        {
716            shell_spec("printf async-platform-internal")
717        }
718    }
719
720    #[tokio::test]
721    async fn blessed_spawn_captures_output_without_sync_wait() {
722        let output = fixture_command()
723            .stdout(StreamMode::Piped)
724            .stderr(StreamMode::Piped)
725            .spawn()
726            .await
727            .expect("spawn")
728            .wait_with_output()
729            .await
730            .expect("wait with output");
731
732        assert!(output.status.success());
733        let expected = if cfg!(windows) {
734            b"async-platform-internal\r\n".as_slice()
735        } else {
736            b"async-platform-internal".as_slice()
737        };
738        assert_eq!(output.stdout, expected);
739        assert!(output.stderr.is_empty());
740    }
741
742    #[tokio::test]
743    async fn blessed_spawn_reports_missing_program() {
744        let result = SpawnSpec::new("running-process-program-that-does-not-exist")
745            .spawn()
746            .await;
747        assert!(result.is_err());
748    }
749
750    #[tokio::test]
751    async fn one_shot_output_closes_owned_stdin() {
752        #[cfg(windows)]
753        let spec = shell_spec("more > nul & echo done");
754        #[cfg(not(windows))]
755        let spec = shell_spec("cat > /dev/null; printf done");
756
757        let output = tokio::time::timeout(
758            std::time::Duration::from_secs(2),
759            spec.stdin(StreamMode::Piped)
760                .stdout(StreamMode::Piped)
761                .stderr(StreamMode::Piped)
762                .spawn()
763                .await
764                .expect("spawn")
765                .wait_with_output(),
766        )
767        .await
768        .expect("stdin is closed for one-shot output")
769        .expect("output succeeds");
770
771        let expected = if cfg!(windows) {
772            b"done\r\n".as_slice()
773        } else {
774            b"done".as_slice()
775        };
776        assert_eq!(output.stdout, expected);
777    }
778}