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::cfg_select;
9use std::ffi::{OsStr, OsString};
10use std::io;
11use std::path::PathBuf;
12use std::process::{ExitStatus, Output, Stdio};
13
14use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
15use tokio::process::{Child, ChildStderr, ChildStdin, ChildStdout, Command};
16
17/// Neutral capability indexes for the eventual workspace-wide host boundary.
18///
19/// The indexes intentionally expose no operations yet: phase 2 establishes
20/// ownership names before later phases move a capability behind them.
21pub mod platform;
22
23// This is deliberately the crate's only host selector.  Facade modules are
24// neutral; native details live behind the selected private root.
25cfg_select! {
26    target_os = "windows" => {
27        mod platform_win;
28        pub(crate) use platform_win as platform_imp;
29    }
30    target_os = "linux" => {
31        mod platform_linux;
32        pub(crate) use platform_linux as platform_imp;
33    }
34    target_os = "macos" => {
35        mod platform_macos;
36        pub(crate) use platform_macos as platform_imp;
37    }
38}
39
40/// Apply host-owned setup for the legacy Tokio-command compatibility surface.
41///
42/// The public wrapper retains its policy type, while console suppression and
43/// owner-death primitives stay inside the selected platform root.
44pub fn configure_compat_tokio_command(
45    command: &mut Command,
46    show_console: bool,
47    kill_when_owner_dies: bool,
48) -> io::Result<()> {
49    platform_imp::configure_compat_tokio_command(command, show_console, kill_when_owner_dies)
50}
51
52/// Complete host-owned setup after a legacy Tokio child has been spawned.
53pub fn after_compat_tokio_spawn(child: &Child, kill_when_owner_dies: bool) {
54    platform_imp::after_compat_tokio_spawn(child, kill_when_owner_dies)
55}
56
57/// Stdio policy for one child stream.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum StreamMode {
60    /// Leave the stream connected to the parent process.
61    Inherit,
62    /// Create an asynchronous pipe owned by the child handle.
63    Piped,
64    /// Connect the stream to the platform null device.
65    Null,
66}
67
68impl StreamMode {
69    fn apply(self) -> Stdio {
70        match self {
71            Self::Inherit => Stdio::inherit(),
72            Self::Piped => Stdio::piped(),
73            Self::Null => Stdio::null(),
74        }
75    }
76}
77
78/// Typed spawn description accepted by the blessed process boundary.
79#[derive(Debug, Clone)]
80pub struct SpawnSpec {
81    program: OsString,
82    args: Vec<OsString>,
83    current_dir: Option<PathBuf>,
84    env: Vec<(OsString, OsString)>,
85    clear_env: bool,
86    stdin: StreamMode,
87    stdout: StreamMode,
88    stderr: StreamMode,
89    create_process_group: bool,
90    kill_when_owner_dies: bool,
91}
92
93impl SpawnSpec {
94    /// Create a direct (non-shell) command description.
95    pub fn new(program: impl Into<OsString>) -> Self {
96        Self {
97            program: program.into(),
98            args: Vec::new(),
99            current_dir: None,
100            env: Vec::new(),
101            clear_env: false,
102            stdin: StreamMode::Inherit,
103            stdout: StreamMode::Inherit,
104            stderr: StreamMode::Inherit,
105            create_process_group: false,
106            kill_when_owner_dies: false,
107        }
108    }
109
110    /// Append one argument without requiring UTF-8.
111    pub fn arg(mut self, arg: impl Into<OsString>) -> Self {
112        self.args.push(arg.into());
113        self
114    }
115
116    /// Set the child working directory.
117    pub fn current_dir(mut self, path: impl Into<PathBuf>) -> Self {
118        self.current_dir = Some(path.into());
119        self
120    }
121
122    /// Add an environment override.
123    pub fn env(mut self, key: impl Into<OsString>, value: impl Into<OsString>) -> Self {
124        self.env.push((key.into(), value.into()));
125        self
126    }
127
128    /// Start with an empty inherited environment before applying overrides.
129    pub fn clear_env(mut self, clear: bool) -> Self {
130        self.clear_env = clear;
131        self
132    }
133
134    /// Configure child stdin.
135    pub fn stdin(mut self, mode: StreamMode) -> Self {
136        self.stdin = mode;
137        self
138    }
139
140    /// Configure child stdout.
141    pub fn stdout(mut self, mode: StreamMode) -> Self {
142        self.stdout = mode;
143        self
144    }
145
146    /// Configure child stderr.
147    pub fn stderr(mut self, mode: StreamMode) -> Self {
148        self.stderr = mode;
149        self
150    }
151
152    /// Put the child in its own process group.
153    ///
154    /// This is what makes a group-wide soft signal addressable at all:
155    /// [`PlatformEmergencySignal::terminate_group_soft`] is a no-op without
156    /// it, because on POSIX the negative-PID signal would otherwise reach the
157    /// caller's own group, and on Windows `GenerateConsoleCtrlEvent` only
158    /// routes to children spawned with `CREATE_NEW_PROCESS_GROUP`. It also
159    /// detaches the child from the parent's console Ctrl+C, so it is opt-in.
160    pub fn create_process_group(mut self, create: bool) -> Self {
161        self.create_process_group = create;
162        self
163    }
164
165    /// Kill this child when the spawning process exits unexpectedly.
166    ///
167    /// Linux uses `PR_SET_PDEATHSIG(SIGTERM)`. Windows assigns the child to a
168    /// process-wide kill-on-close Job Object. macOS retains the modeled
169    /// kqueue-supervisor contract; its concrete supervisor is not part of the
170    /// async platform spawn seam yet.
171    pub fn kill_when_owner_dies(mut self, kill: bool) -> Self {
172        self.kill_when_owner_dies = kill;
173        self
174    }
175
176    /// Spawn using the canonical asynchronous platform operation.
177    pub async fn spawn(self) -> io::Result<PlatformChild> {
178        let mut command = Command::new(&self.program);
179        command.args(&self.args);
180        if let Some(current_dir) = self.current_dir.as_deref() {
181            command.current_dir(current_dir);
182        }
183        if self.clear_env {
184            command.env_clear();
185        }
186        for (key, value) in &self.env {
187            command.env(key, value);
188        }
189        command
190            .stdin(self.stdin.apply())
191            .stdout(self.stdout.apply())
192            .stderr(self.stderr.apply());
193        platform_imp::configure_command(
194            &mut command,
195            self.create_process_group,
196            self.kill_when_owner_dies,
197        )?;
198
199        let child = command.spawn()?;
200        platform_imp::after_spawn(&child, self.kill_when_owner_dies);
201        Ok(PlatformChild::new(child, self.create_process_group))
202    }
203}
204
205/// Owned child handle returned by [`SpawnSpec::spawn`].
206pub struct PlatformChild {
207    child: Child,
208    stdin: Option<ChildStdin>,
209    stdout: Option<ChildStdout>,
210    stderr: Option<ChildStderr>,
211    signal: PlatformEmergencySignal,
212}
213
214impl PlatformChild {
215    fn new(mut child: Child, own_process_group: bool) -> Self {
216        let signal = PlatformEmergencySignal {
217            pid: child.id(),
218            own_process_group,
219        };
220        Self {
221            stdin: child.stdin.take(),
222            stdout: child.stdout.take(),
223            stderr: child.stderr.take(),
224            child,
225            signal,
226        }
227    }
228
229    /// Return the operating-system process identifier, if available.
230    pub fn id(&self) -> Option<u32> {
231        self.child.id()
232    }
233
234    /// Wait for completion without capturing output.
235    pub async fn wait(&mut self) -> io::Result<ExitStatus> {
236        self.child.wait().await
237    }
238
239    /// Terminate the child and wait for its exit.
240    pub async fn kill(&mut self) -> io::Result<()> {
241        self.child.kill().await
242    }
243
244    /// Capture piped stdout and stderr while waiting for the child.
245    pub async fn wait_with_output(self) -> io::Result<Output> {
246        let Self {
247            mut child,
248            stdin,
249            stdout,
250            stderr,
251            ..
252        } = self;
253        // Match Tokio's `Child::wait_with_output` contract: one-shot output
254        // closes an owned stdin pipe so a child waiting for EOF can finish.
255        drop(stdin);
256        let (status, stdout, stderr) = tokio::try_join!(
257            child.wait(),
258            read_owned_to_end(stdout),
259            read_owned_to_end(stderr),
260        )?;
261        Ok(Output {
262            status,
263            stdout,
264            stderr,
265        })
266    }
267
268    /// Write bytes to piped stdin and flush them.
269    pub async fn write_stdin(&mut self, bytes: &[u8]) -> io::Result<()> {
270        let stdin = self.stdin.as_mut().ok_or_else(stdin_not_piped)?;
271        stdin.write_all(bytes).await?;
272        stdin.flush().await
273    }
274
275    /// Close the piped stdin handle, delivering EOF to the child.
276    ///
277    /// This operation is idempotent. Closing an inherited or null stdin is
278    /// also a no-op because there is no owned pipe to close.
279    pub fn close_stdin(&mut self) {
280        drop(self.stdin.take());
281    }
282
283    /// Read all bytes from piped stdout without waiting for process exit.
284    pub async fn read_stdout_to_end(&mut self) -> io::Result<Vec<u8>> {
285        let stdout = self.stdout.as_mut().ok_or_else(stdout_not_piped)?;
286        let mut bytes = Vec::new();
287        stdout.read_to_end(&mut bytes).await?;
288        Ok(bytes)
289    }
290
291    /// Read all bytes from piped stderr without waiting for process exit.
292    pub async fn read_stderr_to_end(&mut self) -> io::Result<Vec<u8>> {
293        let stderr = self.stderr.as_mut().ok_or_else(stderr_not_piped)?;
294        let mut bytes = Vec::new();
295        stderr.read_to_end(&mut bytes).await?;
296        Ok(bytes)
297    }
298
299    /// Split this child into sealed actor capabilities.
300    ///
301    /// The lifecycle wait handle, emergency termination handle, input pipe,
302    /// and output readers are deliberately separate so the actor can keep
303    /// accepting control commands while an asynchronous exit wait is pending.
304    pub fn into_actor_parts(
305        self,
306    ) -> (
307        PlatformLifecycle,
308        PlatformEmergencySignal,
309        Option<PlatformStdin>,
310        Option<PlatformOutput>,
311        Option<PlatformOutput>,
312    ) {
313        (
314            PlatformLifecycle { child: self.child },
315            self.signal,
316            self.stdin.map(|stdin| PlatformStdin { stdin }),
317            self.stdout.map(PlatformOutput::stdout),
318            self.stderr.map(PlatformOutput::stderr),
319        )
320    }
321}
322
323/// Opaque exit-wait capability owned by a process actor.
324pub struct PlatformLifecycle {
325    child: Child,
326}
327
328impl PlatformLifecycle {
329    /// Wait asynchronously for the child to exit.
330    pub async fn wait(&mut self) -> io::Result<ExitStatus> {
331        self.child.wait().await
332    }
333}
334
335/// Opaque, non-reap-capable emergency termination capability.
336///
337/// It can be used while the actor has a pending wait on
338/// [`PlatformLifecycle`], but it cannot observe or consume the exit result.
339pub struct PlatformEmergencySignal {
340    pid: Option<u32>,
341    own_process_group: bool,
342}
343
344impl PlatformEmergencySignal {
345    /// Request immediate termination without waiting for process reaping.
346    pub fn kill(&self) -> io::Result<()> {
347        platform_imp::signal_process(self.target()?)
348    }
349
350    /// Ask the child's whole process group to shut down gracefully.
351    ///
352    /// Returns `Ok(false)` when the child was not spawned with
353    /// [`SpawnSpec::create_process_group`]: there is no group to address, and
354    /// signalling anyway would hit the caller's own group on POSIX or the
355    /// caller's console on Windows. A child that has already exited is also
356    /// `Ok` -- the soft step's only job is to give a live child a chance to
357    /// clean up before a hard kill, so a dead target is a success.
358    pub fn terminate_group_soft(&self) -> io::Result<bool> {
359        if !self.own_process_group {
360            return Ok(false);
361        }
362        platform_imp::signal_process_group(self.target()?).map(|()| true)
363    }
364
365    fn target(&self) -> io::Result<u32> {
366        self.pid.ok_or_else(|| {
367            io::Error::new(
368                io::ErrorKind::BrokenPipe,
369                "child process no longer has an emergency signal target",
370            )
371        })
372    }
373}
374
375/// Opaque piped stdin capability owned by a process actor.
376pub struct PlatformStdin {
377    stdin: ChildStdin,
378}
379
380impl PlatformStdin {
381    /// Write and flush bytes to the child stdin pipe.
382    pub async fn write(&mut self, bytes: &[u8]) -> io::Result<()> {
383        self.stdin.write_all(bytes).await?;
384        self.stdin.flush().await
385    }
386}
387
388/// Opaque stdout or stderr reader owned by a process actor.
389pub struct PlatformOutput {
390    reader: OutputReader,
391}
392
393enum OutputReader {
394    Stdout(ChildStdout),
395    Stderr(ChildStderr),
396}
397
398impl PlatformOutput {
399    fn stdout(stdout: ChildStdout) -> Self {
400        Self {
401            reader: OutputReader::Stdout(stdout),
402        }
403    }
404
405    fn stderr(stderr: ChildStderr) -> Self {
406        Self {
407            reader: OutputReader::Stderr(stderr),
408        }
409    }
410
411    /// Drain this output endpoint to EOF without blocking a runtime worker.
412    pub async fn read_to_end(self) -> io::Result<Vec<u8>> {
413        match self.reader {
414            OutputReader::Stdout(stdout) => read_owned_to_end(Some(stdout)).await,
415            OutputReader::Stderr(stderr) => read_owned_to_end(Some(stderr)).await,
416        }
417    }
418
419    /// Read the next asynchronous chunk from this output endpoint.
420    ///
421    /// The caller owns the buffer and therefore controls the amount of data
422    /// retained at each read. EOF is reported as `Ok(0)`.
423    pub async fn read_chunk(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
424        match &mut self.reader {
425            OutputReader::Stdout(stdout) => stdout.read(buffer).await,
426            OutputReader::Stderr(stderr) => stderr.read(buffer).await,
427        }
428    }
429}
430
431fn stdin_not_piped() -> io::Error {
432    io::Error::new(io::ErrorKind::BrokenPipe, "child stdin is not piped")
433}
434
435fn stdout_not_piped() -> io::Error {
436    io::Error::new(io::ErrorKind::BrokenPipe, "child stdout is not piped")
437}
438
439fn stderr_not_piped() -> io::Error {
440    io::Error::new(io::ErrorKind::BrokenPipe, "child stderr is not piped")
441}
442
443async fn read_owned_to_end<R>(reader: Option<R>) -> io::Result<Vec<u8>>
444where
445    R: AsyncRead + Unpin,
446{
447    let Some(mut reader) = reader else {
448        return Ok(Vec::new());
449    };
450    let mut bytes = Vec::new();
451    reader.read_to_end(&mut bytes).await?;
452    Ok(bytes)
453}
454
455/// Build a shell command using the host platform's supported shell.
456pub fn shell_spec(command: impl AsRef<OsStr>) -> SpawnSpec {
457    platform_imp::shell_spec(command.as_ref())
458}
459
460#[cfg(test)]
461mod tests {
462    use super::{shell_spec, SpawnSpec, StreamMode};
463
464    fn fixture_command() -> SpawnSpec {
465        #[cfg(windows)]
466        {
467            shell_spec("echo async-platform-internal")
468        }
469        #[cfg(not(windows))]
470        {
471            shell_spec("printf async-platform-internal")
472        }
473    }
474
475    #[tokio::test]
476    async fn blessed_spawn_captures_output_without_sync_wait() {
477        let output = fixture_command()
478            .stdout(StreamMode::Piped)
479            .stderr(StreamMode::Piped)
480            .spawn()
481            .await
482            .expect("spawn")
483            .wait_with_output()
484            .await
485            .expect("wait with output");
486
487        assert!(output.status.success());
488        let expected = if cfg!(windows) {
489            b"async-platform-internal\r\n".as_slice()
490        } else {
491            b"async-platform-internal".as_slice()
492        };
493        assert_eq!(output.stdout, expected);
494        assert!(output.stderr.is_empty());
495    }
496
497    #[tokio::test]
498    async fn blessed_spawn_reports_missing_program() {
499        let result = SpawnSpec::new("running-process-program-that-does-not-exist")
500            .spawn()
501            .await;
502        assert!(result.is_err());
503    }
504
505    #[tokio::test]
506    async fn one_shot_output_closes_owned_stdin() {
507        #[cfg(windows)]
508        let spec = shell_spec("more > nul & echo done");
509        #[cfg(not(windows))]
510        let spec = shell_spec("cat > /dev/null; printf done");
511
512        let output = tokio::time::timeout(
513            std::time::Duration::from_secs(2),
514            spec.stdin(StreamMode::Piped)
515                .stdout(StreamMode::Piped)
516                .stderr(StreamMode::Piped)
517                .spawn()
518                .await
519                .expect("spawn")
520                .wait_with_output(),
521        )
522        .await
523        .expect("stdin is closed for one-shot output")
524        .expect("output succeeds");
525
526        let expected = if cfg!(windows) {
527            b"done\r\n".as_slice()
528        } else {
529            b"done".as_slice()
530        };
531        assert_eq!(output.stdout, expected);
532    }
533}