1use hashbrown::HashMap;
10use std::io::{self, ErrorKind};
11use std::path::Path;
12use std::process::Stdio;
13use std::sync::Arc;
14use std::sync::Mutex as StdMutex;
15use std::sync::atomic::AtomicBool;
16
17use anyhow::{Context, Result};
18use bytes::Bytes;
19use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt, BufReader};
20use tokio::process::Command;
21use tokio::sync::{broadcast, mpsc, oneshot};
22use tokio::task::JoinHandle;
23
24use crate::process::{ChildTerminator, ProcessHandle, SpawnedProcess};
25use crate::process_group;
26
27struct PipeChildTerminator {
29 #[cfg(windows)]
30 pid: u32,
31 #[cfg(unix)]
32 process_group_id: u32,
33}
34
35impl ChildTerminator for PipeChildTerminator {
36 fn kill(&mut self) -> io::Result<()> {
37 #[cfg(unix)]
38 {
39 process_group::kill_process_group(self.process_group_id)
40 }
41
42 #[cfg(windows)]
43 {
44 process_group::kill_process(self.pid)
45 }
46
47 #[cfg(not(any(unix, windows)))]
48 {
49 Ok(())
50 }
51 }
52}
53
54async fn read_output_stream<R>(mut reader: R, output_tx: broadcast::Sender<Bytes>)
56where
57 R: AsyncRead + Unpin,
58{
59 let mut buf = vec![0u8; 8_192];
60 loop {
61 match reader.read(&mut buf).await {
62 Ok(0) => break,
63 Ok(n) => {
64 let _ = output_tx.send(Bytes::copy_from_slice(&buf[..n]));
65 }
66 Err(ref e) if e.kind() == ErrorKind::Interrupted => continue,
67 Err(_) => break,
68 }
69 }
70}
71
72#[derive(Clone, Copy)]
74pub enum PipeStdinMode {
75 Piped,
77 Null,
79}
80
81#[derive(Clone)]
83pub struct PipeSpawnOptions {
84 pub program: String,
86 pub args: Vec<String>,
88 pub cwd: std::path::PathBuf,
90 pub env: Option<HashMap<String, String>>,
92 pub arg0: Option<String>,
94 pub stdin_mode: PipeStdinMode,
96}
97
98impl PipeSpawnOptions {
99 pub fn new(program: impl Into<String>, cwd: impl Into<std::path::PathBuf>) -> Self {
101 Self {
102 program: program.into(),
103 args: Vec::new(),
104 cwd: cwd.into(),
105 env: None,
106 arg0: None,
107 stdin_mode: PipeStdinMode::Piped,
108 }
109 }
110
111 pub fn args(mut self, args: impl IntoIterator<Item = impl Into<String>>) -> Self {
113 self.args = args.into_iter().map(Into::into).collect();
114 self
115 }
116
117 pub fn env(mut self, env: HashMap<String, String>) -> Self {
119 self.env = Some(env);
120 self
121 }
122
123 pub fn arg0(mut self, arg0: impl Into<String>) -> Self {
125 self.arg0 = Some(arg0.into());
126 self
127 }
128
129 pub fn stdin_mode(mut self, mode: PipeStdinMode) -> Self {
131 self.stdin_mode = mode;
132 self
133 }
134}
135
136async fn spawn_process_internal(opts: PipeSpawnOptions) -> Result<SpawnedProcess> {
138 if opts.program.is_empty() {
139 anyhow::bail!("missing program for pipe spawn");
140 }
141
142 let mut command = Command::new(&opts.program);
143
144 #[cfg(unix)]
145 if let Some(ref arg0) = opts.arg0 {
146 command.arg0(arg0);
147 }
148
149 #[cfg(unix)]
150 #[expect(
151 unsafe_code,
152 reason = "detach_from_tty only calls setpgrp/setpgid to detach the child process from the controlling terminal; it is a pure process-group operation with no undefined behavior"
153 )]
154 unsafe {
155 command.pre_exec(process_group::detach_from_tty);
156 }
157
158 #[cfg(not(unix))]
159 let _ = &opts.arg0;
160
161 command.current_dir(&opts.cwd);
162
163 if let Some(ref env) = opts.env {
165 command.env_clear();
166 for (key, value) in env {
167 command.env(key, value);
168 }
169 }
170
171 for arg in &opts.args {
172 command.arg(arg);
173 }
174
175 match opts.stdin_mode {
176 PipeStdinMode::Piped => {
177 command.stdin(Stdio::piped());
178 }
179 PipeStdinMode::Null => {
180 command.stdin(Stdio::null());
181 }
182 }
183 command.stdout(Stdio::piped());
184 command.stderr(Stdio::piped());
185
186 let mut child = command.spawn().context("failed to spawn pipe process")?;
187 let pid = child.id().ok_or_else(|| io::Error::other("missing child pid"))?;
188
189 #[cfg(unix)]
190 let process_group_id = pid;
191
192 let stdin = child.stdin.take();
193 let stdout = child.stdout.take();
194 let stderr = child.stderr.take();
195
196 let (writer_tx, mut writer_rx) = mpsc::channel::<Vec<u8>>(128);
197 let (output_tx, _) = broadcast::channel::<Bytes>(256);
198 let initial_output_rx = output_tx.subscribe();
199
200 let writer_handle = if let Some(stdin) = stdin {
202 let writer = Arc::new(tokio::sync::Mutex::new(stdin));
203 tokio::spawn(async move {
204 while let Some(bytes) = writer_rx.recv().await {
205 let mut guard = writer.lock().await;
206 let _ = guard.write_all(&bytes).await;
207 let _ = guard.flush().await;
208 }
209 })
210 } else {
211 drop(writer_rx);
212 tokio::spawn(async {})
213 };
214
215 let stdout_handle = stdout.map(|stdout| {
217 let output_tx = output_tx.clone();
218 tokio::spawn(async move {
219 read_output_stream(BufReader::new(stdout), output_tx).await;
220 })
221 });
222
223 let stderr_handle = stderr.map(|stderr| {
224 let output_tx = output_tx.clone();
225 tokio::spawn(async move {
226 read_output_stream(BufReader::new(stderr), output_tx).await;
227 })
228 });
229
230 let mut reader_abort_handles = Vec::new();
231 if let Some(ref handle) = stdout_handle {
232 reader_abort_handles.push(handle.abort_handle());
233 }
234 if let Some(ref handle) = stderr_handle {
235 reader_abort_handles.push(handle.abort_handle());
236 }
237
238 let reader_handle = tokio::spawn(async move {
239 if let Some(handle) = stdout_handle {
240 let _ = handle.await;
241 }
242 if let Some(handle) = stderr_handle {
243 let _ = handle.await;
244 }
245 });
246
247 let (exit_tx, exit_rx) = oneshot::channel::<i32>();
249 let exit_status = Arc::new(AtomicBool::new(false));
250 let wait_exit_status = Arc::clone(&exit_status);
251 let exit_code = Arc::new(StdMutex::new(None));
252 let wait_exit_code = Arc::clone(&exit_code);
253
254 let wait_handle: JoinHandle<()> = tokio::spawn(async move {
255 let code = match child.wait().await {
256 Ok(status) => status.code().unwrap_or(-1),
257 Err(_) => -1,
258 };
259 wait_exit_status.store(true, std::sync::atomic::Ordering::SeqCst);
260 if let Ok(mut guard) = wait_exit_code.lock() {
261 *guard = Some(code);
262 }
263 let _ = exit_tx.send(code);
264 });
265
266 let (handle, output_rx) = ProcessHandle::new(
267 writer_tx,
268 output_tx,
269 initial_output_rx,
270 Box::new(PipeChildTerminator {
271 #[cfg(windows)]
272 pid,
273 #[cfg(unix)]
274 process_group_id,
275 }),
276 reader_handle,
277 reader_abort_handles,
278 writer_handle,
279 wait_handle,
280 exit_status,
281 exit_code,
282 None,
283 );
284
285 Ok(SpawnedProcess { session: handle, output_rx, exit_rx })
286}
287
288pub async fn spawn_process(
302 program: &str,
303 args: &[String],
304 cwd: &Path,
305 env: &HashMap<String, String>,
306 arg0: &Option<String>,
307) -> Result<SpawnedProcess> {
308 let opts = PipeSpawnOptions {
309 program: program.to_string(),
310 args: args.to_vec(),
311 cwd: cwd.to_path_buf(),
312 env: Some(env.clone()),
313 arg0: arg0.clone(),
314 stdin_mode: PipeStdinMode::Piped,
315 };
316 spawn_process_internal(opts).await
317}
318
319pub async fn spawn_process_no_stdin(
323 program: &str,
324 args: &[String],
325 cwd: &Path,
326 env: &HashMap<String, String>,
327 arg0: &Option<String>,
328) -> Result<SpawnedProcess> {
329 let opts = PipeSpawnOptions {
330 program: program.to_string(),
331 args: args.to_vec(),
332 cwd: cwd.to_path_buf(),
333 env: Some(env.clone()),
334 arg0: arg0.clone(),
335 stdin_mode: PipeStdinMode::Null,
336 };
337 spawn_process_internal(opts).await
338}
339
340pub async fn spawn_process_with_options(opts: PipeSpawnOptions) -> Result<SpawnedProcess> {
342 spawn_process_internal(opts).await
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348 use assert_fs::TempDir;
349
350 fn find_echo_command() -> Option<(String, Vec<String>)> {
351 #[cfg(windows)]
352 {
353 Some(("cmd.exe".to_string(), vec!["/C".to_string(), "echo".to_string()]))
354 }
355 #[cfg(not(windows))]
356 {
357 Some(("echo".to_string(), vec![]))
358 }
359 }
360
361 #[tokio::test]
362 async fn test_spawn_process_echo() -> Result<()> {
363 let Some((program, mut base_args)) = find_echo_command() else {
364 return Ok(());
365 };
366
367 base_args.push("hello".to_string());
368
369 let env: HashMap<String, String> = std::env::vars().collect();
370 let spawned = spawn_process(&program, &base_args, Path::new("."), &env, &None).await?;
371
372 let exit_code = spawned.exit_rx.await.unwrap_or(-1);
373 assert_eq!(exit_code, 0);
374
375 Ok(())
376 }
377
378 #[tokio::test]
379 async fn test_spawn_options_builder() {
380 let opts = PipeSpawnOptions::new("echo", ".")
381 .args(["hello", "world"])
382 .stdin_mode(PipeStdinMode::Null);
383
384 assert_eq!(opts.program, "echo");
385 assert_eq!(opts.args, vec!["hello", "world"]);
386 assert!(matches!(opts.stdin_mode, PipeStdinMode::Null));
387 }
388
389 #[cfg(unix)]
390 #[tokio::test]
391 async fn test_spawn_process_detaches_from_tty() {
392 let dir = TempDir::new().expect("tempdir");
393 let env: HashMap<String, String> = HashMap::new();
394
395 let spawned = spawn_process("sh", &["-c".into(), "echo ok".into()], dir.path(), &env, &None)
396 .await
397 .expect("spawn");
398
399 let exit_code = spawned.exit_rx.await.unwrap_or(-1);
400 assert_eq!(exit_code, 0);
401 }
402}