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)]
103#[must_use = "spawn options must be passed to a spawn function"]
104pub struct PipeSpawnOptions {
105 program: String,
107 args: Vec<String>,
109 cwd: std::path::PathBuf,
111 env: Option<HashMap<String, String>>,
113 arg0: Option<String>,
115 stdin_mode: PipeStdinMode,
117 lossless_output: bool,
121}
122
123impl PipeSpawnOptions {
124 pub fn new(program: impl Into<String>, cwd: impl Into<std::path::PathBuf>) -> Self {
126 Self {
127 program: program.into(),
128 args: Vec::new(),
129 cwd: cwd.into(),
130 env: None,
131 arg0: None,
132 stdin_mode: PipeStdinMode::Piped,
133 lossless_output: false,
134 }
135 }
136
137 pub fn args(mut self, args: impl IntoIterator<Item = impl Into<String>>) -> Self {
139 self.args = args.into_iter().map(Into::into).collect();
140 self
141 }
142
143 pub fn env(mut self, env: HashMap<String, String>) -> Self {
145 self.env = Some(env);
146 self
147 }
148
149 pub fn arg0(mut self, arg0: impl Into<String>) -> Self {
151 self.arg0 = Some(arg0.into());
152 self
153 }
154
155 pub fn stdin_mode(mut self, mode: PipeStdinMode) -> Self {
157 self.stdin_mode = mode;
158 self
159 }
160
161 pub fn lossless_output(mut self, enabled: bool) -> Self {
163 self.lossless_output = enabled;
164 self
165 }
166}
167
168async fn spawn_process_internal(opts: PipeSpawnOptions) -> Result<SpawnedProcess> {
170 if opts.program.is_empty() {
171 anyhow::bail!("missing program for pipe spawn");
172 }
173
174 #[cfg(unix)]
175 let owned = crate::process_owner::OwnedCommand::from_environment(
176 opts.program.as_ref(),
177 opts.args.iter(),
178 opts.arg0.as_deref().map(std::ffi::OsStr::new),
179 )?;
180
181 #[cfg(unix)]
182 let mut command = match &owned {
183 Some(owned) => {
184 let mut command = Command::new(&owned.executable);
185 command.args(&owned.arguments);
186 command
187 }
188 None => Command::new(&opts.program),
189 };
190 #[cfg(not(unix))]
191 let mut command = Command::new(&opts.program);
192
193 #[cfg(unix)]
194 if owned.is_none()
195 && let Some(ref arg0) = opts.arg0
196 {
197 command.arg0(arg0);
198 }
199
200 #[cfg(unix)]
201 #[expect(
202 unsafe_code,
203 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"
204 )]
205 unsafe {
211 command.pre_exec(process_group::detach_from_tty);
212 }
213
214 #[cfg(not(unix))]
215 let _ = &opts.arg0;
216
217 command.current_dir(&opts.cwd);
218
219 if let Some(ref env) = opts.env {
221 command.env_clear();
222 for (key, value) in env {
223 command.env(key, value);
224 }
225 }
226
227 #[cfg(unix)]
228 if let Some(owned) = &owned {
229 command.env(crate::process_owner::OWNER_SOCKET_ENV, &owned.owner_socket);
232 } else {
233 command.args(&opts.args);
234 }
235 #[cfg(not(unix))]
236 command.args(&opts.args);
237
238 match opts.stdin_mode {
239 PipeStdinMode::Piped => {
240 command.stdin(Stdio::piped());
241 }
242 PipeStdinMode::Null => {
243 command.stdin(Stdio::null());
244 }
245 }
246 command.stdout(Stdio::piped());
247 command.stderr(Stdio::piped());
248
249 let mut child = command.spawn().context("failed to spawn pipe process")?;
250 let pid = child.id().ok_or_else(|| io::Error::other("missing child pid"))?;
251
252 #[cfg(unix)]
253 let process_group_id = pid;
254
255 let stdin = child.stdin.take();
256 let stdout = child.stdout.take();
257 let stderr = child.stderr.take();
258
259 let (writer_tx, mut writer_rx) = mpsc::channel::<Vec<u8>>(128);
260 let (output_tx, _) = broadcast::channel::<Bytes>(256);
261 let initial_output_rx = output_tx.subscribe();
262 let (reliable_output_tx, reliable_output_rx) = mpsc::channel::<Bytes>(RELIABLE_OUTPUT_CHANNEL_CAPACITY);
263 let reliable_output_enabled = opts.lossless_output;
264
265 let writer_handle = if let Some(stdin) = stdin {
267 let writer = Arc::new(tokio::sync::Mutex::new(stdin));
268 tokio::spawn(async move {
269 while let Some(bytes) = writer_rx.recv().await {
270 let mut guard = writer.lock().await;
271 let _ = guard.write_all(&bytes).await;
272 let _ = guard.flush().await;
273 }
274 })
275 } else {
276 drop(writer_rx);
277 tokio::spawn(async {})
278 };
279
280 let stdout_handle = stdout.map(|stdout| {
282 let output_tx = output_tx.clone();
283 let reliable_output_tx = opts.lossless_output.then(|| reliable_output_tx.clone());
284 tokio::spawn(async move {
285 read_output_stream(BufReader::new(stdout), output_tx, reliable_output_tx).await;
286 })
287 });
288
289 let stderr_handle = stderr.map(|stderr| {
290 let output_tx = output_tx.clone();
291 let reliable_output_tx = opts.lossless_output.then(|| reliable_output_tx.clone());
292 tokio::spawn(async move {
293 read_output_stream(BufReader::new(stderr), output_tx, reliable_output_tx).await;
294 })
295 });
296 drop(reliable_output_tx);
297
298 let mut reader_abort_handles = Vec::new();
299 if let Some(ref handle) = stdout_handle {
300 reader_abort_handles.push(handle.abort_handle());
301 }
302 if let Some(ref handle) = stderr_handle {
303 reader_abort_handles.push(handle.abort_handle());
304 }
305
306 let reader_handle = tokio::spawn(async move {
307 if let Some(handle) = stdout_handle {
308 let _ = handle.await;
309 }
310 if let Some(handle) = stderr_handle {
311 let _ = handle.await;
312 }
313 });
314
315 let (exit_tx, exit_rx) = oneshot::channel::<i32>();
317 let exit_status = Arc::new(AtomicBool::new(false));
318 let wait_exit_status = Arc::clone(&exit_status);
319 let exit_observed = Arc::new(AtomicBool::new(false));
320 let wait_exit_observed = Arc::clone(&exit_observed);
321 let exit_code = Arc::new(StdMutex::new(None));
322 let wait_exit_code = Arc::clone(&exit_code);
323
324 let wait_handle: JoinHandle<()> = tokio::spawn(async move {
325 let code = match child.wait().await {
326 Ok(status) => {
327 wait_exit_observed.store(true, std::sync::atomic::Ordering::SeqCst);
328 status.code().unwrap_or(-1)
329 }
330 Err(_) => -1,
331 };
332 wait_exit_status.store(true, std::sync::atomic::Ordering::SeqCst);
333 if let Ok(mut guard) = wait_exit_code.lock() {
334 *guard = Some(code);
335 }
336 let _ = exit_tx.send(code);
337 });
338
339 let (handle, output_rx) = ProcessHandle::new(
340 writer_tx,
341 output_tx,
342 initial_output_rx,
343 Box::new(PipeChildTerminator {
344 #[cfg(windows)]
345 pid,
346 #[cfg(unix)]
347 process_group_id,
348 }),
349 reader_handle,
350 reader_abort_handles,
351 writer_handle,
352 wait_handle,
353 exit_status,
354 exit_observed,
355 exit_code,
356 None,
357 );
358
359 Ok(SpawnedProcess {
360 session: handle,
361 process_id: pid,
362 output_rx,
363 reliable_output_rx,
364 reliable_output_enabled,
365 exit_rx,
366 })
367}
368
369pub async fn spawn_process(
389 program: &str,
390 args: &[String],
391 cwd: &Path,
392 env: &HashMap<String, String>,
393 arg0: Option<&str>,
394) -> Result<SpawnedProcess> {
395 let opts = PipeSpawnOptions {
396 program: program.to_string(),
397 args: args.to_vec(),
398 cwd: cwd.to_path_buf(),
399 env: Some(env.clone()),
400 arg0: arg0.map(str::to_owned),
401 stdin_mode: PipeStdinMode::Piped,
402 lossless_output: false,
403 };
404 spawn_process_internal(opts).await
405}
406
407pub async fn spawn_process_no_stdin(
415 program: &str,
416 args: &[String],
417 cwd: &Path,
418 env: &HashMap<String, String>,
419 arg0: Option<&str>,
420) -> Result<SpawnedProcess> {
421 let opts = PipeSpawnOptions {
422 program: program.to_string(),
423 args: args.to_vec(),
424 cwd: cwd.to_path_buf(),
425 env: Some(env.clone()),
426 arg0: arg0.map(str::to_owned),
427 stdin_mode: PipeStdinMode::Null,
428 lossless_output: false,
429 };
430 spawn_process_internal(opts).await
431}
432
433pub async fn spawn_process_with_options(opts: PipeSpawnOptions) -> Result<SpawnedProcess> {
435 spawn_process_internal(opts).await
436}
437
438#[cfg(test)]
439mod tests {
440 use super::*;
441 use assert_fs::TempDir;
442
443 fn find_echo_command() -> Option<(String, Vec<String>)> {
444 #[cfg(windows)]
445 {
446 Some(("cmd.exe".to_string(), vec!["/C".to_string(), "echo".to_string()]))
447 }
448 #[cfg(not(windows))]
449 {
450 Some(("echo".to_string(), vec![]))
451 }
452 }
453
454 #[tokio::test]
455 async fn test_spawn_process_echo() -> Result<()> {
456 let Some((program, mut base_args)) = find_echo_command() else {
457 return Ok(());
458 };
459
460 base_args.push("hello".to_string());
461
462 let env: HashMap<String, String> = std::env::vars().collect();
463 let spawned = spawn_process(&program, &base_args, Path::new("."), &env, None).await?;
464
465 let exit_code = spawned.exit_rx.await.unwrap_or(-1);
466 assert_eq!(exit_code, 0);
467 assert!(spawned.session.has_observed_exit(), "successful wait records the direct-child exit");
468
469 Ok(())
470 }
471
472 #[tokio::test]
473 async fn test_spawn_options_builder() {
474 let opts = PipeSpawnOptions::new("echo", ".")
475 .args(["hello", "world"])
476 .stdin_mode(PipeStdinMode::Null);
477
478 assert_eq!(opts.program, "echo");
479 assert_eq!(opts.args, vec!["hello", "world"]);
480 assert!(matches!(opts.stdin_mode, PipeStdinMode::Null));
481 }
482
483 #[cfg(unix)]
484 #[tokio::test]
485 async fn test_spawn_process_detaches_from_tty() {
486 let dir = TempDir::new().expect("tempdir");
487 let env: HashMap<String, String> = HashMap::new();
488
489 let spawned = spawn_process("sh", &["-c".into(), "echo ok".into()], dir.path(), &env, None)
490 .await
491 .expect("spawn");
492
493 let exit_code = spawned.exit_rx.await.unwrap_or(-1);
494 assert_eq!(exit_code, 0);
495 }
496}