Best error handing practices when using `std::process::Command`?

Hey, how do people do error handling when executing external commands? Specifically, how people get a useful error message to show to the user?

Say, I have something like Command::new("ls").arg("-al").output(). I need to handle:

  • io::Error from output (using something like .context("ls -all failed"), but I don't want to repeat command name)
  • non success status (which I need to explicitly check for with if)
  • (sometimes) UTF8 decoding error when reading stdout

Is there perhaps some short idiom I am missing, which allows me to handle of the failure modes once, while producing a nice error message with the name of command and the list of arguments?

EDIT: cc #72009

2 Likes

I don't know if there's a best practice, but this is indeed kind of tricky and I'm not sure there is a succinct way to do it. In particular, one thing that isn't on your list that I think should be is the stderr of the command being executed. You could perhaps ignore that and just let the command dump its stderr contents to the user's tty, but I've found that to be unfriendly. Instead, I wrote a fairly small adapter to handle all this stuff for me: https://docs.rs/grep-cli/0.1.4/grep_cli/struct.CommandReader.html

When an error occurs, the caller can tag it with the debug representation of the command, which I think provides sufficient context. (Kindly ignore the io::Error kludging. I haven't migrated ripgrep to anyhow yet.)

Note that in my case, I don't need to deal with valid UTF-8, since I explicitly want to allow invalid UTF-8. If I did need to do that, then I'd just do String::from_utf8 and deal with the error there. If I needed to stream valid UTF-8, then I might use encoding_rs_io, but that doesn't report errors. Instead, it just substitutes the Unicode replacement codepoint for invalid bytes.

Here is what it looks like:

$ rg --pre 'wat foo bar baz' nada crates/core/main.rs
crates/core/main.rs: preprocessor command could not start: '"wat foo bar baz" "crates/core/main.rs"': No such file or directory (os error 2)

$ rg --pre $(pwd)/always-fails nada crates/core/main.rs
crates/core/main.rs: preprocessor command failed: '"/home/andrew/rust/ripgrep/always-fails" "crates/core/main.rs"':
-------------------------------------------------------------------------------
this is an error message from the command on stderr
-------------------------------------------------------------------------------

$ cat always-fails
#!/bin/sh

echo "this is an error message from the command on stderr" >&2
exit 1
1 Like

Riiight, properly catching stderr is another can of worms, because one needs to either:

  • hope that the command won't deadlock due to printing too much to stderr
  • spawn a thread to drain stderr
  • copy-paste read2 function from Cargo to drain stderr without extra threads

The wait_with_output() in std::process already uses a similar read2 implementation to read both stdout and stderr without risking deadlock.

Note: at the cost of buffering all of stdout into memory. That's why I didn't use that method in ripgrep.

Sure, but the example at the top of this thread is using Command::output, so it's doing that already.

I usually use the Command's Debug representation because it'll show you the original command and all its arguments. Everything has loads of quotes, but something like whatever.with_context(|| format!("Command failed with exit code {}: {:?}", exit_code, cmd) works pretty well.

Ewww.... I guess you could buffer the entire stderr stream and wait til the end to see whether you need to catch it? but that's a pretty poor user experience (e.g. users won't see progress for long running commands until the end), and unless you're redirecting it to a temporary file you may have issues with filling up pipes.

Spawning threads is equally as messy because you've added a concurrency element. Although I guess you could skip avoid that by Command::spawn()ing the child process in the background and having your main thread continually read stderr or stdout in a loop?

If it helps, I've got a small program which automates the build/release process for a thing at work and this is the code related to executing commands:

// src/utils.rs

use anyhow::Error;
use std::process::{Command, ExitStatus};

/// Execute a command.
pub fn execute(mut cmd: Command, env: &Environment) -> Result<(), Error> {
    log::debug!("Executing {:?}", cmd);

    if !env.dry_run {
        let exit_status = cmd
            .status()
            .with_context(|| format!("Unable to invoke {:?}", cmd))?;

        check_exit_status(&cmd, exit_status)?;
    }

    Ok(())
}

pub fn check_exit_status(
    cmd: &Command,
    exit_status: ExitStatus,
) -> Result<(), Error> {
    if exit_status.success() {
        Ok(())
    } else {
        Err(Error::from(ExecutionError {
            command: format!("{:?}", cmd),
            exit_code: exit_status.code(),
        }))
    }
}

/// Execution of a [`Command`] finished with an unsuccessful error code.
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
#[error("Received an exit code of {} from {}", exit_code.unwrap_or(1), command)]
pub struct ExecutionError {
    pub command: String,
    pub exit_code: Option<i32>,
}

It's a lot simpler in that we pass stderr and stdout through to the attached TTY so users can see the output of each build step as they happen.

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.