Files
oh-my-pi/packages/coding-agent/test/bash-executor.test.ts
T
roboomp 14c7ddf7d0 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
2026-05-25 18:03:09 +00:00

557 lines
18 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
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.
const ARTIFACT_HEAD_BYTES_DEFAULT = 20 * 1024;
const BACKGROUND_COMPLETION_RACE_MS = 750;
const KILL_MARKER_DELAY_SECONDS = "0.4";
const KILL_MARKER_ASSERTION_WAIT_MS = 900;
function makeTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "omp-bash-exec-"));
}
describe("executeBash", () => {
let tempDir: string;
beforeEach(async () => {
tempDir = makeTempDir();
resetSettingsForTest();
await Settings.init({ inMemory: true, cwd: tempDir });
});
afterEach(() => {
resetSettingsForTest();
vi.restoreAllMocks();
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true });
}
});
it("returns non-zero exit codes without cancellation", async () => {
const result = await executeBash("exit 7", { cwd: tempDir, timeout: 5000 });
expect(result.exitCode).toBe(7);
expect(result.cancelled).toBe(false);
});
it("honors cwd", async () => {
const result = await executeBash("pwd", { cwd: tempDir, timeout: 5000 });
expect(result.output.trim()).toBe(fs.realpathSync(tempDir));
});
it("canonicalizes symlinked cwd before execution", async () => {
if (process.platform === "win32") {
return;
}
const realDir = path.join(tempDir, "real");
const linkDir = path.join(tempDir, "link");
fs.mkdirSync(realDir);
fs.symlinkSync(realDir, linkDir, "dir");
const result = await executeBash("pwd", { cwd: linkDir, timeout: 5000 });
expect(result.output.trim()).toBe(fs.realpathSync(linkDir));
});
it("passes env vars", async () => {
const result = await executeBash("echo $PI_TEST_ENV", {
cwd: tempDir,
timeout: 5000,
env: { PI_TEST_ENV: "hello" },
});
expect(result.output.trim()).toBe("hello");
});
it("applies non-interactive environment defaults", async () => {
const result = await executeBash('echo "$GIT_TERMINAL_PROMPT:$PI_TEST_ENV"', {
cwd: tempDir,
timeout: 5000,
env: { PI_TEST_ENV: "hello" },
});
expect(result.output.trim()).toBe("0:hello");
});
it("invokes onChunk with command output", async () => {
let seenChunk: string | null = null;
const result = await executeBash("echo hello", {
cwd: tempDir,
timeout: 5000,
onChunk: chunk => {
if (seenChunk === null) {
seenChunk = chunk;
}
},
});
expect(result.output.trim()).toBe("hello");
expect(seenChunk).not.toBeNull();
expect(seenChunk ?? "").toContain("hello");
});
it("returns even if command spawns a background job", async () => {
if (process.platform === "win32") {
return;
}
const runPromise = executeBash("{ sleep 2; } & echo fg", {
cwd: tempDir,
timeout: 5000,
});
const timed = await Promise.race([
runPromise.then(result => ({ type: "result" as const, result })),
Bun.sleep(BACKGROUND_COMPLETION_RACE_MS).then(() => ({ type: "timeout" as const })),
]);
expect(timed.type).toBe("result");
if (timed.type === "result") {
expect(timed.result.output).toContain("fg");
}
});
it("returns a real PID for background external commands", async () => {
if (process.platform === "win32") {
return;
}
const result = await executeBash('python3 -c "import time; time.sleep(10)" & echo $!', {
cwd: tempDir,
timeout: 5000,
});
const pid = Number.parseInt(result.output.trim(), 10);
expect(Number.isInteger(pid)).toBe(true);
expect(pid).toBeGreaterThan(0);
expect(() => process.kill(pid, 0)).not.toThrow();
expect(() => process.kill(pid, "SIGKILL")).not.toThrow();
});
it("times out commands", async () => {
if (process.platform === "win32") {
return;
}
const result = await executeBash("sleep 10", { cwd: tempDir, timeout: 50 });
expect(result.cancelled).toBe(true);
expect(result.output).toContain("timed out");
});
it("times out before follow-up output", async () => {
if (process.platform === "win32") {
return;
}
const result = await executeBash("sleep 10; echo done", { cwd: tempDir, timeout: 50 });
expect(result.cancelled).toBe(true);
expect(result.output).toContain("timed out");
expect(result.output).not.toContain("done");
});
it("aborts commands", async () => {
if (process.platform === "win32") {
return;
}
const controller = new AbortController();
const promise = executeBash("sleep 10", {
cwd: tempDir,
timeout: 5000,
signal: controller.signal,
});
await Bun.sleep(50);
controller.abort();
const result = await promise;
expect(result.cancelled).toBe(true);
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;
}
const controller = new AbortController();
const promise = executeBash("sleep 10; echo done", {
cwd: tempDir,
timeout: 5000,
signal: controller.signal,
});
await Bun.sleep(100);
controller.abort();
const result = await promise;
expect(result.cancelled).toBe(true);
expect(result.output).toContain("Command cancelled");
expect(result.output).not.toContain("done");
});
it("resets persistent session state after abort", async () => {
if (process.platform === "win32") {
return;
}
const sessionKey = "reset-on-abort";
await executeBash("export PI_RESET_VAR=alive", { cwd: tempDir, timeout: 5000, sessionKey });
const beforeAbort = await executeBash("echo $PI_RESET_VAR", { cwd: tempDir, timeout: 5000, sessionKey });
expect(beforeAbort.output.trim()).toBe("alive");
const controller = new AbortController();
const abortPromise = executeBash("sleep 10", {
cwd: tempDir,
timeout: 5000,
signal: controller.signal,
sessionKey,
});
await Bun.sleep(50);
controller.abort();
const aborted = await abortPromise;
expect(aborted.cancelled).toBe(true);
// biome-ignore lint/suspicious/noTemplateCurlyInString: this is a bash variable expansion
const afterAbort = await executeBash("echo ${PI_RESET_VAR:-unset}", {
cwd: tempDir,
timeout: 5000,
sessionKey,
});
expect(afterAbort.output.trim()).toBe("unset");
});
it("streams output chunks", async () => {
const chunks: string[] = [];
const result = await executeBash("i=1; while [ $i -le 20 ]; do echo line$i; i=$((i+1)); done", {
cwd: tempDir,
timeout: 5000,
onChunk: chunk => {
expect(chunk.length).toBeGreaterThan(0);
chunks.push(chunk);
},
});
// At least one chunk should have been delivered to onChunk
expect(chunks.length).toBeGreaterThan(0);
const combined = chunks.join("");
expect(combined).toContain("line1");
// Final result always has the complete output regardless of chunk throttle
expect(result.output).toContain("line1");
expect(result.output).toContain("line20");
});
it("streams large output without exhausting memory", async () => {
if (process.platform === "win32") {
return;
}
let sawChunk = false;
const result = await executeBash("awk 'BEGIN { for (i = 0; i < 100000; i++) printf \"a\" }'", {
cwd: tempDir,
timeout: 5000,
onChunk: () => {
sawChunk = true;
},
});
expect(sawChunk).toBe(true);
expect(result.totalBytes).toBe(100000);
expect(result.outputBytes).toBeLessThanOrEqual(DEFAULT_MAX_BYTES);
expect(result.output).toContain("a");
});
it("handles multi-million line output without freeze or OOM", async () => {
if (process.platform === "win32") return;
// 5 million lines ~= 40MB of output. Before the 64KB read buffer and
// direct-push fixes, this would freeze or OOM the process.
const lineCount = 5_000_000;
let chunkCount = 0;
const start = Date.now();
const result = await executeBash(`seq 1 ${lineCount}`, {
cwd: tempDir,
timeout: 30_000,
onChunk: () => {
chunkCount++;
},
});
const elapsed = Date.now() - start;
// Should complete, not hang or OOM
expect(result.exitCode).toBe(0);
expect(result.cancelled).toBe(false);
// Output summary should reflect all lines
expect(result.totalLines).toBeGreaterThanOrEqual(lineCount);
// Truncated output should be bounded by head + tail + marker overhead
// (middle-elision keeps the head budget plus the tail spill window).
expect(result.outputBytes).toBeLessThanOrEqual(DEFAULT_MAX_BYTES + ARTIFACT_HEAD_BYTES_DEFAULT + 1024);
// The tail should still contain numeric values near the end of the range.
// BSD `seq` on macOS formats large numbers in scientific notation, so parse
// the final lines numerically instead of matching one exact decimal string.
const tailValues = result.output
.split("\n")
.slice(-1000)
.map(line => Number(line.trim()))
.filter(Number.isFinite);
expect(tailValues.some(value => value >= lineCount - 500 && value <= lineCount)).toBe(true);
// With 64KB read buffer, ~40MB should produce ~600 chunks, not 5M.
// Allow generous headroom but ensure it's orders of magnitude below lineCount.
expect(chunkCount).toBeLessThan(lineCount / 100);
// Should complete in reasonable time (not frozen). On a modern machine
// seq 1 5000000 itself takes ~0.5s; with JS overhead allow 20s.
expect(elapsed).toBeLessThan(20_000);
}, 35_000);
it("sources snapshot env vars across session commands", async () => {
if (process.platform === "win32") {
return;
}
const bashPath = Bun.env.SHELL?.includes("bash") ? Bun.env.SHELL : "/bin/bash";
if (!fs.existsSync(bashPath)) {
return;
}
const snapshotPath = path.join(tempDir, "snapshot.sh");
fs.writeFileSync(snapshotPath, "export PI_SNAPSHOT_TEST=from_snapshot\n");
vi.spyOn(Settings.prototype, "getShellConfig").mockReturnValue({
shell: bashPath,
args: ["-l", "-c"],
env: {
PATH: Bun.env.PATH ?? "",
HOME: Bun.env.HOME ?? tempDir,
},
prefix: undefined,
});
vi.spyOn(shellSnapshot, "getOrCreateSnapshot").mockResolvedValue(snapshotPath);
const sessionKey = "snapshot-test";
await executeBash("true", { cwd: tempDir, timeout: 5000, sessionKey });
const result = await executeBash("echo $PI_SNAPSHOT_TEST", { cwd: tempDir, timeout: 5000, sessionKey });
expect(result.output.trim()).toBe("from_snapshot");
});
it("sources large bash functions without base64 eval wrappers", async () => {
if (process.platform === "win32") {
return;
}
const realBashPath = Bun.env.SHELL?.includes("bash") ? Bun.env.SHELL : "/bin/bash";
if (!fs.existsSync(realBashPath)) {
return;
}
const bashPath = path.join(tempDir, "test-bash");
fs.symlinkSync(realBashPath, bashPath);
const largeBody = Array.from({ length: 200 }, (_, index) => ` echo "snapshot ${index}"`).join("\n");
fs.writeFileSync(path.join(tempDir, ".bashrc"), `pi_snapshot_large_function ()\n{\n${largeBody}\n}\n`);
vi.spyOn(os, "homedir").mockReturnValue(tempDir);
vi.spyOn(Settings.prototype, "getShellConfig").mockReturnValue({
shell: bashPath,
args: ["-l", "-c"],
env: {
PATH: Bun.env.PATH ?? "",
HOME: tempDir,
},
prefix: undefined,
});
const snapshotPath = await shellSnapshot.getOrCreateSnapshot(bashPath, {
PATH: Bun.env.PATH ?? "",
HOME: tempDir,
});
expect(snapshotPath).not.toBeNull();
const snapshot = fs.readFileSync(snapshotPath!, "utf8");
expect(snapshot).toContain("pi_snapshot_large_function");
expect(snapshot).not.toContain("base64 -d");
const result = await executeBash("printf 'snapshot_ok\\n'", {
cwd: tempDir,
timeout: 5000,
sessionKey: "large-function-snapshot",
});
expect(result.cancelled).toBe(false);
expect(result.output.trim()).toBe("snapshot_ok");
});
it("does not allow exec to replace the host", async () => {
const result = await executeBash("exec echo hi", { cwd: tempDir, timeout: 5000 });
expect(result.cancelled).toBe(false);
expect(result.exitCode).not.toBeUndefined();
if (!result.output.includes("hi")) {
expect(result.output.toLowerCase()).toContain("exec");
}
});
it("completes even when background job keeps stdout pipe open", async () => {
if (process.platform === "win32") return;
const runPromise = executeBash("{ sleep 2; echo late; } & echo immediate", {
cwd: tempDir,
timeout: 5000,
});
const timed = await Promise.race([
runPromise.then(result => ({ type: "result" as const, result })),
Bun.sleep(BACKGROUND_COMPLETION_RACE_MS).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;
const marker = path.join(tempDir, "marker.txt");
const markerEscaped = marker.replace(/'/g, "'\\''");
// Command creates marker after a short delay, but we timeout before then.
const result = await executeBash(`sleep ${KILL_MARKER_DELAY_SECONDS} && echo done > '${markerEscaped}'`, {
cwd: tempDir,
timeout: 100,
});
expect(result.cancelled).toBe(true);
// Wait longer than the command would have needed to create the marker.
await Bun.sleep(KILL_MARKER_ASSERTION_WAIT_MS);
// If process was killed (not orphaned), marker should NOT exist
expect(fs.existsSync(marker)).toBe(false);
});
it("kills background jobs on timeout", async () => {
if (process.platform === "win32") return;
const marker = path.join(tempDir, "marker-bg.txt");
const markerEscaped = marker.replace(/'/g, "'\\''");
const result = await executeBash(
`{ sleep ${KILL_MARKER_DELAY_SECONDS}; echo done > '${markerEscaped}'; } & sleep 10`,
{
cwd: tempDir,
timeout: 100,
},
);
expect(result.cancelled).toBe(true);
await Bun.sleep(KILL_MARKER_ASSERTION_WAIT_MS);
expect(fs.existsSync(marker)).toBe(false);
});
it("kills background jobs on abort", async () => {
if (process.platform === "win32") return;
const marker = path.join(tempDir, "marker-bg-abort.txt");
const markerEscaped = marker.replace(/'/g, "'\\''");
const controller = new AbortController();
const promise = executeBash(
`{ sleep ${KILL_MARKER_DELAY_SECONDS}; echo done > '${markerEscaped}'; } & sleep 10`,
{
cwd: tempDir,
timeout: 10000,
signal: controller.signal,
},
);
await Bun.sleep(100);
controller.abort();
const result = await promise;
expect(result.cancelled).toBe(true);
expect(result.output).toContain("Command cancelled");
await Bun.sleep(KILL_MARKER_ASSERTION_WAIT_MS);
expect(fs.existsSync(marker)).toBe(false);
});
it("kills spawned process on abort (not just orphans it)", async () => {
if (process.platform === "win32") return;
const marker = path.join(tempDir, "marker.txt");
const markerEscaped = marker.replace(/'/g, "'\\''");
const controller = new AbortController();
// Command creates marker after a short delay.
const promise = executeBash(`sleep ${KILL_MARKER_DELAY_SECONDS} && echo done > '${markerEscaped}'`, {
cwd: tempDir,
timeout: 10000,
signal: controller.signal,
});
// Abort before the command can create the marker.
await Bun.sleep(100);
controller.abort();
const result = await promise;
expect(result.cancelled).toBe(true);
expect(result.output).toContain("Command cancelled");
// Wait longer than the command would have needed to create the marker.
await Bun.sleep(KILL_MARKER_ASSERTION_WAIT_MS);
// If process was killed (not orphaned), marker should NOT exist
expect(fs.existsSync(marker)).toBe(false);
});
});