1
1
use std:: future:: Future ;
2
- use std:: io:: Write ;
2
+ use std:: io:: { Read , Seek , Write } ;
3
3
use std:: os:: fd:: BorrowedFd ;
4
4
use std:: process:: Command ;
5
5
use std:: time:: Duration ;
@@ -15,17 +15,72 @@ pub(crate) trait CommandRunExt {
15
15
fn run ( & mut self ) -> Result < ( ) > ;
16
16
}
17
17
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
+
18
62
impl CommandRunExt for Command {
19
63
/// Synchronously execute the child, and return an error if the child exited unsuccessfully.
20
64
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)
29
84
}
30
85
}
31
86
@@ -212,6 +267,43 @@ fn test_sigpolicy_from_opts() {
212
267
213
268
#[ test]
214
269
fn command_run_ext ( ) {
270
+ // The basics
215
271
Command :: new ( "true" ) . run ( ) . unwrap ( ) ;
216
272
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))\n expected-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))\n expected-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( ) ) ;
217
309
}
0 commit comments