From 0a3c192d7d73f2cfe9d7d9d4e7ea67fd2d59fdae Mon Sep 17 00:00:00 2001 From: can1357 Date: Sun, 1 Mar 2026 11:40:19 +0100 Subject: [PATCH] fix(pi-natives): bound shell reader shutdown wait Fixes #230 --- crates/pi-natives/src/shell.rs | 11 ++++++++++- .../coding-agent/test/bash-executor.test.ts | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/pi-natives/src/shell.rs b/crates/pi-natives/src/shell.rs index 3033ba184..0292201a8 100644 --- a/crates/pi-natives/src/shell.rs +++ b/crates/pi-natives/src/shell.rs @@ -599,6 +599,7 @@ async fn run_shell_command( // for a short period, then cancel. const POST_EXIT_IDLE: Duration = Duration::from_millis(250); const POST_EXIT_MAX: Duration = Duration::from_secs(2); + const READER_SHUTDOWN_TIMEOUT: Duration = Duration::from_millis(250); let mut reader_finished = false; let mut idle_timer = Box::pin(time::sleep(POST_EXIT_IDLE)); @@ -624,7 +625,15 @@ async fn run_shell_command( if !reader_finished { reader_cancel.cancel(); - let _ = reader_handle.await; + match time::timeout(READER_SHUTDOWN_TIMEOUT, &mut reader_handle).await { + Ok(res) => { + let _ = res; + } + Err(_) => { + reader_handle.abort(); + let _ = reader_handle.await; + } + } } cancel_bridge.abort(); let _ = cancel_bridge.await; diff --git a/packages/coding-agent/test/bash-executor.test.ts b/packages/coding-agent/test/bash-executor.test.ts index c00b272d1..538988c09 100644 --- a/packages/coding-agent/test/bash-executor.test.ts +++ b/packages/coding-agent/test/bash-executor.test.ts @@ -244,6 +244,25 @@ describe("executeBash", () => { } }); + it("completes even when background job keeps stdout pipe open", async () => { + if (process.platform === "win32") return; + + const runPromise = executeBash("{ sleep 3; echo late; } & echo immediate", { + cwd: tempDir, + timeout: 5000, + }); + const timed = await Promise.race([ + runPromise.then(result => ({ type: "result" as const, result })), + Bun.sleep(1500).then(() => ({ type: "timeout" as const })), + ]); + + expect(timed.type).toBe("result"); + if (timed.type === "result") { + expect(timed.result.cancelled).toBe(false); + expect(timed.result.exitCode).toBe(0); + expect(timed.result.output).toContain("immediate"); + } + }); it("kills spawned process on timeout (not just orphans it)", async () => { if (process.platform === "win32") return;