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
23/// Milliseconds between the polite signal and `SIGKILL` when a process started
24/// by [`start_process`] is stopped, matching the JavaScript `killGrace` default.
25pub const DEFAULT_KILL_GRACE: Duration = Duration::from_millis(2000);
26
27/// Output of a command run to completion by [`run_command`].
28#[derive(Debug, Clone, PartialEq, Eq, Default)]
29pub struct CommandOutput {
30    /// Everything the command wrote to stdout, decoded lossily as UTF-8.
31    pub stdout: String,
32    /// Everything the command wrote to stderr, decoded lossily as UTF-8.
33    pub stderr: String,
34    /// Exit code; a signal exit is reported as `128 + signal`.
35    pub code: i32,
36}
37
38/// Failure to run a command.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum CommandError {
41    /// The executable could not be started at all (for example it is missing).
42    Spawn {
43        /// Executable that was requested.
44        file: String,
45        /// Exact arguments that were requested.
46        args: Vec<String>,
47        /// Why the spawn failed.
48        message: String,
49    },
50    /// The command ran and exited with a non-zero status while `check` was on.
51    Exited {
52        /// Executable that ran.
53        file: String,
54        /// Exact arguments it ran with.
55        args: Vec<String>,
56        /// Its exit code.
57        code: i32,
58        /// Its captured stdout.
59        stdout: String,
60        /// Its captured stderr.
61        stderr: String,
62    },
63}
64
65impl CommandError {
66    /// Exit code of the command, when it ran at all.
67    pub fn code(&self) -> Option<i32> {
68        match self {
69            Self::Spawn { .. } => None,
70            Self::Exited { code, .. } => Some(*code),
71        }
72    }
73
74    /// Executable the error is about.
75    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/// Options for [`run_command`].
105#[derive(Debug, Clone)]
106pub struct RunCommandOptions {
107    /// Extra environment for the child only; the parent's environment is never
108    /// modified.
109    pub env: Option<HashMap<String, String>>,
110    /// Working directory.
111    pub cwd: Option<PathBuf>,
112    /// Data written to the child's stdin; stdin is closed (null) otherwise.
113    pub input: Option<String>,
114    /// Return [`CommandError::Exited`] on a non-zero exit (default `true`).
115    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
180/// Run a command to completion and return its output.
181///
182/// `file` is resolved through `PATH` and receives exactly `args`; no shell is
183/// involved.
184pub 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
198/// Synchronous [`run_command`] for code that is not async, such as the
199/// credential-store readers.
200///
201/// command-stream's blocking collector refuses to run inside a Tokio runtime,
202/// so when one is active the command is collected on a short-lived thread of
203/// its own instead.
204pub 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
226/// Callback receiving raw output chunks of a process started by
227/// [`start_process`].
228pub type OutputListener = Arc<dyn Fn(&[u8]) + Send + Sync>;
229
230/// Options for [`start_process`].
231#[derive(Clone)]
232pub struct StartProcessOptions {
233    /// Extra environment for the child only; the parent's environment is never
234    /// modified.
235    pub env: Option<HashMap<String, String>>,
236    /// Working directory.
237    pub cwd: Option<PathBuf>,
238    /// Mirror the child's output to this process's stdout/stderr.
239    pub forward_output: bool,
240    /// Time between the polite signal and `SIGKILL` when the process is stopped.
241    pub kill_grace: Duration,
242    /// Listeners registered before the spawn, so no early output is missed.
243    pub on_stdout: Vec<OutputListener>,
244    /// Listeners registered before the spawn, so no early output is missed -
245    /// used to watch for the `DevTools listening on ...` line.
246    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
288/// Handle for a long-running process started by [`start_process`], such as a
289/// browser.
290///
291/// Dropping the handle asks command-stream to stop the process; call
292/// [`kill`](Self::kill) and [`wait`](Self::wait) for deterministic cleanup.
293pub 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    /// Operating system process id.
315    pub fn pid(&self) -> Option<u32> {
316        self.pid
317    }
318
319    /// Exit code once the process has exited (`128 + signal` for a signal).
320    pub fn exit_code(&self) -> Option<i32> {
321        *self.exit.borrow()
322    }
323
324    /// Whether the process is still running.
325    pub fn is_running(&self) -> bool {
326        self.exit_code().is_none()
327    }
328
329    /// Register another stdout listener. Output already delivered is not
330    /// replayed; pass listeners in [`StartProcessOptions`] to see everything.
331    pub fn on_stdout(&self, listener: OutputListener) {
332        if let Ok(mut listeners) = self.stdout.lock() {
333            listeners.push(listener);
334        }
335    }
336
337    /// Register another stderr listener. Output already delivered is not
338    /// replayed; pass listeners in [`StartProcessOptions`] to see everything.
339    pub fn on_stderr(&self, listener: OutputListener) {
340        if let Ok(mut listeners) = self.stderr.lock() {
341            listeners.push(listener);
342        }
343    }
344
345    /// Stop the process with `SIGTERM`, then `SIGKILL` after the grace period.
346    ///
347    /// Returns whether a running process was signalled.
348    pub fn kill(&self) -> bool {
349        self.kill_with("SIGTERM")
350    }
351
352    /// Stop the process with an explicit signal name, then `SIGKILL` after the
353    /// grace period. Returns whether a running process was signalled.
354    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    /// Wait for the process to exit and return its exit code.
362    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    /// Wait up to `timeout` for the process to exit; `None` if it is still
372    /// running afterwards.
373    pub async fn wait_timeout(&self, timeout: Duration) -> Option<i32> {
374        tokio::time::timeout(timeout, self.wait()).await.ok()
375    }
376
377    /// A future that resolves with the exit code once the process exits.
378    ///
379    /// Unlike [`wait`](Self::wait) it does not borrow the handle, so it can be
380    /// moved into a task (the launcher uses it to delete a temporary profile
381    /// as soon as the browser is gone).
382    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        // Matches the JavaScript cleanup: an abandoned handle must not leave the
397        // process running. command-stream's pump task performs the kill.
398        self.kill();
399    }
400}
401
402/// Start a long-running process, such as a browser.
403///
404/// `file` receives exactly `args`, without a shell. The process's output is
405/// drained continuously and delivered to the listeners in `options`. Must be
406/// called inside a Tokio runtime.
407pub 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        // command-stream reports a failed spawn by never publishing a pid; the
419        // underlying io::Error stays inside its task, so describe the common
420        // cause ourselves.
421        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}