Skip to content

Commit a4578a5

Browse files
authored
fix(acp): Bound stderr capture memory (#255)
1 parent 0976cc2 commit a4578a5

1 file changed

Lines changed: 311 additions & 17 deletions

File tree

‎src/agent-client-protocol/src/acp_agent.rs‎

Lines changed: 311 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
//! This module provides [`AcpAgent`], a convenient wrapper around [`crate::schema::v1::McpServer`]
44
//! that can be parsed from either a command string or JSON configuration.
55
6+
use std::collections::VecDeque;
67
use std::path::PathBuf;
78
use std::str::FromStr;
89
use std::sync::Arc;
@@ -13,6 +14,12 @@ use std::pin::pin;
1314
use crate::schema::v1::{EnvVariable, McpServer as SchemaMcpServer, McpServerStdio};
1415
use crate::{Client, Conductor, Role};
1516

17+
type DebugCallback = Arc<dyn Fn(&str, LineDirection) + Send + Sync + 'static>;
18+
19+
const STDERR_CAPTURE_LIMIT: usize = 64 * 1024;
20+
const STDERR_READ_BUFFER_SIZE: usize = 8 * 1024;
21+
const STDERR_LINE_TRUNCATION_MARKER: &str = "… [stderr line truncated]";
22+
1623
/// Direction of a line being sent or received.
1724
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1825
pub enum LineDirection {
@@ -60,7 +67,7 @@ pub enum LineDirection {
6067
/// ```
6168
pub struct AcpAgent {
6269
server: SchemaMcpServer,
63-
debug_callback: Option<Arc<dyn Fn(&str, LineDirection) + Send + Sync + 'static>>,
70+
debug_callback: Option<DebugCallback>,
6471
}
6572

6673
impl std::fmt::Debug for AcpAgent {
@@ -146,6 +153,7 @@ impl AcpAgent {
146153
///
147154
/// The callback receives the line content and the direction (stdin/stdout/stderr).
148155
/// This is useful for logging, debugging, or monitoring agent communication.
156+
/// Exceptionally long stderr lines are truncated to keep memory usage bounded.
149157
///
150158
/// # Example
151159
///
@@ -275,9 +283,152 @@ impl Drop for ChildGuard {
275283
}
276284
}
277285

286+
#[derive(Default)]
287+
struct StderrTail {
288+
bytes: VecDeque<u8>,
289+
truncated: bool,
290+
}
291+
292+
impl StderrTail {
293+
fn push(&mut self, bytes: &[u8]) {
294+
if bytes.len() >= STDERR_CAPTURE_LIMIT {
295+
self.truncated |= !self.bytes.is_empty() || bytes.len() > STDERR_CAPTURE_LIMIT;
296+
self.bytes.clear();
297+
self.bytes
298+
.extend(bytes[bytes.len() - STDERR_CAPTURE_LIMIT..].iter().copied());
299+
return;
300+
}
301+
302+
let overflow = self
303+
.bytes
304+
.len()
305+
.saturating_add(bytes.len())
306+
.saturating_sub(STDERR_CAPTURE_LIMIT);
307+
if overflow > 0 {
308+
self.truncated = true;
309+
drop(self.bytes.drain(..overflow));
310+
}
311+
self.bytes.extend(bytes.iter().copied());
312+
}
313+
314+
fn into_string(mut self) -> String {
315+
let truncated = self.truncated;
316+
let stderr = String::from_utf8_lossy(self.bytes.make_contiguous());
317+
if truncated {
318+
format!("[stderr truncated; showing last {STDERR_CAPTURE_LIMIT} bytes]\n{stderr}")
319+
} else {
320+
stderr.into_owned()
321+
}
322+
}
323+
}
324+
325+
#[derive(Default)]
326+
struct StderrDebugLines {
327+
current: Vec<u8>,
328+
truncated: bool,
329+
pending_carriage_return: bool,
330+
}
331+
332+
impl StderrDebugLines {
333+
fn push(&mut self, bytes: &[u8], callback: &DebugCallback) {
334+
for &byte in bytes {
335+
if self.pending_carriage_return {
336+
if byte == b'\n' {
337+
self.pending_carriage_return = false;
338+
self.emit(callback);
339+
continue;
340+
}
341+
342+
self.push_byte(b'\r');
343+
self.pending_carriage_return = false;
344+
}
345+
346+
match byte {
347+
b'\r' => self.pending_carriage_return = true,
348+
b'\n' => self.emit(callback),
349+
byte => self.push_byte(byte),
350+
}
351+
}
352+
}
353+
354+
fn finish(&mut self, callback: &DebugCallback) {
355+
if self.pending_carriage_return {
356+
self.push_byte(b'\r');
357+
self.pending_carriage_return = false;
358+
}
359+
if !self.current.is_empty() || self.truncated {
360+
self.emit(callback);
361+
}
362+
}
363+
364+
fn push_byte(&mut self, byte: u8) {
365+
if self.current.len() < STDERR_CAPTURE_LIMIT {
366+
self.current.push(byte);
367+
} else {
368+
self.truncated = true;
369+
}
370+
}
371+
372+
fn emit(&mut self, callback: &DebugCallback) {
373+
let line = String::from_utf8_lossy(&self.current);
374+
375+
if self.truncated {
376+
let mut line = line.into_owned();
377+
line.push_str(STDERR_LINE_TRUNCATION_MARKER);
378+
callback(&line, LineDirection::Stderr);
379+
} else {
380+
callback(line.as_ref(), LineDirection::Stderr);
381+
}
382+
383+
self.current.clear();
384+
self.truncated = false;
385+
}
386+
}
387+
388+
struct StderrDrainResult {
389+
captured: String,
390+
read_error: Option<std::io::Error>,
391+
}
392+
393+
async fn drain_stderr(
394+
mut stderr: impl futures::AsyncRead + Unpin,
395+
debug_callback: Option<DebugCallback>,
396+
) -> StderrDrainResult {
397+
use futures::AsyncReadExt as _;
398+
399+
let mut tail = StderrTail::default();
400+
let mut debug_lines = debug_callback.as_ref().map(|_| StderrDebugLines::default());
401+
let mut buffer = [0; STDERR_READ_BUFFER_SIZE];
402+
403+
let read_error = loop {
404+
match stderr.read(&mut buffer).await {
405+
Ok(0) => break None,
406+
Ok(read) => {
407+
let bytes = &buffer[..read];
408+
tail.push(bytes);
409+
if let (Some(lines), Some(callback)) =
410+
(debug_lines.as_mut(), debug_callback.as_ref())
411+
{
412+
lines.push(bytes, callback);
413+
}
414+
}
415+
Err(error) => break Some(error),
416+
}
417+
};
418+
419+
if let (Some(lines), Some(callback)) = (debug_lines.as_mut(), debug_callback.as_ref()) {
420+
lines.finish(callback);
421+
}
422+
423+
StderrDrainResult {
424+
captured: tail.into_string(),
425+
read_error,
426+
}
427+
}
428+
278429
/// Waits for a child process and returns an error if it exits with non-zero status.
279430
///
280-
/// The error message includes any stderr output collected concurrently.
431+
/// The error message includes a bounded tail of stderr collected concurrently.
281432
/// When dropped, the child process is killed.
282433
async fn monitor_child(
283434
mut guard: ChildGuard,
@@ -332,21 +483,18 @@ impl<Counterpart: AcpAgentCounterpartRole> crate::ConnectTo<Counterpart> for Acp
332483
// so this runs as part of the same task — no tokio::spawn needed.
333484
let debug_callback = self.debug_callback.clone();
334485
let stderr_future = async move {
335-
let stderr_reader = BufReader::new(child_stderr);
336-
let mut stderr_lines = stderr_reader.lines();
337-
let mut collected = String::new();
338-
while let Some(line_result) = stderr_lines.next().await {
339-
if let Ok(line) = line_result {
340-
if let Some(ref callback) = debug_callback {
341-
callback(&line, LineDirection::Stderr);
342-
}
343-
if !collected.is_empty() {
344-
collected.push('\n');
345-
}
346-
collected.push_str(&line);
347-
}
486+
let StderrDrainResult {
487+
captured,
488+
read_error,
489+
} = drain_stderr(child_stderr, debug_callback).await;
490+
drop(stderr_tx.send(captured));
491+
492+
if let Some(error) = read_error {
493+
tracing::warn!(
494+
?error,
495+
"Failed to read process stderr; stderr will no longer be captured"
496+
);
348497
}
349-
drop(stderr_tx.send(collected));
350498
};
351499

352500
// Create the guard eagerly so cancelling this connection before the
@@ -416,7 +564,7 @@ impl<Counterpart: AcpAgentCounterpartRole> crate::ConnectTo<Counterpart> for Acp
416564
let main_race = pin!(main_race);
417565
match futures::future::select(main_race, stderr_future).await {
418566
futures::future::Either::Left((result, _)) => result,
419-
futures::future::Either::Right(((), protocol)) => protocol.await,
567+
futures::future::Either::Right(((), main_race)) => main_race.await,
420568
}
421569
}
422570
}
@@ -533,6 +681,152 @@ impl FromStr for AcpAgent {
533681
#[cfg(test)]
534682
mod tests {
535683
use super::*;
684+
use std::sync::Mutex;
685+
use std::sync::atomic::{AtomicUsize, Ordering};
686+
687+
fn recording_debug_callback() -> (DebugCallback, Arc<Mutex<Vec<String>>>) {
688+
let lines = Arc::new(Mutex::new(Vec::new()));
689+
let recorded = lines.clone();
690+
let callback = Arc::new(move |line: &str, direction| {
691+
assert_eq!(direction, LineDirection::Stderr);
692+
recorded.lock().unwrap().push(line.to_owned());
693+
});
694+
(callback, lines)
695+
}
696+
697+
#[test]
698+
fn stderr_tail_keeps_last_bytes() {
699+
let initial = vec![b'a'; STDERR_CAPTURE_LIMIT];
700+
701+
let mut exact = StderrTail::default();
702+
exact.push(&initial);
703+
assert_eq!(exact.into_string(), String::from_utf8(initial).unwrap());
704+
705+
let mut truncated = StderrTail::default();
706+
truncated.push(&vec![b'a'; STDERR_CAPTURE_LIMIT]);
707+
truncated.push(b"the end");
708+
let captured = truncated.into_string();
709+
let (notice, tail) = captured.split_once('\n').unwrap();
710+
assert_eq!(
711+
notice,
712+
format!("[stderr truncated; showing last {STDERR_CAPTURE_LIMIT} bytes]")
713+
);
714+
assert_eq!(tail.len(), STDERR_CAPTURE_LIMIT);
715+
assert!(tail.ends_with("the end"));
716+
}
717+
718+
#[test]
719+
fn stderr_debug_callback_preserves_lines() {
720+
let (callback, recorded) = recording_debug_callback();
721+
let mut lines = StderrDebugLines::default();
722+
723+
lines.push(b"one\r", &callback);
724+
lines.push(b"\n\ntw", &callback);
725+
lines.push(b"o\nbad\xff\nlast\r", &callback);
726+
lines.finish(&callback);
727+
728+
assert_eq!(
729+
*recorded.lock().unwrap(),
730+
["one", "", "two", "bad\u{fffd}", "last\r"]
731+
);
732+
}
733+
734+
#[test]
735+
fn stderr_debug_callback_truncates_oversized_lines() {
736+
let (callback, recorded) = recording_debug_callback();
737+
let mut lines = StderrDebugLines::default();
738+
let exact = vec![b'y'; STDERR_CAPTURE_LIMIT];
739+
let oversized = vec![b'x'; STDERR_CAPTURE_LIMIT + 1];
740+
741+
lines.push(&exact, &callback);
742+
lines.push(b"\r\n", &callback);
743+
lines.push(&oversized, &callback);
744+
assert_eq!(lines.current.len(), STDERR_CAPTURE_LIMIT);
745+
assert!(lines.truncated);
746+
lines.push(b"\nnext\n", &callback);
747+
748+
let recorded = recorded.lock().unwrap();
749+
assert_eq!(recorded.len(), 3);
750+
assert_eq!(recorded[0].len(), STDERR_CAPTURE_LIMIT);
751+
assert!(!recorded[0].ends_with(STDERR_LINE_TRUNCATION_MARKER));
752+
assert_eq!(
753+
recorded[1].len(),
754+
STDERR_CAPTURE_LIMIT + STDERR_LINE_TRUNCATION_MARKER.len()
755+
);
756+
assert!(recorded[1].ends_with(STDERR_LINE_TRUNCATION_MARKER));
757+
assert_eq!(recorded[2], "next");
758+
}
759+
760+
struct ErrorAfterData {
761+
polls: Arc<AtomicUsize>,
762+
}
763+
764+
impl futures::AsyncRead for ErrorAfterData {
765+
fn poll_read(
766+
self: std::pin::Pin<&mut Self>,
767+
_cx: &mut std::task::Context<'_>,
768+
buffer: &mut [u8],
769+
) -> std::task::Poll<std::io::Result<usize>> {
770+
match self.polls.fetch_add(1, Ordering::SeqCst) {
771+
0 => {
772+
buffer[..7].copy_from_slice(b"partial");
773+
std::task::Poll::Ready(Ok(7))
774+
}
775+
1 => std::task::Poll::Ready(Err(std::io::Error::other("read failed"))),
776+
_ => panic!("stderr reader was polled again after an error"),
777+
}
778+
}
779+
}
780+
781+
#[tokio::test]
782+
async fn stderr_drain_stops_after_read_error() {
783+
let polls = Arc::new(AtomicUsize::new(0));
784+
let (callback, recorded) = recording_debug_callback();
785+
786+
let result = drain_stderr(
787+
ErrorAfterData {
788+
polls: polls.clone(),
789+
},
790+
Some(callback),
791+
)
792+
.await;
793+
794+
assert_eq!(result.captured, "partial");
795+
assert_eq!(result.read_error.unwrap().to_string(), "read failed");
796+
assert_eq!(polls.load(Ordering::SeqCst), 2);
797+
assert_eq!(*recorded.lock().unwrap(), ["partial"]);
798+
}
799+
800+
#[cfg(unix)]
801+
#[tokio::test]
802+
async fn large_unterminated_stderr_is_fully_drained() {
803+
let agent = AcpAgent::from_args([
804+
"/bin/sh",
805+
"-c",
806+
r#"i=0; while [ "$i" -lt 4096 ]; do printf '%01024d' 0; i=$((i + 1)); done >&2; printf ACP_END >&2; exit 17"#,
807+
])
808+
.unwrap();
809+
let (child_stdin, child_stdout, child_stderr, child) = agent.spawn_process().unwrap();
810+
drop(child_stdin);
811+
drop(child_stdout);
812+
let mut guard = ChildGuard(child);
813+
814+
let (drained, status) = tokio::time::timeout(std::time::Duration::from_secs(10), async {
815+
futures::join!(drain_stderr(child_stderr, None), guard.wait())
816+
})
817+
.await
818+
.expect("stderr drain should not block after its retained tail is full");
819+
820+
assert_eq!(status.unwrap().code(), Some(17));
821+
assert!(drained.read_error.is_none());
822+
let (notice, tail) = drained.captured.split_once('\n').unwrap();
823+
assert_eq!(
824+
notice,
825+
format!("[stderr truncated; showing last {STDERR_CAPTURE_LIMIT} bytes]")
826+
);
827+
assert_eq!(tail.len(), STDERR_CAPTURE_LIMIT);
828+
assert!(tail.ends_with("ACP_END"));
829+
}
536830

537831
#[cfg(unix)]
538832
struct KillOnDrop(Option<rustix::process::Pid>);

0 commit comments

Comments
 (0)