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