Skip to main content

browser_commander/utilities/
subprocess.rs

1//! Subprocesses through command-stream (issue #104).
2//!
3//! Every subprocess Browser Commander starts - the real browser, the credential
4//! tools (`security`, `secret-tool`, `kwallet-query`, `powershell.exe`,
5//! `icacls`, `whoami`) and anything else run to completion - goes through the
6//! [`command_stream`] crate. Its argv mode runs the file directly with exact
7//! argument boundaries, no shell in between, and gives one consistent surface
8//! for streaming output, cancellation and exit codes. This module mirrors
9//! `js/src/utilities/subprocess.js` and the Python `subprocess` wrapper: the
10//! same two functions, [`run_command`] and [`start_process`], with snake_case
11//! names.
12
13use 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
25/// Milliseconds between the polite signal and `SIGKILL` when a process started
26/// by [`start_process`] is stopped, matching the JavaScript `killGrace` default.
27pub const DEFAULT_KILL_GRACE: Duration = Duration::from_millis(2000);
28
29/// Output of a command run to completion by [`run_command`].
30#[derive(Debug, Clone, PartialEq, Eq, Default)]
31pub struct CommandOutput {
32    /// Everything the command wrote to stdout, decoded lossily as UTF-8.
33    pub stdout: String,
34    /// Everything the command wrote to stderr, decoded lossily as UTF-8.
35    pub stderr: String,
36    /// Exit code; a signal exit is reported as `128 + signal`.
37    pub code: i32,
38}
39
40/// Failure to run a command.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum CommandError {
43    /// The executable could not be started at all (for example it is missing).
44    Spawn {
45        /// Executable that was requested.
46        file: String,
47        /// Exact arguments that were requested.
48        args: Vec<String>,
49        /// Why the spawn failed.
50        message: String,
51    },
52    /// The command ran and exited with a non-zero status while `check` was on.
53    Exited {
54        /// Executable that ran.
55        file: String,
56        /// Exact arguments it ran with.
57        args: Vec<String>,
58        /// Its exit code.
59        code: i32,
60        /// Its captured stdout.
61        stdout: String,
62        /// Its captured stderr.
63        stderr: String,
64    },
65}
66
67impl CommandError {
68    /// Exit code of the command, when it ran at all.
69    pub fn code(&self) -> Option<i32> {
70        match self {
71            Self::Spawn { .. } => None,
72            Self::Exited { code, .. } => Some(*code),
73        }
74    }
75
76    /// Executable the error is about.
77    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/// Options for [`run_command`].
107#[derive(Debug, Clone)]
108pub struct RunCommandOptions {
109    /// Extra environment for the child only; the parent's environment is never
110    /// modified.
111    pub env: Option<HashMap<String, String>>,
112    /// Working directory.
113    pub cwd: Option<PathBuf>,
114    /// Data written to the child's stdin; stdin is closed (null) otherwise.
115    pub input: Option<String>,
116    /// Return [`CommandError::Exited`] on a non-zero exit (default `true`).
117    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
182/// Run a command to completion and return its output.
183///
184/// `file` is resolved through `PATH` and receives exactly `args`; no shell is
185/// involved.
186pub 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
200/// Synchronous [`run_command`] for code that is not async, such as the
201/// credential-store readers.
202///
203/// command-stream's blocking collector refuses to run inside a Tokio runtime,
204/// so when one is active the command is collected on a short-lived thread of
205/// its own instead.
206pub 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
228/// Callback receiving raw output chunks of a process started by
229/// [`start_process`].
230pub type OutputListener = Arc<dyn Fn(&[u8]) + Send + Sync>;
231
232/// Options for [`start_process`].
233#[derive(Clone)]
234pub struct StartProcessOptions {
235    /// Extra environment for the child only; the parent's environment is never
236    /// modified.
237    pub env: Option<HashMap<String, String>>,
238    /// Working directory.
239    pub cwd: Option<PathBuf>,
240    /// Mirror the child's output to this process's stdout/stderr.
241    pub forward_output: bool,
242    /// Time between the polite signal and `SIGKILL` when the process is stopped.
243    pub kill_grace: Duration,
244    /// Listeners registered before the spawn, so no early output is missed.
245    pub on_stdout: Vec<OutputListener>,
246    /// Listeners registered before the spawn, so no early output is missed -
247    /// used to watch for the `DevTools listening on ...` line.
248    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
290/// Handle for a long-running process started by [`start_process`], such as a
291/// browser.
292///
293/// Dropping the handle asks command-stream to stop the process; call
294/// [`kill`](Self::kill) and [`wait`](Self::wait) for deterministic cleanup.
295pub 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    /// Operating system process id.
317    pub fn pid(&self) -> Option<u32> {
318        self.pid
319    }
320
321    /// Exit code once the process has exited (`128 + signal` for a signal).
322    pub fn exit_code(&self) -> Option<i32> {
323        *self.exit.borrow()
324    }
325
326    /// Whether the process is still running.
327    pub fn is_running(&self) -> bool {
328        self.exit_code().is_none()
329    }
330
331    /// Register another stdout listener. Output already delivered is not
332    /// replayed; pass listeners in [`StartProcessOptions`] to see everything.
333    pub fn on_stdout(&self, listener: OutputListener) {
334        if let Ok(mut listeners) = self.stdout.lock() {
335            listeners.push(listener);
336        }
337    }
338
339    /// Register another stderr listener. Output already delivered is not
340    /// replayed; pass listeners in [`StartProcessOptions`] to see everything.
341    pub fn on_stderr(&self, listener: OutputListener) {
342        if let Ok(mut listeners) = self.stderr.lock() {
343            listeners.push(listener);
344        }
345    }
346
347    /// Stop the process with `SIGTERM`, then `SIGKILL` after the grace period.
348    ///
349    /// Returns whether a running process was signalled.
350    pub fn kill(&self) -> bool {
351        self.kill_with("SIGTERM")
352    }
353
354    /// Stop the process with an explicit signal name, then `SIGKILL` after the
355    /// grace period. Returns whether a running process was signalled.
356    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    /// Wait for the process to exit and return its exit code.
364    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    /// Wait up to `timeout` for the process to exit; `None` if it is still
374    /// running afterwards.
375    pub async fn wait_timeout(&self, timeout: Duration) -> Option<i32> {
376        tokio::time::timeout(timeout, self.wait()).await.ok()
377    }
378
379    /// A future that resolves with the exit code once the process exits.
380    ///
381    /// Unlike [`wait`](Self::wait) it does not borrow the handle, so it can be
382    /// moved into a task (the launcher uses it to delete a temporary profile
383    /// as soon as the browser is gone).
384    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
396/// Kill a command-stream child and every process in its group (Unix) or tree
397/// (Windows), synchronously. For owners of a raw command-stream
398/// `ProcessRunner`, such as the Playwright driver pipe.
399pub(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        // Drop can run after its Tokio runtime has shut down. An async kill
406        // request alone can then never reach command-stream's pump. Explicit
407        // close/kill keeps the grace period; final abandoned ownership is killed
408        // synchronously so browser and driver children cannot escape.
409        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
418/// Start a long-running process, such as a browser.
419///
420/// `file` receives exactly `args`, without a shell. The process's output is
421/// drained continuously and delivered to the listeners in `options`. Must be
422/// called inside a Tokio runtime.
423pub 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        // command-stream reports a failed spawn by never publishing a pid; the
435        // underlying io::Error stays inside its task, so describe the common
436        // cause ourselves.
437        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}