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, BytesMut};
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
54const RELIABLE_OUTPUT_CHANNEL_CAPACITY: usize = 128;
55
56async fn read_output_stream<R>(
61 mut reader: R,
62 output_tx: broadcast::Sender<Bytes>,
63 mut reliable_output_tx: Option<mpsc::Sender<Bytes>>,
64) where
65 R: AsyncRead + Unpin,
66{
67 let mut buf = BytesMut::with_capacity(65_536);
70 loop {
71 buf.clear();
72 match reader.read_buf(&mut buf).await {
73 Ok(0) => break,
74 Ok(n) => {
75 let chunk = buf.split_to(n).freeze();
76 let _ = output_tx.send(chunk.clone());
77 if let Some(sender) = reliable_output_tx.as_ref().cloned()
78 && sender.send(chunk).await.is_err()
79 {
80 reliable_output_tx = None;
84 }
85 }
86 Err(ref e) if e.kind() == ErrorKind::Interrupted => continue,
87 Err(_) => break,
88 }
89 }
90}
91
92#[derive(Clone, Copy)]
94pub enum PipeStdinMode {
95 Piped,
97 Null,
99}
100
101#[derive(Clone)]
103pub struct PipeSpawnOptions {
104 program: String,
106 args: Vec<String>,
108 cwd: std::path::PathBuf,
110 env: Option<HashMap<String, String>>,
112 arg0: Option<String>,
114 stdin_mode: PipeStdinMode,
116 lossless_output: bool,
120}
121
122impl PipeSpawnOptions {
123 pub fn new(program: impl Into<String>, cwd: impl Into<std::path::PathBuf>) -> Self {
125 Self {
126 program: program.into(),
127 args: Vec::new(),
128 cwd: cwd.into(),
129 env: None,
130 arg0: None,
131 stdin_mode: PipeStdinMode::Piped,
132 lossless_output: false,
133 }
134 }
135
136 pub fn args(mut self, args: impl IntoIterator<Item = impl Into<String>>) -> Self {
138 self.args = args.into_iter().map(Into::into).collect();
139 self
140 }
141
142 pub fn env(mut self, env: HashMap<String, String>) -> Self {
144 self.env = Some(env);
145 self
146 }
147
148 pub fn arg0(mut self, arg0: impl Into<String>) -> Self {
150 self.arg0 = Some(arg0.into());
151 self
152 }
153
154 pub fn stdin_mode(mut self, mode: PipeStdinMode) -> Self {
156 self.stdin_mode = mode;
157 self
158 }
159
160 pub fn lossless_output(mut self, enabled: bool) -> Self {
162 self.lossless_output = enabled;
163 self
164 }
165}
166
167async fn spawn_process_internal(opts: PipeSpawnOptions) -> Result<SpawnedProcess> {
169 if opts.program.is_empty() {
170 anyhow::bail!("missing program for pipe spawn");
171 }
172
173 let mut command = Command::new(&opts.program);
174
175 #[cfg(unix)]
176 if let Some(ref arg0) = opts.arg0 {
177 command.arg0(arg0);
178 }
179
180 #[cfg(unix)]
181 #[expect(
182 unsafe_code,
183 reason = "detach_from_tty only calls setsid/setpgid via safe nix wrappers to detach the child from the controlling terminal; it is a pure process-group operation with no undefined behavior"
184 )]
185 unsafe {
191 command.pre_exec(process_group::detach_from_tty);
192 }
193
194 #[cfg(not(unix))]
195 let _ = &opts.arg0;
196
197 command.current_dir(&opts.cwd);
198
199 if let Some(ref env) = opts.env {
201 command.env_clear();
202 for (key, value) in env {
203 command.env(key, value);
204 }
205 }
206
207 for arg in &opts.args {
208 command.arg(arg);
209 }
210
211 match opts.stdin_mode {
212 PipeStdinMode::Piped => {
213 command.stdin(Stdio::piped());
214 }
215 PipeStdinMode::Null => {
216 command.stdin(Stdio::null());
217 }
218 }
219 command.stdout(Stdio::piped());
220 command.stderr(Stdio::piped());
221
222 let mut child = command.spawn().context("failed to spawn pipe process")?;
223 let pid = child.id().ok_or_else(|| io::Error::other("missing child pid"))?;
224
225 #[cfg(unix)]
226 let process_group_id = pid;
227
228 let stdin = child.stdin.take();
229 let stdout = child.stdout.take();
230 let stderr = child.stderr.take();
231
232 let (writer_tx, mut writer_rx) = mpsc::channel::<Vec<u8>>(128);
233 let (output_tx, _) = broadcast::channel::<Bytes>(256);
234 let initial_output_rx = output_tx.subscribe();
235 let (reliable_output_tx, reliable_output_rx) = mpsc::channel::<Bytes>(RELIABLE_OUTPUT_CHANNEL_CAPACITY);
236 let reliable_output_enabled = opts.lossless_output;
237
238 let writer_handle = if let Some(stdin) = stdin {
240 let writer = Arc::new(tokio::sync::Mutex::new(stdin));
241 tokio::spawn(async move {
242 while let Some(bytes) = writer_rx.recv().await {
243 let mut guard = writer.lock().await;
244 let _ = guard.write_all(&bytes).await;
245 let _ = guard.flush().await;
246 }
247 })
248 } else {
249 drop(writer_rx);
250 tokio::spawn(async {})
251 };
252
253 let stdout_handle = stdout.map(|stdout| {
255 let output_tx = output_tx.clone();
256 let reliable_output_tx = opts.lossless_output.then(|| reliable_output_tx.clone());
257 tokio::spawn(async move {
258 read_output_stream(BufReader::new(stdout), output_tx, reliable_output_tx).await;
259 })
260 });
261
262 let stderr_handle = stderr.map(|stderr| {
263 let output_tx = output_tx.clone();
264 let reliable_output_tx = opts.lossless_output.then(|| reliable_output_tx.clone());
265 tokio::spawn(async move {
266 read_output_stream(BufReader::new(stderr), output_tx, reliable_output_tx).await;
267 })
268 });
269 drop(reliable_output_tx);
270
271 let mut reader_abort_handles = Vec::new();
272 if let Some(ref handle) = stdout_handle {
273 reader_abort_handles.push(handle.abort_handle());
274 }
275 if let Some(ref handle) = stderr_handle {
276 reader_abort_handles.push(handle.abort_handle());
277 }
278
279 let reader_handle = tokio::spawn(async move {
280 if let Some(handle) = stdout_handle {
281 let _ = handle.await;
282 }
283 if let Some(handle) = stderr_handle {
284 let _ = handle.await;
285 }
286 });
287
288 let (exit_tx, exit_rx) = oneshot::channel::<i32>();
290 let exit_status = Arc::new(AtomicBool::new(false));
291 let wait_exit_status = Arc::clone(&exit_status);
292 let exit_code = Arc::new(StdMutex::new(None));
293 let wait_exit_code = Arc::clone(&exit_code);
294
295 let wait_handle: JoinHandle<()> = tokio::spawn(async move {
296 let code = match child.wait().await {
297 Ok(status) => status.code().unwrap_or(-1),
298 Err(_) => -1,
299 };
300 wait_exit_status.store(true, std::sync::atomic::Ordering::SeqCst);
301 if let Ok(mut guard) = wait_exit_code.lock() {
302 *guard = Some(code);
303 }
304 let _ = exit_tx.send(code);
305 });
306
307 let (handle, output_rx) = ProcessHandle::new(
308 writer_tx,
309 output_tx,
310 initial_output_rx,
311 Box::new(PipeChildTerminator {
312 #[cfg(windows)]
313 pid,
314 #[cfg(unix)]
315 process_group_id,
316 }),
317 reader_handle,
318 reader_abort_handles,
319 writer_handle,
320 wait_handle,
321 exit_status,
322 exit_code,
323 None,
324 );
325
326 Ok(SpawnedProcess {
327 session: handle,
328 output_rx,
329 reliable_output_rx,
330 reliable_output_enabled,
331 exit_rx,
332 })
333}
334
335pub async fn spawn_process(
349 program: &str,
350 args: &[String],
351 cwd: &Path,
352 env: &HashMap<String, String>,
353 arg0: &Option<String>,
354) -> Result<SpawnedProcess> {
355 let opts = PipeSpawnOptions {
356 program: program.to_string(),
357 args: args.to_vec(),
358 cwd: cwd.to_path_buf(),
359 env: Some(env.clone()),
360 arg0: arg0.clone(),
361 stdin_mode: PipeStdinMode::Piped,
362 lossless_output: false,
363 };
364 spawn_process_internal(opts).await
365}
366
367pub async fn spawn_process_no_stdin(
371 program: &str,
372 args: &[String],
373 cwd: &Path,
374 env: &HashMap<String, String>,
375 arg0: &Option<String>,
376) -> Result<SpawnedProcess> {
377 let opts = PipeSpawnOptions {
378 program: program.to_string(),
379 args: args.to_vec(),
380 cwd: cwd.to_path_buf(),
381 env: Some(env.clone()),
382 arg0: arg0.clone(),
383 stdin_mode: PipeStdinMode::Null,
384 lossless_output: false,
385 };
386 spawn_process_internal(opts).await
387}
388
389pub async fn spawn_process_with_options(opts: PipeSpawnOptions) -> Result<SpawnedProcess> {
391 spawn_process_internal(opts).await
392}
393
394#[cfg(test)]
395mod tests {
396 use super::*;
397 use assert_fs::TempDir;
398
399 fn find_echo_command() -> Option<(String, Vec<String>)> {
400 #[cfg(windows)]
401 {
402 Some(("cmd.exe".to_string(), vec!["/C".to_string(), "echo".to_string()]))
403 }
404 #[cfg(not(windows))]
405 {
406 Some(("echo".to_string(), vec![]))
407 }
408 }
409
410 #[tokio::test]
411 async fn test_spawn_process_echo() -> Result<()> {
412 let Some((program, mut base_args)) = find_echo_command() else {
413 return Ok(());
414 };
415
416 base_args.push("hello".to_string());
417
418 let env: HashMap<String, String> = std::env::vars().collect();
419 let spawned = spawn_process(&program, &base_args, Path::new("."), &env, &None).await?;
420
421 let exit_code = spawned.exit_rx.await.unwrap_or(-1);
422 assert_eq!(exit_code, 0);
423
424 Ok(())
425 }
426
427 #[tokio::test]
428 async fn test_spawn_options_builder() {
429 let opts = PipeSpawnOptions::new("echo", ".")
430 .args(["hello", "world"])
431 .stdin_mode(PipeStdinMode::Null);
432
433 assert_eq!(opts.program, "echo");
434 assert_eq!(opts.args, vec!["hello", "world"]);
435 assert!(matches!(opts.stdin_mode, PipeStdinMode::Null));
436 }
437
438 #[cfg(unix)]
439 #[tokio::test]
440 async fn test_spawn_process_detaches_from_tty() {
441 let dir = TempDir::new().expect("tempdir");
442 let env: HashMap<String, String> = HashMap::new();
443
444 let spawned = spawn_process("sh", &["-c".into(), "echo ok".into()], dir.path(), &env, &None)
445 .await
446 .expect("spawn");
447
448 let exit_code = spawned.exit_rx.await.unwrap_or(-1);
449 assert_eq!(exit_code, 0);
450 }
451}