Skip to content

Commit 81556a4

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 42412fa commit 81556a4

File tree

1 file changed

+108
-9
lines changed

1 file changed

+108
-9
lines changed

lib/src/utils.rs

+108-9
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,79 @@ pub(crate) trait CommandRunExt {
1515
fn run(&mut self) -> Result<()>;
1616
}
1717

18+
/// Helpers intended for [`std::process::ExitStatus`].
19+
pub(crate) trait ExitStatusExt {
20+
/// If the exit status signals it was not successful, return an error.
21+
/// Note that we intentionally *don't* include the command string
22+
/// in the output; we leave it to the caller to add that if they want,
23+
/// as it may be verbose.
24+
fn check_status(&mut self, stderr: std::fs::File) -> Result<()>;
25+
}
26+
27+
/// Parse the last chunk (e.g. 1024 bytes) from the provided file,
28+
/// ensure it's UTF-8, and return that value. This function is infallible;
29+
/// if the file cannot be read for some reason, a copy of a static string
30+
/// is returned.
31+
fn last_utf8_content_from_file(mut f: std::fs::File) -> String {
32+
// u16 since we truncate to just the trailing bytes here
33+
// to avoid pathological error messages
34+
const MAX_STDERR_BYTES: u16 = 1024;
35+
let size = f
36+
.metadata()
37+
.map_err(|e| {
38+
tracing::warn!("failed to fstat: {e}");
39+
})
40+
.map(|m| m.len().try_into().unwrap_or(u16::MAX))
41+
.unwrap_or(0);
42+
let size = size.min(MAX_STDERR_BYTES);
43+
let seek_offset = -(size as i32);
44+
let mut stderr_buf = Vec::with_capacity(size.into());
45+
// We should never fail to seek()+read() really, but let's be conservative
46+
let r = match f
47+
.seek(std::io::SeekFrom::End(seek_offset.into()))
48+
.and_then(|_| f.read_to_end(&mut stderr_buf))
49+
{
50+
Ok(_) => String::from_utf8_lossy(&stderr_buf),
51+
Err(e) => {
52+
tracing::warn!("failed seek+read: {e}");
53+
"<failed to read stderr>".into()
54+
}
55+
};
56+
(&*r).to_owned()
57+
}
58+
59+
impl ExitStatusExt for std::process::ExitStatus {
60+
fn check_status(&mut self, stderr: std::fs::File) -> Result<()> {
61+
let stderr_buf = last_utf8_content_from_file(stderr);
62+
if self.success() {
63+
return Ok(());
64+
}
65+
anyhow::bail!(format!("Subprocess failed: {self:?}\n{stderr_buf}"))
66+
}
67+
}
68+
1869
impl CommandRunExt for Command {
1970
/// Synchronously execute the child, and return an error if the child exited unsuccessfully.
2071
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(())
72+
let stderr = tempfile::tempfile()?;
73+
self.stderr(stderr.try_clone()?);
74+
self.status()?.check_status(stderr)
75+
}
76+
}
77+
78+
/// Helpers intended for [`tokio::process::Command`].
79+
#[allow(dead_code)]
80+
pub(crate) trait AsyncCommandRunExt {
81+
async fn run(&mut self) -> Result<()>;
82+
}
83+
84+
impl AsyncCommandRunExt for tokio::process::Command {
85+
/// Asynchronously execute the child, and return an error if the child exited unsuccessfully.
86+
///
87+
async fn run(&mut self) -> Result<()> {
88+
let stderr = tempfile::tempfile()?;
89+
self.stderr(stderr.try_clone()?);
90+
self.status().await?.check_status(stderr)
2991
}
3092
}
3193

@@ -212,6 +274,43 @@ fn test_sigpolicy_from_opts() {
212274

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

0 commit comments

Comments
 (0)