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}