Skip to content

Commit dd56e3b

Browse files
committed
Fixed: Preserve buffered output on tokio bash timeout
Avoid canceling stdout/stderr drains by spawning independent pipe-read tasks and racing only child completion against timeout. On timeout, kill the process tree, await drain tasks briefly to capture buffered output, and include captured stdout/stderr in timeout errors; add a regression test for pre-timeout output.
1 parent 4cac3b1 commit dd56e3b

1 file changed

Lines changed: 121 additions & 31 deletions

File tree

src/llm-coding-tools-core/src/tools/bash/tokio_impl.rs

Lines changed: 121 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,78 @@
22
33
use super::{BashOutput, PIPE_BUFFER_CAPACITY};
44
use crate::error::{ToolError, ToolResult};
5+
use core::fmt::Write;
56
use process_wrap::tokio::*;
67
use std::path::Path;
8+
use std::pin::Pin;
79
use std::process::Stdio;
810
use 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

Comments
 (0)