@@ -16,8 +16,6 @@ pub struct ProcessInfo {
1616 child_pid : Option < u32 > ,
1717 stdout_buffer : Arc < Mutex < String > > ,
1818 stderr_buffer : Arc < Mutex < String > > ,
19- stdout_position : usize ,
20- stderr_position : usize ,
2119 accessed : bool ,
2220 finished : Arc < AtomicBool > ,
2321 exit_code : Arc < Mutex < Option < i32 > > > ,
@@ -89,6 +87,9 @@ impl ProcessManager {
8987 . stdin ( Stdio :: null ( ) )
9088 . stdout ( Stdio :: piped ( ) )
9189 . stderr ( Stdio :: piped ( ) )
90+ // Spawn the shell into its own process group so that terminate
91+ // can send signals to the entire group (shell + all pipeline children).
92+ . process_group ( 0 )
9293 . kill_on_drop ( true ) ;
9394
9495 if let Some ( ref cwd_path) = resolved_cwd {
@@ -200,8 +201,6 @@ impl ProcessManager {
200201 child_pid,
201202 stdout_buffer,
202203 stderr_buffer,
203- stdout_position : 0 ,
204- stderr_position : 0 ,
205204 accessed : false ,
206205 finished,
207206 exit_code,
@@ -236,10 +235,11 @@ impl ProcessManager {
236235
237236 if terminate && !finished. load ( Ordering :: SeqCst ) {
238237 if let Some ( pid) = self . processes [ & process_id] . child_pid {
239- let _ = nix:: sys:: signal:: kill (
240- nix:: unistd:: Pid :: from_raw ( pid as i32 ) ,
241- nix:: sys:: signal:: Signal :: SIGTERM ,
242- ) ;
238+ // Negate the PID to address the entire process group (shell + all pipeline
239+ // children). The shell was spawned with process_group(0), so its PGID equals
240+ // its own PID.
241+ let pgid = nix:: unistd:: Pid :: from_raw ( -( pid as i32 ) ) ;
242+ let _ = nix:: sys:: signal:: kill ( pgid, nix:: sys:: signal:: Signal :: SIGTERM ) ;
243243 }
244244 {
245245 let guard = logger_for_signal. lock ( ) . await ;
@@ -254,10 +254,8 @@ impl ProcessManager {
254254
255255 if timed_out && !finished. load ( Ordering :: SeqCst ) {
256256 if let Some ( pid) = self . processes [ & process_id] . child_pid {
257- let _ = nix:: sys:: signal:: kill (
258- nix:: unistd:: Pid :: from_raw ( pid as i32 ) ,
259- nix:: sys:: signal:: Signal :: SIGKILL ,
260- ) ;
257+ let pgid = nix:: unistd:: Pid :: from_raw ( -( pid as i32 ) ) ;
258+ let _ = nix:: sys:: signal:: kill ( pgid, nix:: sys:: signal:: Signal :: SIGKILL ) ;
261259 }
262260 {
263261 let guard = logger_for_signal. lock ( ) . await ;
@@ -334,20 +332,19 @@ impl ProcessManager {
334332 }
335333 }
336334
337- // Extract incremental output
335+ // Extract incremental output by draining the buffer — this is safe for
336+ // any UTF-8 content because we drain whole Strings (never mid-char slices).
338337 let proc = self . processes . get_mut ( & process_id) . unwrap ( ) ;
339338
340339 let new_stdout = {
341- let guard = proc. stdout_buffer . lock ( ) . await ;
342- guard[ proc . stdout_position .. ] . to_string ( )
340+ let mut guard = proc. stdout_buffer . lock ( ) . await ;
341+ guard. drain ( .. ) . collect :: < String > ( )
343342 } ;
344- proc. stdout_position += new_stdout. len ( ) ;
345343
346344 let new_stderr = {
347- let guard = proc. stderr_buffer . lock ( ) . await ;
348- guard[ proc . stderr_position .. ] . to_string ( )
345+ let mut guard = proc. stderr_buffer . lock ( ) . await ;
346+ guard. drain ( .. ) . collect :: < String > ( )
349347 } ;
350- proc. stderr_position += new_stderr. len ( ) ;
351348
352349 let elapsed_time = proc. start_time . elapsed ( ) . as_secs_f64 ( ) * 1000.0 ;
353350 let is_finished = proc. finished . load ( Ordering :: SeqCst ) ;
@@ -359,8 +356,6 @@ impl ProcessManager {
359356
360357 if is_finished {
361358 proc. accessed = true ;
362- proc. stdout_buffer . lock ( ) . await . clear ( ) ;
363- proc. stderr_buffer . lock ( ) . await . clear ( ) ;
364359 }
365360
366361 Ok ( PollResult {
@@ -844,4 +839,72 @@ mod tests {
844839 log_files
845840 ) ;
846841 }
842+
843+ // --- Bug regression tests ---
844+
845+ /// UTF-8 multi-byte characters should not cause a panic when output is read
846+ /// incrementally (stdout_position was a byte offset that could land mid-char).
847+ #[ tokio:: test]
848+ async fn test_multibyte_utf8_incremental_output ( ) {
849+ let mut pm = ProcessManager :: new ( false ) ;
850+ // Output contains multi-byte UTF-8 (Chinese characters, emoji).
851+ // Two polls exercise the incremental slice logic.
852+ let id = pm
853+ . spawn_process ( "printf '你好世界\n ' && sleep 0.1 && printf '🎉done\n '" , None )
854+ . await
855+ . unwrap ( ) ;
856+ let r1 = pm. poll_process ( id, 200 , false , None ) . await . unwrap ( ) ;
857+ let r2 = pm. poll_process ( id, 3000 , false , None ) . await . unwrap ( ) ;
858+ let combined = format ! ( "{}{}" , r1. stdout, r2. stdout) ;
859+ assert ! (
860+ combined. contains( '你' ) ,
861+ "should contain Chinese chars, got: {:?}" ,
862+ combined
863+ ) ;
864+ assert ! (
865+ combined. contains( "🎉done" ) ,
866+ "should contain emoji output, got: {:?}" ,
867+ combined
868+ ) ;
869+ }
870+
871+ /// When terminate=true on a pipeline command, ALL child processes in the
872+ /// pipeline must be killed, not just the shell process.
873+ #[ tokio:: test]
874+ async fn test_terminate_kills_process_group ( ) {
875+ let mut pm = ProcessManager :: new ( false ) ;
876+ // `sleep 30 | cat` — the shell forks both `sleep` and `cat`.
877+ // After terminate, neither should survive.
878+ // Use a unique marker to avoid matching other test processes.
879+ let unique = "async_bash_mcp_terminate_test_marker_31415926" ;
880+ let cmd = format ! ( "sleep 30 | cat # {unique}" ) ;
881+ let id = pm. spawn_process ( & cmd, None ) . await . unwrap ( ) ;
882+ // Brief wait so bash has time to fork the pipeline children.
883+ tokio:: time:: sleep ( tokio:: time:: Duration :: from_millis ( 200 ) ) . await ;
884+ let start = Instant :: now ( ) ;
885+ let result = pm. poll_process ( id, 8000 , true , None ) . await . unwrap ( ) ;
886+ let elapsed = start. elapsed ( ) . as_millis ( ) ;
887+ assert ! (
888+ result. finished,
889+ "pipeline should be finished after terminate"
890+ ) ;
891+ assert ! (
892+ elapsed < 5000 ,
893+ "terminate should complete quickly (all procs killed), took {}ms" ,
894+ elapsed
895+ ) ;
896+ // Give the OS a moment to reap processes.
897+ tokio:: time:: sleep ( tokio:: time:: Duration :: from_millis ( 200 ) ) . await ;
898+ // Confirm the `sleep 30` pipeline child is actually dead (not a lingering orphan).
899+ // If only the shell was killed, `sleep` would still be running.
900+ let orphan_check = std:: process:: Command :: new ( "pgrep" )
901+ . args ( [ "-f" , unique] )
902+ . output ( )
903+ . expect ( "pgrep" ) ;
904+ assert ! (
905+ orphan_check. stdout. is_empty( ) ,
906+ "pipeline child should not be running after terminate (orphan leak), pgrep found: {:?}" ,
907+ String :: from_utf8_lossy( & orphan_check. stdout)
908+ ) ;
909+ }
847910}
0 commit comments