Skip to content

Commit 218ed0e

Browse files
committed
utils: Capture stderr, add async
We want to capture stderr by default in these methods so we provide useful errors. Also add an async variant. Signed-off-by: Colin Walters <[email protected]>
1 parent b50bc7b commit 218ed0e

File tree

1 file changed

+101
-9
lines changed

1 file changed

+101
-9
lines changed

lib/src/utils.rs

Lines changed: 101 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use std::future::Future;
2-
use std::io::Write;
2+
use std::io::{Read, Seek, Write};
33
use std::os::fd::BorrowedFd;
44
use std::process::Command;
55
use std::time::Duration;
@@ -15,17 +15,72 @@ pub(crate) trait CommandRunExt {
1515
fn run(&mut self) -> Result<()>;
1616
}
1717

18+
/// Somewhat like String::from_utf8_lossy() but just ignores
19+
/// invalid UTF-8 instead of inserting the replacement character,
20+
/// as otherwise we may end up with that at the start of a string.
21+
fn bytes_to_utf8_ignore_invalid(v: &[u8]) -> String {
22+
let mut s = String::new();
23+
for chunk in v.utf8_chunks() {
24+
s.push_str(chunk.valid());
25+
}
26+
s
27+
}
28+
29+
/// If the exit status signals it was not successful, return an error.
30+
/// Note that we intentionally *don't* include the command string
31+
/// in the output; we leave it to the caller to add that if they want,
32+
/// as it may be verbose.
33+
fn exit_with_stderr(st: std::process::ExitStatus, mut stderr: std::fs::File) -> Result<()> {
34+
if st.success() {
35+
return Ok(());
36+
}
37+
// u16 since we truncate to just the trailing bytes here
38+
// to avoid pathological error messages
39+
const MAX_STDERR_BYTES: u16 = 1024;
40+
let size = stderr
41+
.metadata()
42+
.inspect_err(|e| tracing::warn!("failed to fstat: {e}"))
43+
.map(|m| m.len().try_into().unwrap_or(u16::MAX))
44+
.unwrap_or(0);
45+
let size = size.min(MAX_STDERR_BYTES);
46+
let seek_offset = -(size as i32);
47+
let mut stderr_buf = Vec::with_capacity(size.into());
48+
// We should never fail to seek()+read() really, but let's be conservative
49+
let stderr_buf = match stderr
50+
.seek(std::io::SeekFrom::End(seek_offset.into()))
51+
.and_then(|_| stderr.read_to_end(&mut stderr_buf))
52+
{
53+
Ok(_) => bytes_to_utf8_ignore_invalid(&stderr_buf),
54+
Err(e) => {
55+
tracing::warn!("failed seek+read: {e}");
56+
"<failed to read stderr>".into()
57+
}
58+
};
59+
anyhow::bail!(format!("Subprocess failed: {st:?}\n{stderr_buf}"))
60+
}
61+
1862
impl CommandRunExt for Command {
1963
/// Synchronously execute the child, and return an error if the child exited unsuccessfully.
2064
fn run(&mut self) -> Result<()> {
21-
let st = self.status()?;
22-
if !st.success() {
23-
// Note that we intentionally *don't* include the command string
24-
// in the output; we leave it to the caller to add that if they want,
25-
// as it may be verbose.
26-
anyhow::bail!(format!("Subprocess failed: {st:?}"))
27-
}
28-
Ok(())
65+
let stderr = tempfile::tempfile()?;
66+
self.stderr(stderr.try_clone()?);
67+
exit_with_stderr(self.status()?, stderr)
68+
}
69+
}
70+
71+
/// Helpers intended for [`tokio::process::Command`].
72+
#[allow(dead_code)]
73+
pub(crate) trait AsyncCommandRunExt {
74+
async fn run(&mut self) -> Result<()>;
75+
}
76+
77+
impl AsyncCommandRunExt for tokio::process::Command {
78+
/// Asynchronously execute the child, and return an error if the child exited unsuccessfully.
79+
///
80+
async fn run(&mut self) -> Result<()> {
81+
let stderr = tempfile::tempfile()?;
82+
self.stderr(stderr.try_clone()?);
83+
exit_with_stderr(self.status().await?, stderr)
2984
}
3085
}
3186

@@ -212,6 +267,43 @@ fn test_sigpolicy_from_opts() {
212267

213268
#[test]
214269
fn command_run_ext() {
270+
// The basics
215271
Command::new("true").run().unwrap();
216272
assert!(Command::new("false").run().is_err());
273+
274+
// Verify we capture stderr
275+
let e = Command::new("/bin/sh")
276+
.args(["-c", "echo expected-this-oops-message 1>&2; exit 1"])
277+
.run()
278+
.err()
279+
.unwrap();
280+
similar_asserts::assert_eq!(
281+
e.to_string(),
282+
"Subprocess failed: ExitStatus(unix_wait_status(256))\nexpected-this-oops-message\n"
283+
);
284+
285+
// Ignoring invalid UTF-8
286+
let e = Command::new("/bin/sh")
287+
.args([
288+
"-c",
289+
r"echo -e 'expected\xf5\x80\x80\x80\x80-foo\xc0bar\xc0\xc0' 1>&2; exit 1",
290+
])
291+
.run()
292+
.err()
293+
.unwrap();
294+
similar_asserts::assert_eq!(
295+
e.to_string(),
296+
"Subprocess failed: ExitStatus(unix_wait_status(256))\nexpected-foobar\n"
297+
);
298+
}
299+
300+
#[tokio::test]
301+
async fn async_command_run_ext() {
302+
use tokio::process::Command as AsyncCommand;
303+
let mut success = AsyncCommand::new("true");
304+
let mut fail = AsyncCommand::new("false");
305+
// Run these in parallel just because we can
306+
let (success, fail) = tokio::join!(success.run(), fail.run(),);
307+
success.unwrap();
308+
assert!(fail.is_err());
217309
}

0 commit comments

Comments
 (0)