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 ;
67use std:: path:: PathBuf ;
78use std:: str:: FromStr ;
89use std:: sync:: Arc ;
@@ -13,6 +14,12 @@ use std::pin::pin;
1314use crate :: schema:: v1:: { EnvVariable , McpServer as SchemaMcpServer , McpServerStdio } ;
1415use 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 ) ]
1825pub enum LineDirection {
@@ -60,7 +67,7 @@ pub enum LineDirection {
6067/// ```
6168pub struct AcpAgent {
6269 server : SchemaMcpServer ,
63- debug_callback : Option < Arc < dyn Fn ( & str , LineDirection ) + Send + Sync + ' static > > ,
70+ debug_callback : Option < DebugCallback > ,
6471}
6572
6673impl 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.
282433async 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) ]
534682mod 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 \n tw" , & callback) ;
725+ lines. push ( b"o\n bad\xff \n last\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"\n next\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