22
33use super :: { BashOutput , PIPE_BUFFER_CAPACITY } ;
44use crate :: error:: { ToolError , ToolResult } ;
5+ use core:: fmt:: Write ;
56use process_wrap:: tokio:: * ;
67use std:: path:: Path ;
8+ use std:: pin:: Pin ;
79use std:: process:: Stdio ;
810use std:: time:: Duration ;
9- use tokio:: io:: AsyncReadExt ;
11+ use tokio:: io:: { AsyncRead , AsyncReadExt } ;
12+ use tokio:: task:: JoinHandle ;
13+
14+ /// Maximum time to wait for pipe drains after timeout kill.
15+ const PIPE_DRAIN_GRACE_PERIOD : Duration = Duration :: from_millis ( 100 ) ;
16+
17+ #[ inline]
18+ fn spawn_pipe_drain_task < R > ( mut pipe : R ) -> JoinHandle < Vec < u8 > >
19+ where
20+ R : AsyncRead + Unpin + Send + ' static ,
21+ {
22+ tokio:: spawn ( async move {
23+ let mut buf = Vec :: with_capacity ( PIPE_BUFFER_CAPACITY ) ;
24+ let _ = pipe. read_to_end ( & mut buf) . await ;
25+ buf
26+ } )
27+ }
28+
29+ #[ inline]
30+ async fn await_pipe_drain_task ( task : JoinHandle < Vec < u8 > > ) -> Vec < u8 > {
31+ task. await . unwrap_or_default ( )
32+ }
33+
34+ #[ inline]
35+ async fn await_pipe_drain_task_with_grace (
36+ mut task : JoinHandle < Vec < u8 > > ,
37+ grace : Duration ,
38+ ) -> Vec < u8 > {
39+ tokio:: select! {
40+ result = & mut task => result. unwrap_or_default( ) ,
41+ _ = tokio:: time:: sleep( grace) => {
42+ task. abort( ) ;
43+ let _ = task. await ;
44+ Vec :: new( )
45+ }
46+ }
47+ }
48+
49+ #[ inline]
50+ fn timeout_with_buffered_output (
51+ timeout : Duration ,
52+ stdout_data : & [ u8 ] ,
53+ stderr_data : & [ u8 ] ,
54+ ) -> ToolError {
55+ let stdout = String :: from_utf8_lossy ( stdout_data) ;
56+ let stderr = String :: from_utf8_lossy ( stderr_data) ;
57+
58+ // Base message + outputs + stderr label.
59+ let mut message = String :: with_capacity ( stdout. len ( ) + stderr. len ( ) + 64 ) ;
60+ let _ = write ! ( message, "command timed out after {}ms" , timeout. as_millis( ) ) ;
61+
62+ if !stdout. is_empty ( ) {
63+ message. push ( '\n' ) ;
64+ message. push_str ( & stdout) ;
65+ }
66+
67+ if !stderr. is_empty ( ) {
68+ if stdout. is_empty ( ) || !stdout. ends_with ( '\n' ) {
69+ message. push ( '\n' ) ;
70+ }
71+ message. push_str ( "[stderr]\n " ) ;
72+ message. push_str ( & stderr) ;
73+ }
74+
75+ ToolError :: Timeout ( message)
76+ }
1077
1178/// Executes a shell command with optional working directory and timeout.
1279///
@@ -67,40 +134,29 @@ pub async fn execute_command(
67134
68135 // Take stdout/stderr handles to drain them concurrently with process wait.
69136 // This prevents deadlock when output exceeds pipe buffer (~64KB Linux, ~4KB Windows).
70- let mut stdout_pipe = child. stdout ( ) . take ( ) . expect ( "stdout was piped" ) ;
71- let mut stderr_pipe = child. stderr ( ) . take ( ) . expect ( "stderr was piped" ) ;
137+ let stdout_pipe = child. stdout ( ) . take ( ) . expect ( "stdout was piped" ) ;
138+ let stderr_pipe = child. stderr ( ) . take ( ) . expect ( "stderr was piped" ) ;
72139
73- // Race between timeout and (process completion + pipe draining).
74- // Using join! inside select! avoids tokio::spawn overhead while still
75- // providing concurrent I/O for the pipe reads.
76- tokio:: select! {
140+ // Keep output drains independent from timeout selection so timed-out
141+ // commands can still return buffered stdout/stderr.
142+ let stdout_task = spawn_pipe_drain_task ( stdout_pipe) ;
143+ let stderr_task = spawn_pipe_drain_task ( stderr_pipe) ;
144+
145+ // Race between timeout and process completion. Pipe drain tasks keep running
146+ // regardless of which branch wins this select.
147+ let wait_result = tokio:: select! {
77148 biased; // Check timeout first for consistent behavior
78149
79- _ = tokio:: time:: sleep( timeout) => {
80- // Timeout: explicitly kill the process tree (Job Object on Windows, process group on Unix)
81- let _ = Pin :: from( child. kill( ) ) . await ;
82- Err ( ToolError :: Timeout ( format!(
83- "command timed out after {}ms" ,
84- timeout. as_millis( )
85- ) ) )
86- }
150+ _ = tokio:: time:: sleep( timeout) => None ,
151+ status = child. wait( ) => Some ( status) ,
152+ } ;
87153
88- result = async {
89- tokio:: join!(
90- child. wait( ) ,
91- async {
92- let mut buf = Vec :: with_capacity( PIPE_BUFFER_CAPACITY ) ;
93- let _ = stdout_pipe. read_to_end( & mut buf) . await ;
94- buf
95- } ,
96- async {
97- let mut buf = Vec :: with_capacity( PIPE_BUFFER_CAPACITY ) ;
98- let _ = stderr_pipe. read_to_end( & mut buf) . await ;
99- buf
100- }
101- )
102- } => {
103- let ( status, stdout_data, stderr_data) = result;
154+ match wait_result {
155+ Some ( status) => {
156+ let ( stdout_data, stderr_data) = tokio:: join!(
157+ await_pipe_drain_task( stdout_task) ,
158+ await_pipe_drain_task( stderr_task)
159+ ) ;
104160 let status = status. map_err ( |e| ToolError :: Execution ( e. to_string ( ) ) ) ?;
105161
106162 Ok ( BashOutput {
@@ -109,6 +165,22 @@ pub async fn execute_command(
109165 stderr : String :: from_utf8_lossy ( & stderr_data) . into_owned ( ) ,
110166 } )
111167 }
168+ None => {
169+ // Timeout: explicitly kill the process tree (Job Object on Windows,
170+ // process group on Unix), then briefly await pipe drains for buffered output.
171+ let _ = Pin :: from ( child. kill ( ) ) . await ;
172+
173+ let ( stdout_data, stderr_data) = tokio:: join!(
174+ await_pipe_drain_task_with_grace( stdout_task, PIPE_DRAIN_GRACE_PERIOD ) ,
175+ await_pipe_drain_task_with_grace( stderr_task, PIPE_DRAIN_GRACE_PERIOD )
176+ ) ;
177+
178+ Err ( timeout_with_buffered_output (
179+ timeout,
180+ & stdout_data,
181+ & stderr_data,
182+ ) )
183+ }
112184 }
113185}
114186
@@ -157,6 +229,24 @@ mod tests {
157229 assert ! ( matches!( result, Err ( ToolError :: Timeout ( _) ) ) ) ;
158230 }
159231
232+ #[ tokio:: test]
233+ async fn timeout_preserves_buffered_output ( ) {
234+ let cmd = if cfg ! ( target_os = "windows" ) {
235+ "echo stdout-before-timeout & echo stderr-before-timeout 1>&2 & ping -n 10 127.0.0.1 >nul"
236+ } else {
237+ "echo stdout-before-timeout; echo stderr-before-timeout 1>&2; sleep 10"
238+ } ;
239+
240+ let result = execute_command ( cmd, None , Duration :: from_millis ( 100 ) ) . await ;
241+ match result {
242+ Err ( ToolError :: Timeout ( message) ) => {
243+ assert ! ( message. contains( "stdout-before-timeout" ) ) ;
244+ assert ! ( message. contains( "stderr-before-timeout" ) ) ;
245+ }
246+ other => panic ! ( "expected timeout error, got: {other:?}" ) ,
247+ }
248+ }
249+
160250 #[ tokio:: test]
161251 async fn invalid_workdir_returns_error ( ) {
162252 let result = execute_command (
0 commit comments