fix(bash): returned on stalled cancellation

Raced bash execution against the JavaScript abort signal and timeout so the tool returns even when native shell cleanup does not settle.

Added regression coverage for native cleanup stalls on ESC abort and timeout.

Fixes #1347
This commit is contained in:
roboomp
2026-05-25 18:03:09 +00:00
parent 3b072a10b9
commit 14c7ddf7d0
3 changed files with 90 additions and 14 deletions
@@ -6,6 +6,7 @@ import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config
import { executeBash } from "@oh-my-pi/pi-coding-agent/exec/bash-executor";
import { DEFAULT_MAX_BYTES } from "@oh-my-pi/pi-coding-agent/session/streaming-output";
import * as shellSnapshot from "@oh-my-pi/pi-coding-agent/utils/shell-snapshot";
import * as piNatives from "@oh-my-pi/pi-natives";
// Matches the schema default for `tools.artifactHeadBytes` (20 KB) used by
// OutputSink when bash-executor pulls settings via resolveOutputSinkHeadBytes.
@@ -164,6 +165,69 @@ describe("executeBash", () => {
expect(result.output).toContain("Command cancelled");
});
it("returns promptly when native abort cleanup stalls", async () => {
if (process.platform === "win32") {
return;
}
vi.spyOn(piNatives.Shell.prototype, "run").mockImplementation((_options, onChunk) => {
onChunk?.(null, "started\n");
return new Promise(() => {});
});
const abortSpy = vi.spyOn(piNatives.Shell.prototype, "abort").mockResolvedValue();
const controller = new AbortController();
const promise = executeBash("sleep 10", {
cwd: tempDir,
timeout: 5000,
signal: controller.signal,
sessionKey: "hung-native-abort",
});
await Bun.sleep(50);
controller.abort();
const raced = await Promise.race([
promise.then(result => ({ type: "result" as const, result })),
Bun.sleep(750).then(() => ({ type: "timeout" as const })),
]);
expect(raced.type).toBe("result");
if (raced.type === "result") {
expect(raced.result.cancelled).toBe(true);
expect(raced.result.output).toContain("Command cancelled");
}
expect(abortSpy).toHaveBeenCalled();
});
it("returns at the JavaScript timeout when native timeout cleanup stalls", async () => {
if (process.platform === "win32") {
return;
}
vi.spyOn(piNatives.Shell.prototype, "run").mockImplementation((_options, onChunk) => {
onChunk?.(null, "started\n");
return new Promise(() => {});
});
const abortSpy = vi.spyOn(piNatives.Shell.prototype, "abort").mockResolvedValue();
const promise = executeBash("sleep 10", {
cwd: tempDir,
timeout: 1000,
sessionKey: "hung-native-timeout",
});
const raced = await Promise.race([
promise.then(result => ({ type: "result" as const, result })),
Bun.sleep(1500).then(() => ({ type: "timeout" as const })),
]);
expect(raced.type).toBe("result");
if (raced.type === "result") {
expect(raced.result.cancelled).toBe(true);
expect(raced.result.output).toContain("Command timed out after 1 seconds");
}
expect(abortSpy).toHaveBeenCalled();
});
it("aborts before follow-up output", async () => {
if (process.platform === "win32") {
return;