From 311782f8d99fcb42c95edda181f649a83fa32b9d Mon Sep 17 00:00:00 2001 From: Yanuar Date: Mon, 31 Aug 2026 16:59:43 +0700 Subject: [PATCH] fix(tools): kill process group when bash task aborts --- src/tools/bash.rs | 88 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 86 insertions(+), 2 deletions(-) diff --git a/src/tools/bash.rs b/src/tools/bash.rs index 150e224e..6b447fdd 100644 --- a/src/tools/bash.rs +++ b/src/tools/bash.rs @@ -120,6 +120,7 @@ impl BashTool { .spawn() .map_err(|e| ToolError::Execution(format!("Failed to spawn process: {}", e)))?; let process_group_id = child.id(); + let mut process_group_guard = ProcessGroupGuard::new(process_group_id); let stdout = child.stdout.take().expect("stdout should be piped"); let stderr = child.stderr.take().expect("stderr should be piped"); @@ -153,7 +154,7 @@ impl BashTool { } else { match child.wait().await { Ok(exit_status) => { - kill_process_group(process_group_id); + process_group_guard.kill(); Ok(exit_status) } Err(e) => Err(ToolError::Execution(format!("Process error: {}", e))), @@ -183,7 +184,7 @@ impl BashTool { // A shell can exit successfully while background descendants // keep running and retain the output pipes. Kill the process group // so those descendants cannot leak beyond this tool invocation. - kill_process_group(process_group_id); + process_group_guard.kill(); } Err(e) => return Err(ToolError::Execution(format!("Process error: {}", e))), } @@ -391,6 +392,26 @@ fn kill_process_group(pid: Option) { } } +struct ProcessGroupGuard { + pid: Option, +} + +impl ProcessGroupGuard { + fn new(pid: Option) -> Self { + Self { pid } + } + + fn kill(&mut self) { + kill_process_group(self.pid.take()); + } +} + +impl Drop for ProcessGroupGuard { + fn drop(&mut self) { + self.kill(); + } +} + #[cfg(unix)] async fn terminate_child(child: &mut tokio::process::Child) { kill_process_group(child.id()); @@ -639,4 +660,67 @@ mod tests { } assert!(!still_running, "background process {pid} was not killed"); } + + #[cfg(unix)] + #[tokio::test] + async fn aborted_tool_future_kills_node_descendants() { + if std::process::Command::new("node") + .arg("--version") + .output() + .is_err() + { + return; + } + + let temp_dir = tempfile::tempdir().expect("temp directory should be created"); + let pid_file = temp_dir.path().join("node.pid"); + let escaped_pid_file = pid_file.to_string_lossy().replace('\'', "'\\''"); + let command = format!( + "node -e 'setInterval(() => {{}}, 1000)' & echo $! > '{escaped_pid_file}'; wait" + ); + + let handle = tokio::spawn(async move { + let ctx = ToolContext::from_cancel_token( + "session", + "message", + "Build", + CancellationToken::new(), + ); + BashTool::new() + .execute( + serde_json::json!({ + "command": command, + "timeout": 30 + }), + &ctx, + ) + .await + }); + + let pid = tokio::time::timeout(Duration::from_secs(3), async { + loop { + if let Ok(contents) = std::fs::read_to_string(&pid_file) { + if let Ok(pid) = contents.trim().parse::() { + break pid; + } + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await + .expect("node pid should be written"); + + handle.abort(); + let _ = handle.await; + + let mut still_running = true; + for _ in 0..40 { + still_running = unsafe { libc::kill(pid, 0) == 0 }; + if !still_running { + break; + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + assert!(!still_running, "node descendant {pid} survived tool abort"); + } }