Skip to main content

dtact_util/process/
native.rs

1//! Native process backend: spawn is a direct synchronous
2//! `std::process::Command::spawn()` call (matches `tokio::process`'s own
3//! choice — spawning is a single `fork`+`exec`/`CreateProcess` syscall,
4//! not something worth dispatching anywhere), while `wait`/stdio I/O
5//! (operations that can genuinely block for an unbounded time) run on a
6//! dedicated blocking-thread pool.
7//!
8//! **No `Mutex` anywhere.** Unlike an earlier draft of this module, child
9//! handles are never shared behind a lock: `DtactChild::wait`/
10//! `wait_with_output` *consume* `self` and move the whole
11//! `std::process::Child` into the pool closure — there is no concurrent
12//! access to coordinate because ownership transfers outright. Stdio
13//! handles (`take_stdin`/`take_stdout`/`take_stderr`) are the same idea:
14//! each is exclusively owned by whoever holds the returned handle, and
15//! each async op temporarily moves it into a pool closure and gets it
16//! back in the result, rather than holding it behind a shared lock across
17//! the `.await`. Completion signaling uses
18//! [`crate::lockfree::OnceSlot`] (a single `AtomicPtr` swap + wait-free
19//! waker), not a `Mutex`-guarded completion flag.
20
21use crate::lockfree::OnceSlot;
22use std::ffi::OsStr;
23use std::future::Future;
24use std::io;
25use std::pin::Pin;
26use std::process::{Command, ExitStatus, Output, Stdio};
27use std::sync::{Arc, OnceLock, mpsc};
28use std::task::{Context, Poll};
29
30type Job = Box<dyn FnOnce() + Send + 'static>;
31
32struct ProcessPool {
33    sender: mpsc::Sender<Job>,
34}
35
36static PROCESS_POOL: OnceLock<ProcessPool> = OnceLock::new();
37
38/// Start the process thread pool with the given number of worker
39/// threads. Idempotent — later calls are no-ops once initialized.
40///
41/// # Panics
42///
43/// Panics if the OS refuses to spawn one of the `workers` pool threads
44/// (`std::thread::Builder::spawn` failure, e.g. the process is out of
45/// resources) — this is treated as fatal because a partially-started pool
46/// would silently drop later `spawn_blocking` work.
47pub fn init(workers: usize) {
48    PROCESS_POOL.get_or_init(|| {
49        let (tx, rx) = mpsc::channel::<Job>();
50        let rx = Arc::new(std::sync::Mutex::new(rx));
51        for _ in 0..workers.max(1) {
52            let rx = Arc::clone(&rx);
53            std::thread::Builder::new()
54                .name("dtact-process-worker".into())
55                .spawn(move || {
56                    loop {
57                        let job = { rx.lock().unwrap().recv() };
58                        match job {
59                            Ok(job) => job(),
60                            Err(_) => break,
61                        }
62                    }
63                })
64                .expect("failed to spawn dtact-process worker thread");
65        }
66        ProcessPool { sender: tx }
67    });
68}
69
70/// Full-signature entry point matching the other native backends' call.
71///
72/// Matches `init_fs`'s shape, for a future `process_init` macro to call
73/// uniformly. `ring_depth`/`buffer_pool_size`/`chunk_size`/`pin_cpus`
74/// don't apply to this thread-pool-bridged backend and are ignored.
75///
76/// # Panics
77///
78/// See [`init`] — the same worker-thread-spawn failure is fatal here too.
79pub fn init_process(
80    workers: usize,
81    _ring_depth: u32,
82    _buffer_pool_size: usize,
83    _chunk_size: usize,
84    _pin_cpus: &[usize],
85) {
86    init(workers);
87}
88
89/// A single blocking operation dispatched to the process thread pool,
90/// completed via a wait-free [`OnceSlot`] rather than a `Mutex`-guarded
91/// flag.
92pub struct BlockingOp<T> {
93    slot: Arc<OnceSlot<T>>,
94}
95
96impl<T: Send + 'static> Future for BlockingOp<T> {
97    type Output = T;
98    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<T> {
99        self.slot.poll(cx)
100    }
101}
102
103fn spawn_blocking<T, F>(f: F) -> BlockingOp<T>
104where
105    T: Send + 'static,
106    F: FnOnce() -> T + Send + 'static,
107{
108    if PROCESS_POOL.get().is_none() {
109        init(4);
110    }
111    let slot = Arc::new(OnceSlot::new());
112    let slot2 = Arc::clone(&slot);
113    let job: Job = Box::new(move || {
114        let result = f();
115        slot2.set(result);
116    });
117    let _ = PROCESS_POOL.get().unwrap().sender.send(job);
118    BlockingOp { slot }
119}
120
121/// Async-friendly wrapper over [`std::process::Command`]. Builder methods
122/// mirror `std::process::Command`'s own naming.
123pub struct DtactCommand(Command);
124
125impl DtactCommand {
126    /// Start building a command that will run `program`.
127    pub fn new(program: impl AsRef<OsStr>) -> Self {
128        Self(Command::new(program))
129    }
130
131    /// Append a single argument.
132    pub fn arg(&mut self, arg: impl AsRef<OsStr>) -> &mut Self {
133        self.0.arg(arg);
134        self
135    }
136
137    /// Append multiple arguments at once.
138    pub fn args<I, S>(&mut self, args: I) -> &mut Self
139    where
140        I: IntoIterator<Item = S>,
141        S: AsRef<OsStr>,
142    {
143        self.0.args(args);
144        self
145    }
146
147    /// Set an environment variable for the child process.
148    pub fn env(&mut self, key: impl AsRef<OsStr>, val: impl AsRef<OsStr>) -> &mut Self {
149        self.0.env(key, val);
150        self
151    }
152
153    /// Set the working directory the child process is spawned in.
154    pub fn current_dir(&mut self, dir: impl AsRef<std::path::Path>) -> &mut Self {
155        self.0.current_dir(dir);
156        self
157    }
158
159    /// Configure how the child's stdin is set up (inherit/pipe/null).
160    pub fn stdin(&mut self, cfg: Stdio) -> &mut Self {
161        self.0.stdin(cfg);
162        self
163    }
164
165    /// Configure how the child's stdout is set up (inherit/pipe/null).
166    pub fn stdout(&mut self, cfg: Stdio) -> &mut Self {
167        self.0.stdout(cfg);
168        self
169    }
170
171    /// Configure how the child's stderr is set up (inherit/pipe/null).
172    pub fn stderr(&mut self, cfg: Stdio) -> &mut Self {
173        self.0.stderr(cfg);
174        self
175    }
176
177    /// Spawn the child process. A direct synchronous syscall — see the
178    /// module doc for why this isn't dispatched to the pool.
179    ///
180    /// # Errors
181    ///
182    /// Returns whatever `std::process::Command::spawn` returns: most
183    /// commonly `io::ErrorKind::NotFound` if `program` isn't on `PATH`/
184    /// doesn't exist, or `PermissionDenied` if it exists but isn't
185    /// executable by the current user.
186    pub fn spawn(&mut self) -> io::Result<DtactChild> {
187        self.0.spawn().map(DtactChild::new)
188    }
189}
190
191/// A spawned child process.
192///
193/// `wait`/`wait_with_output` consume `self` (ownership transfers into the
194/// pool closure, so nothing needs to be shared or locked); `kill`/`id`
195/// are synchronous since they're fast, non-blocking syscalls.
196pub struct DtactChild {
197    inner: std::process::Child,
198}
199
200impl DtactChild {
201    const fn new(inner: std::process::Child) -> Self {
202        Self { inner }
203    }
204
205    /// The OS process ID of the child.
206    #[must_use]
207    pub fn id(&self) -> u32 {
208        self.inner.id()
209    }
210
211    /// Send `SIGKILL` (Unix) / `TerminateProcess` (Windows) to the child.
212    ///
213    /// # Errors
214    ///
215    /// Returns an error if the OS kill syscall fails — in practice this is
216    /// almost always because the process had already exited (the
217    /// underlying `std::process::Child::kill` documents this as the
218    /// common failure case; it is not itself treated as success).
219    pub fn kill(&mut self) -> io::Result<()> {
220        self.inner.kill()
221    }
222
223    /// Take ownership of the child's stdin, if it was configured with
224    /// `Stdio::piped()`. Can only be taken once.
225    pub fn take_stdin(&mut self) -> Option<DtactChildStdin> {
226        self.inner.stdin.take().map(DtactChildStdin::new)
227    }
228
229    /// Take ownership of the child's stdout, if it was configured with
230    /// `Stdio::piped()`. Can only be taken once.
231    pub fn take_stdout(&mut self) -> Option<DtactChildStdout> {
232        self.inner.stdout.take().map(DtactChildStdout::new)
233    }
234
235    /// Take ownership of the child's stderr, if it was configured with
236    /// `Stdio::piped()`. Can only be taken once.
237    pub fn take_stderr(&mut self) -> Option<DtactChildStderr> {
238        self.inner.stderr.take().map(DtactChildStderr::new)
239    }
240
241    /// Block (on the process pool, not the calling task's thread) until
242    /// the child exits.
243    ///
244    /// # Errors
245    ///
246    /// Returns whatever `std::process::Child::wait` returns — an I/O
247    /// error if waiting on the OS process handle itself fails (rare; not
248    /// the same as the child exiting non-zero, which is a normal `Ok`
249    /// with a non-zero `ExitStatus`).
250    pub async fn wait(mut self) -> io::Result<ExitStatus> {
251        spawn_blocking(move || self.inner.wait()).await
252    }
253
254    /// Wait for exit and collect stdout/stderr in one shot — the
255    /// std-library convenience, dispatched to the pool the same way.
256    ///
257    /// # Errors
258    ///
259    /// Same as [`Self::wait`]: an I/O error only if waiting on the OS
260    /// process handle or reading its piped stdout/stderr fails, not for a
261    /// non-zero exit status.
262    pub async fn wait_with_output(self) -> io::Result<Output> {
263        spawn_blocking(move || self.inner.wait_with_output()).await
264    }
265}
266
267// These three were previously generated by a `child_pipe!($name, $inner)`
268// macro (identical shape: a newtype over `Option<$inner>` plus a `const
269// fn new`). Spelled out by hand instead, because cbindgen — which needs to
270// see these as plain `pub struct`s to emit their opaque C typedefs — only
271// resolves macro-generated items when `[parse.expand]` is enabled in
272// `cbindgen.toml`, and that requires the `cargo-expand` subcommand (plus a
273// nightly toolchain) to be installed wherever headers are generated. Three
274// near-identical structs is a small price for not taking on that build-time
275// dependency; see `dtact-util/dtact_util.h`'s `DtactChildStdin`/`Stdout`/
276// `Stderr` typedefs, which silently disappeared the one time this *was*
277// macro-generated with `[parse.expand]` off.
278
279/// One end of a child process's stdin pipe.
280///
281/// Exclusively owned by whoever holds it (returned by `take_stdin` on
282/// `DtactChild`) — each async op temporarily moves the handle into a pool
283/// closure and gets it back in the result, never shared behind a lock.
284pub struct DtactChildStdin(Option<std::process::ChildStdin>);
285
286impl DtactChildStdin {
287    const fn new(inner: std::process::ChildStdin) -> Self {
288        Self(Some(inner))
289    }
290}
291
292/// One end of a child process's stdout pipe.
293///
294/// Exclusively owned by whoever holds it (returned by `take_stdout` on
295/// `DtactChild`) — each async op temporarily moves the handle into a pool
296/// closure and gets it back in the result, never shared behind a lock.
297pub struct DtactChildStdout(Option<std::process::ChildStdout>);
298
299impl DtactChildStdout {
300    const fn new(inner: std::process::ChildStdout) -> Self {
301        Self(Some(inner))
302    }
303}
304
305/// One end of a child process's stderr pipe.
306///
307/// Exclusively owned by whoever holds it (returned by `take_stderr` on
308/// `DtactChild`) — each async op temporarily moves the handle into a pool
309/// closure and gets it back in the result, never shared behind a lock.
310pub struct DtactChildStderr(Option<std::process::ChildStderr>);
311
312impl DtactChildStderr {
313    const fn new(inner: std::process::ChildStderr) -> Self {
314        Self(Some(inner))
315    }
316}
317
318impl DtactChildStdin {
319    /// Write `buf` to the child's stdin on the process pool, returning
320    /// the number of bytes written and the buffer back for reuse.
321    ///
322    /// # Errors
323    ///
324    /// Returns whatever the underlying blocking `Write::write` on the
325    /// pipe returns (e.g. `BrokenPipe` if the child has already exited
326    /// and closed its end).
327    ///
328    /// # Panics
329    ///
330    /// Panics if called again while a previous call on the same `&mut
331    /// self` hasn't finished — in practice this is unreachable through
332    /// safe code: taking `&mut self` for the duration of the returned
333    /// future's lifetime means a second call cannot start until the
334    /// first's future has been driven to completion (or dropped), at
335    /// which point the handle has already been restored to `Some`.
336    pub async fn write(&mut self, buf: Vec<u8>) -> io::Result<(usize, Vec<u8>)> {
337        use std::io::Write;
338        let mut handle = self
339            .0
340            .take()
341            .expect("dtact-process: concurrent write on the same DtactChildStdin");
342        let (result, handle) = spawn_blocking(move || {
343            let r = handle.write(&buf).map(|n| (n, buf));
344            (r, handle)
345        })
346        .await;
347        self.0 = Some(handle);
348        result
349    }
350
351    /// Explicitly drop this end (closes the pipe), letting the child
352    /// observe EOF on its stdin.
353    pub fn close(mut self) {
354        self.0 = None;
355    }
356}
357
358impl DtactChildStdout {
359    /// Read from the child's stdout on the process pool into `buf`,
360    /// returning the number of bytes read and the buffer back.
361    ///
362    /// # Errors
363    ///
364    /// Returns whatever the underlying blocking `Read::read` on the pipe
365    /// returns.
366    ///
367    /// # Panics
368    ///
369    /// Panics if called again while a previous call on the same `&mut
370    /// self` hasn't finished — see [`DtactChildStdin::write`]'s doc for
371    /// why this is unreachable through safe code.
372    pub async fn read(&mut self, mut buf: Vec<u8>) -> io::Result<(usize, Vec<u8>)> {
373        use std::io::Read;
374        let mut handle = self
375            .0
376            .take()
377            .expect("dtact-process: concurrent read on the same DtactChildStdout");
378        let (result, handle) = spawn_blocking(move || {
379            let r = handle.read(&mut buf).map(|n| (n, buf));
380            (r, handle)
381        })
382        .await;
383        self.0 = Some(handle);
384        result
385    }
386}
387
388impl DtactChildStderr {
389    /// Read from the child's stderr on the process pool into `buf`,
390    /// returning the number of bytes read and the buffer back.
391    ///
392    /// # Errors
393    ///
394    /// Returns whatever the underlying blocking `Read::read` on the pipe
395    /// returns.
396    ///
397    /// # Panics
398    ///
399    /// Panics if called again while a previous call on the same `&mut
400    /// self` hasn't finished — see [`DtactChildStdin::write`]'s doc for
401    /// why this is unreachable through safe code.
402    pub async fn read(&mut self, mut buf: Vec<u8>) -> io::Result<(usize, Vec<u8>)> {
403        use std::io::Read;
404        let mut handle = self
405            .0
406            .take()
407            .expect("dtact-process: concurrent read on the same DtactChildStderr");
408        let (result, handle) = spawn_blocking(move || {
409            let r = handle.read(&mut buf).map(|n| (n, buf));
410            (r, handle)
411        })
412        .await;
413        self.0 = Some(handle);
414        result
415    }
416}