1use std::collections::HashMap;
14use std::fmt;
15use std::io::Write;
16use std::path::PathBuf;
17use std::sync::{Arc, Mutex};
18use std::time::Duration;
19
20use command_stream::{OutputChunk, StreamingRunner};
21use tokio::sync::{mpsc, watch};
22#[path = "process_cleanup.rs"]
23mod process_cleanup;
24
25pub const DEFAULT_KILL_GRACE: Duration = Duration::from_millis(2000);
28
29#[derive(Debug, Clone, PartialEq, Eq, Default)]
31pub struct CommandOutput {
32 pub stdout: String,
34 pub stderr: String,
36 pub code: i32,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum CommandError {
43 Spawn {
45 file: String,
47 args: Vec<String>,
49 message: String,
51 },
52 Exited {
54 file: String,
56 args: Vec<String>,
58 code: i32,
60 stdout: String,
62 stderr: String,
64 },
65}
66
67impl CommandError {
68 pub fn code(&self) -> Option<i32> {
70 match self {
71 Self::Spawn { .. } => None,
72 Self::Exited { code, .. } => Some(*code),
73 }
74 }
75
76 pub fn file(&self) -> &str {
78 match self {
79 Self::Spawn { file, .. } | Self::Exited { file, .. } => file,
80 }
81 }
82}
83
84impl fmt::Display for CommandError {
85 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
86 match self {
87 Self::Spawn { file, message, .. } => {
88 write!(formatter, "Could not start {file}: {message}")
89 }
90 Self::Exited {
91 file, code, stderr, ..
92 } => {
93 let stderr = stderr.trim();
94 if stderr.is_empty() {
95 write!(formatter, "{file} exited with code {code}")
96 } else {
97 write!(formatter, "{file} exited with code {code}: {stderr}")
98 }
99 }
100 }
101 }
102}
103
104impl std::error::Error for CommandError {}
105
106#[derive(Debug, Clone)]
108pub struct RunCommandOptions {
109 pub env: Option<HashMap<String, String>>,
112 pub cwd: Option<PathBuf>,
114 pub input: Option<String>,
116 pub check: bool,
118}
119
120impl Default for RunCommandOptions {
121 fn default() -> Self {
122 Self {
123 env: None,
124 cwd: None,
125 input: None,
126 check: true,
127 }
128 }
129}
130
131fn owned_args<S: AsRef<str>>(args: &[S]) -> Vec<String> {
132 args.iter().map(|arg| arg.as_ref().to_owned()).collect()
133}
134
135fn runner_for(
136 file: &str,
137 args: &[String],
138 env: Option<HashMap<String, String>>,
139 cwd: Option<PathBuf>,
140) -> StreamingRunner {
141 let mut runner = StreamingRunner::from_argv(file, args);
142 if let Some(env) = env {
143 runner = runner.env(env);
144 }
145 if let Some(cwd) = cwd {
146 runner = runner.cwd(cwd);
147 }
148 runner
149}
150
151fn finish(
152 file: &str,
153 args: Vec<String>,
154 check: bool,
155 result: command_stream::Result<command_stream::CommandResult>,
156) -> Result<CommandOutput, CommandError> {
157 let result = result.map_err(|error| CommandError::Spawn {
158 file: file.to_owned(),
159 args: args.clone(),
160 message: match error {
161 command_stream::Error::Io(io) => io.to_string(),
162 other => other.to_string(),
163 },
164 })?;
165 let output = CommandOutput {
166 stdout: result.stdout.to_string(),
167 stderr: result.stderr.to_string(),
168 code: result.code,
169 };
170 if check && output.code != 0 {
171 return Err(CommandError::Exited {
172 file: file.to_owned(),
173 args,
174 code: output.code,
175 stdout: output.stdout,
176 stderr: output.stderr,
177 });
178 }
179 Ok(output)
180}
181
182pub async fn run_command<S: AsRef<str>>(
187 file: &str,
188 args: &[S],
189 options: RunCommandOptions,
190) -> Result<CommandOutput, CommandError> {
191 let args = owned_args(args);
192 let mut runner = runner_for(file, &args, options.env, options.cwd);
193 if let Some(input) = options.input {
194 runner = runner.stdin(input);
195 }
196 let result = runner.collect().await;
197 finish(file, args, options.check, result)
198}
199
200pub fn run_command_blocking<S: AsRef<str>>(
207 file: &str,
208 args: &[S],
209 options: RunCommandOptions,
210) -> Result<CommandOutput, CommandError> {
211 let args = owned_args(args);
212 let mut runner = runner_for(file, &args, options.env, options.cwd);
213 if let Some(input) = options.input {
214 runner = runner.stdin(input);
215 }
216 let result = if tokio::runtime::Handle::try_current().is_ok() {
217 std::thread::spawn(move || runner.collect_blocking())
218 .join()
219 .unwrap_or_else(|_| {
220 Err(std::io::Error::other("the command collector thread panicked").into())
221 })
222 } else {
223 runner.collect_blocking()
224 };
225 finish(file, args, options.check, result)
226}
227
228pub type OutputListener = Arc<dyn Fn(&[u8]) + Send + Sync>;
231
232#[derive(Clone)]
234pub struct StartProcessOptions {
235 pub env: Option<HashMap<String, String>>,
238 pub cwd: Option<PathBuf>,
240 pub forward_output: bool,
242 pub kill_grace: Duration,
244 pub on_stdout: Vec<OutputListener>,
246 pub on_stderr: Vec<OutputListener>,
249}
250
251impl Default for StartProcessOptions {
252 fn default() -> Self {
253 Self {
254 env: None,
255 cwd: None,
256 forward_output: false,
257 kill_grace: DEFAULT_KILL_GRACE,
258 on_stdout: Vec::new(),
259 on_stderr: Vec::new(),
260 }
261 }
262}
263
264impl fmt::Debug for StartProcessOptions {
265 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
266 formatter
267 .debug_struct("StartProcessOptions")
268 .field("env", &self.env)
269 .field("cwd", &self.cwd)
270 .field("forward_output", &self.forward_output)
271 .field("kill_grace", &self.kill_grace)
272 .field("on_stdout", &self.on_stdout.len())
273 .field("on_stderr", &self.on_stderr.len())
274 .finish()
275 }
276}
277
278type ListenerList = Arc<Mutex<Vec<OutputListener>>>;
279
280fn emit(listeners: &ListenerList, chunk: &[u8]) {
281 let listeners = listeners
282 .lock()
283 .map(|listeners| listeners.clone())
284 .unwrap_or_default();
285 for listener in listeners {
286 listener(chunk);
287 }
288}
289
290pub struct ManagedProcess {
296 file: String,
297 pid: Option<u32>,
298 exit: watch::Receiver<Option<i32>>,
299 kill_requests: mpsc::UnboundedSender<String>,
300 stdout: ListenerList,
301 stderr: ListenerList,
302}
303
304impl fmt::Debug for ManagedProcess {
305 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
306 formatter
307 .debug_struct("ManagedProcess")
308 .field("file", &self.file)
309 .field("pid", &self.pid)
310 .field("exit_code", &self.exit_code())
311 .finish()
312 }
313}
314
315impl ManagedProcess {
316 pub fn pid(&self) -> Option<u32> {
318 self.pid
319 }
320
321 pub fn exit_code(&self) -> Option<i32> {
323 *self.exit.borrow()
324 }
325
326 pub fn is_running(&self) -> bool {
328 self.exit_code().is_none()
329 }
330
331 pub fn on_stdout(&self, listener: OutputListener) {
334 if let Ok(mut listeners) = self.stdout.lock() {
335 listeners.push(listener);
336 }
337 }
338
339 pub fn on_stderr(&self, listener: OutputListener) {
342 if let Ok(mut listeners) = self.stderr.lock() {
343 listeners.push(listener);
344 }
345 }
346
347 pub fn kill(&self) -> bool {
351 self.kill_with("SIGTERM")
352 }
353
354 pub fn kill_with(&self, signal: &str) -> bool {
357 if !self.is_running() {
358 return false;
359 }
360 self.kill_requests.send(signal.to_owned()).is_ok()
361 }
362
363 pub async fn wait(&self) -> i32 {
365 let mut exit = self.exit.clone();
366 let code = match exit.wait_for(Option::is_some).await {
367 Ok(code) => code.unwrap_or(1),
368 Err(_) => self.exit_code().unwrap_or(1),
369 };
370 code
371 }
372
373 pub async fn wait_timeout(&self, timeout: Duration) -> Option<i32> {
376 tokio::time::timeout(timeout, self.wait()).await.ok()
377 }
378
379 pub fn exited(&self) -> impl std::future::Future<Output = i32> + Send + 'static {
385 let mut exit = self.exit.clone();
386 async move {
387 let waited = exit.wait_for(Option::is_some).await.map(|code| *code);
388 match waited {
389 Ok(code) => code.unwrap_or(1),
390 Err(_) => exit.borrow().unwrap_or(1),
391 }
392 }
393 }
394}
395
396pub(crate) fn kill_owned_process_tree(pid: u32) {
400 process_cleanup::kill_owned_process_tree(pid);
401}
402
403impl Drop for ManagedProcess {
404 fn drop(&mut self) {
405 if self.is_running() {
410 if let Some(pid) = self.pid {
411 process_cleanup::kill_owned_process_tree(pid);
412 }
413 self.kill();
414 }
415 }
416}
417
418pub async fn start_process<S: AsRef<str>>(
424 file: &str,
425 args: &[S],
426 options: StartProcessOptions,
427) -> Result<ManagedProcess, CommandError> {
428 let args = owned_args(args);
429 let kill_grace_ms = u64::try_from(options.kill_grace.as_millis()).unwrap_or(u64::MAX);
430 let mut stream = runner_for(file, &args, options.env, options.cwd)
431 .kill_grace_ms(kill_grace_ms)
432 .stream();
433 let Some(pid) = stream.wait_for_pid().await else {
434 let message =
438 if PathBuf::from(file).components().count() > 1 && !PathBuf::from(file).exists() {
439 "no such file".to_owned()
440 } else {
441 "the executable could not be spawned (missing or not executable)".to_owned()
442 };
443 return Err(CommandError::Spawn {
444 file: file.to_owned(),
445 args,
446 message,
447 });
448 };
449
450 let stdout: ListenerList = Arc::new(Mutex::new(options.on_stdout));
451 let stderr: ListenerList = Arc::new(Mutex::new(options.on_stderr));
452 let (exit_tx, exit_rx) = watch::channel(None);
453 let (kill_tx, mut kill_rx) = mpsc::unbounded_channel::<String>();
454 let forward = options.forward_output;
455 let pump_stdout = Arc::clone(&stdout);
456 let pump_stderr = Arc::clone(&stderr);
457
458 tokio::spawn(async move {
459 let mut code = None;
460 let mut kill_open = true;
461 loop {
462 tokio::select! {
463 chunk = stream.next() => match chunk {
464 Some(OutputChunk::Stdout(data)) => {
465 if forward {
466 let _ = std::io::stdout().write_all(&data);
467 }
468 emit(&pump_stdout, &data);
469 }
470 Some(OutputChunk::Stderr(data)) => {
471 if forward {
472 let _ = std::io::stderr().write_all(&data);
473 }
474 emit(&pump_stderr, &data);
475 }
476 Some(OutputChunk::Exit(exit_code)) => code = Some(exit_code),
477 None => break,
478 },
479 signal = kill_rx.recv(), if kill_open => match signal {
480 Some(signal) => stream.kill_with(&signal),
481 None => kill_open = false,
482 },
483 }
484 }
485 let _ = exit_tx.send(Some(code.unwrap_or(1)));
486 });
487
488 Ok(ManagedProcess {
489 file: file.to_owned(),
490 pid: Some(pid),
491 exit: exit_rx,
492 kill_requests: kill_tx,
493 stdout,
494 stderr,
495 })
496}
497
498#[cfg(all(test, unix))]
499mod tests {
500 use super::*;
501
502 #[tokio::test]
503 async fn run_command_passes_exact_argv_without_a_shell() {
504 let output = run_command(
505 "printf",
506 &["%s|", "a b", "$HOME", "'quoted'"],
507 RunCommandOptions::default(),
508 )
509 .await
510 .unwrap();
511 assert_eq!(output.stdout, "a b|$HOME|'quoted'|");
512 assert_eq!(output.code, 0);
513 }
514
515 #[tokio::test]
516 async fn run_command_reports_non_zero_exits() {
517 let error = run_command(
518 "sh",
519 &["-c", "echo nope >&2; exit 3"],
520 RunCommandOptions::default(),
521 )
522 .await
523 .unwrap_err();
524 assert_eq!(error.code(), Some(3));
525 assert_eq!(error.to_string(), "sh exited with code 3: nope");
526
527 let unchecked = run_command(
528 "sh",
529 &["-c", "exit 4"],
530 RunCommandOptions {
531 check: false,
532 ..RunCommandOptions::default()
533 },
534 )
535 .await
536 .unwrap();
537 assert_eq!(unchecked.code, 4);
538 }
539
540 #[tokio::test]
541 async fn run_command_reports_missing_executables() {
542 let error = run_command(
543 "browser-commander-definitely-missing",
544 &[] as &[&str],
545 RunCommandOptions::default(),
546 )
547 .await
548 .unwrap_err();
549 assert!(matches!(error, CommandError::Spawn { .. }));
550 assert_eq!(error.code(), None);
551 }
552
553 #[tokio::test]
554 async fn run_command_sets_env_and_input_for_the_child_only() {
555 let output = run_command(
556 "sh",
557 &["-c", "printf '%s:' \"$BC_SUBPROCESS_TEST\"; cat"],
558 RunCommandOptions {
559 env: Some(HashMap::from([(
560 "BC_SUBPROCESS_TEST".to_owned(),
561 "child".to_owned(),
562 )])),
563 input: Some("stdin".to_owned()),
564 ..RunCommandOptions::default()
565 },
566 )
567 .await
568 .unwrap();
569 assert_eq!(output.stdout, "child:stdin");
570 assert!(std::env::var_os("BC_SUBPROCESS_TEST").is_none());
571 }
572
573 #[tokio::test]
574 async fn run_command_blocking_works_inside_and_outside_a_runtime() {
575 let inside = run_command_blocking("echo", &["inside"], RunCommandOptions::default());
576 assert_eq!(inside.unwrap().stdout, "inside\n");
577 let outside = std::thread::spawn(|| {
578 run_command_blocking("echo", &["outside"], RunCommandOptions::default())
579 })
580 .join()
581 .unwrap();
582 assert_eq!(outside.unwrap().stdout, "outside\n");
583 }
584
585 #[tokio::test]
586 async fn start_process_streams_stderr_and_reports_the_exit() {
587 let seen = Arc::new(Mutex::new(Vec::new()));
588 let sink = Arc::clone(&seen);
589 let process = start_process(
590 "sh",
591 &["-c", "echo ready >&2; exit 5"],
592 StartProcessOptions {
593 on_stderr: vec![Arc::new(move |chunk: &[u8]| {
594 sink.lock().unwrap().extend_from_slice(chunk);
595 })],
596 ..StartProcessOptions::default()
597 },
598 )
599 .await
600 .unwrap();
601 assert!(process.pid().is_some());
602 assert_eq!(process.wait().await, 5);
603 assert_eq!(process.exit_code(), Some(5));
604 assert!(!process.kill());
605 assert_eq!(seen.lock().unwrap().as_slice(), b"ready\n");
606 }
607
608 #[tokio::test]
609 async fn start_process_kills_with_grace() {
610 let process = start_process(
611 "sleep",
612 &["30"],
613 StartProcessOptions {
614 kill_grace: Duration::from_millis(200),
615 ..StartProcessOptions::default()
616 },
617 )
618 .await
619 .unwrap();
620 assert_eq!(process.wait_timeout(Duration::from_millis(100)).await, None);
621 assert!(process.kill());
622 let code = process.wait_timeout(Duration::from_secs(5)).await;
623 assert_eq!(code, Some(128 + 15));
624 }
625
626 #[tokio::test]
627 async fn start_process_reports_spawn_failures() {
628 let error = start_process(
629 "/nonexistent/browser-commander-missing",
630 &[] as &[&str],
631 StartProcessOptions::default(),
632 )
633 .await
634 .unwrap_err();
635 assert!(matches!(error, CommandError::Spawn { .. }));
636 }
637}