1094 lines
36 KiB
TypeScript
1094 lines
36 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, type ShellMinimizerSettings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
|
import { buildMinimizerOptions, 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 type { Shell } from "@oh-my-pi/pi-natives";
|
|
import * as piNatives from "@oh-my-pi/pi-natives";
|
|
import { removeSyncWithRetries } from "@oh-my-pi/pi-utils";
|
|
|
|
// 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;
|
|
// Killed-vs-orphaned proof: the command's marker write is gated on a `release`
|
|
// file the test creates only AFTER the cancel has landed. A truly killed process
|
|
// never reaches the write; an orphan still polling reacts within one poll
|
|
// interval. This replaces the old "sleep a fixed marker delay, then look"
|
|
// approach, which paid that delay in wall-clock time on every run.
|
|
const KILL_POLL_SECONDS = "0.01"; // a survivor re-checks `release` every ~10ms
|
|
const KILL_SETTLE_MS = 25; // let the kill signal land before we touch `release`
|
|
const KILL_REACT_MS = 50; // > one poll interval: a survivor would write its marker
|
|
|
|
function makeTempDir(): string {
|
|
return fs.mkdtempSync(path.join(os.tmpdir(), "omp-bash-exec-"));
|
|
}
|
|
|
|
function shellQuote(value: string): string {
|
|
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
}
|
|
|
|
function configureBashUserShell(homeDir: string): boolean {
|
|
if (process.platform === "win32" || !fs.existsSync("/bin/bash")) return false;
|
|
Settings.instance.set("shellPath", "/bin/bash");
|
|
vi.spyOn(Settings.prototype, "getShellConfig").mockReturnValue({
|
|
shell: "/bin/bash",
|
|
args: ["-c"],
|
|
env: {
|
|
PATH: Bun.env.PATH ?? "",
|
|
HOME: homeDir,
|
|
SHELL: "/bin/bash",
|
|
},
|
|
prefix: undefined,
|
|
});
|
|
return true;
|
|
}
|
|
|
|
/** Resolve once `predicate()` holds or `deadlineMs` passes, polling every 2ms. */
|
|
async function pollUntil(predicate: () => boolean, deadlineMs: number): Promise<void> {
|
|
while (!predicate() && Date.now() < deadlineMs) {
|
|
await Bun.sleep(2);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Shell that blocks until `release` exists, then writes `marker`. Optionally
|
|
* touches `started` first so a test can wait until the command is actually
|
|
* running before cancelling it.
|
|
*/
|
|
function releaseGuardedWrite(marker: string, release: string, started?: string): string {
|
|
const touch = started ? `touch ${shellQuote(started)}; ` : "";
|
|
return `${touch}while [ ! -f ${shellQuote(release)} ]; do sleep ${KILL_POLL_SECONDS}; done; echo done > ${shellQuote(marker)}`;
|
|
}
|
|
|
|
/**
|
|
* After a cancel has been observed, prove the command was killed (not orphaned):
|
|
* settle so the kill lands, unblock a would-be survivor via `release`, then give
|
|
* it more than one poll interval to write. A killed command never writes `marker`.
|
|
*/
|
|
async function expectMarkerNeverWritten(marker: string, release: string): Promise<void> {
|
|
await Bun.sleep(KILL_SETTLE_MS);
|
|
fs.writeFileSync(release, "");
|
|
await Bun.sleep(KILL_REACT_MS);
|
|
expect(fs.existsSync(marker)).toBe(false);
|
|
}
|
|
|
|
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)) {
|
|
removeSyncWithRetries(tempDir);
|
|
}
|
|
});
|
|
|
|
it("omits minimizer options when the feature is disabled", () => {
|
|
const group: ShellMinimizerSettings = {
|
|
enabled: false,
|
|
settingsPath: undefined,
|
|
only: [],
|
|
except: [],
|
|
maxCaptureBytes: 4096,
|
|
sourceOutlineLevel: "default",
|
|
legacyFilters: undefined,
|
|
};
|
|
expect(buildMinimizerOptions(group)).toBeUndefined();
|
|
});
|
|
|
|
it("forwards source outline and legacy filter settings to native minimizer options", () => {
|
|
const group: ShellMinimizerSettings = {
|
|
enabled: true,
|
|
settingsPath: "minimizer.toml",
|
|
only: ["git"],
|
|
except: ["docker"],
|
|
maxCaptureBytes: 1234,
|
|
sourceOutlineLevel: "aggressive",
|
|
legacyFilters: true,
|
|
};
|
|
expect(buildMinimizerOptions(group)).toEqual({
|
|
enabled: true,
|
|
settingsPath: "minimizer.toml",
|
|
only: ["git"],
|
|
except: ["docker"],
|
|
maxCaptureBytes: 1234,
|
|
sourceOutlineLevel: "aggressive",
|
|
legacyFilters: 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(tempDir);
|
|
});
|
|
|
|
it("honors symlinked cwd requests in persistent shells", async () => {
|
|
if (process.platform === "win32") {
|
|
return;
|
|
}
|
|
if (!configureBashUserShell(tempDir)) return;
|
|
|
|
const realDir = path.join(tempDir, "real");
|
|
const linkDir = path.join(tempDir, "link");
|
|
fs.mkdirSync(realDir);
|
|
fs.symlinkSync(realDir, linkDir, "dir");
|
|
const sessionKey = `cwd-symlink-${Date.now()}`;
|
|
|
|
await executeBash("pwd", { sessionKey, cwd: realDir, timeout: 5000, useUserShell: true });
|
|
const result = await executeBash("pwd", { sessionKey, cwd: linkDir, timeout: 5000, useUserShell: true });
|
|
|
|
expect(result.output.trim()).toBe(linkDir);
|
|
expect(result.workingDir).toBe(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("runs non-bash shellPath commands through the configured shell", async () => {
|
|
if (process.platform === "win32") {
|
|
return;
|
|
}
|
|
|
|
const shellDir = fs.mkdtempSync(path.join(os.tmpdir(), "omp-shellpath-"));
|
|
const marker = path.join(shellDir, "fake-shell-ran");
|
|
const markerEscaped = marker.replace(/'/g, "'\\''");
|
|
const fakeShell = path.join(shellDir, "fake-shell");
|
|
fs.writeFileSync(
|
|
fakeShell,
|
|
`#!/bin/sh
|
|
printf '%s\\n' "$*" > '${markerEscaped}'
|
|
while [ "$#" -gt 0 ]; do
|
|
if [ "$1" = "-c" ]; then
|
|
shift
|
|
exec /bin/sh -c "$1"
|
|
fi
|
|
shift
|
|
done
|
|
exit 64
|
|
`,
|
|
);
|
|
fs.chmodSync(fakeShell, 0o755);
|
|
Settings.instance.set("shellPath", fakeShell);
|
|
|
|
vi.spyOn(Settings.prototype, "getShellConfig").mockReturnValue({
|
|
shell: fakeShell,
|
|
args: ["-l", "-c"],
|
|
env: {
|
|
PATH: Bun.env.PATH ?? "",
|
|
HOME: tempDir,
|
|
},
|
|
prefix: undefined,
|
|
});
|
|
|
|
try {
|
|
const result = await executeBash("printf 'shell-ok\\n'", {
|
|
cwd: tempDir,
|
|
timeout: 5000,
|
|
sessionKey: "custom-shell-path",
|
|
useUserShell: true,
|
|
});
|
|
|
|
expect(result.cancelled).toBe(false);
|
|
expect(result.exitCode).toBe(0);
|
|
expect(result.output.trim()).toBe("shell-ok");
|
|
expect(fs.readFileSync(marker, "utf8")).toContain("-l -c");
|
|
} finally {
|
|
removeSyncWithRetries(shellDir);
|
|
}
|
|
});
|
|
|
|
it("uses executable SHELL for user-shell shortcut commands", async () => {
|
|
if (process.platform === "win32") {
|
|
return;
|
|
}
|
|
|
|
const originalShell = Bun.env.SHELL;
|
|
const shellDir = fs.mkdtempSync(path.join(os.tmpdir(), "omp-env-shell-"));
|
|
const marker = path.join(shellDir, "env-shell-ran");
|
|
const markerEscaped = marker.replace(/'/g, "'\\''");
|
|
const fakeShell = path.join(shellDir, "fish");
|
|
fs.writeFileSync(
|
|
fakeShell,
|
|
`#!/bin/sh
|
|
printf '%s\\n' "$*" > '${markerEscaped}'
|
|
while [ "$#" -gt 0 ]; do
|
|
if [ "$1" = "-c" ]; then
|
|
shift
|
|
exec /bin/sh -c "$1"
|
|
fi
|
|
shift
|
|
done
|
|
exit 64
|
|
`,
|
|
);
|
|
fs.chmodSync(fakeShell, 0o755);
|
|
Bun.env.SHELL = fakeShell;
|
|
|
|
vi.spyOn(Settings.prototype, "getShellConfig").mockReturnValue({
|
|
shell: "/bin/bash",
|
|
args: ["-l", "-c"],
|
|
env: {
|
|
PATH: Bun.env.PATH ?? "",
|
|
HOME: tempDir,
|
|
SHELL: "/bin/bash",
|
|
},
|
|
prefix: undefined,
|
|
});
|
|
|
|
try {
|
|
const result = await executeBash("printf 'env-shell-ok\\n'", {
|
|
cwd: tempDir,
|
|
timeout: 5000,
|
|
sessionKey: "env-user-shell",
|
|
useUserShell: true,
|
|
});
|
|
|
|
expect(result.cancelled).toBe(false);
|
|
expect(result.exitCode).toBe(0);
|
|
expect(result.output.trim()).toBe("env-shell-ok");
|
|
expect(fs.readFileSync(marker, "utf8")).toContain("-l -c");
|
|
expect(fs.readFileSync(marker, "utf8")).not.toContain("-i");
|
|
} finally {
|
|
if (originalShell === undefined) {
|
|
delete Bun.env.SHELL;
|
|
} else {
|
|
Bun.env.SHELL = originalShell;
|
|
}
|
|
removeSyncWithRetries(shellDir);
|
|
}
|
|
});
|
|
|
|
it("loads zshrc aliases for user-shell shortcut commands", async () => {
|
|
if (process.platform === "win32") {
|
|
return;
|
|
}
|
|
|
|
const zshPath = ["/bin/zsh", "/usr/bin/zsh", "/usr/local/bin/zsh", "/opt/homebrew/bin/zsh"].find(candidate =>
|
|
fs.existsSync(candidate),
|
|
);
|
|
if (!zshPath) {
|
|
return;
|
|
}
|
|
|
|
const shellDir = fs.mkdtempSync(path.join(os.tmpdir(), "omp-zsh-shellpath-"));
|
|
fs.writeFileSync(path.join(shellDir, ".zshrc"), "alias pi_shell_alias='printf zsh-alias-ok\\\\n'\n");
|
|
Settings.instance.set("shellPath", zshPath);
|
|
|
|
vi.spyOn(Settings.prototype, "getShellConfig").mockReturnValue({
|
|
shell: zshPath,
|
|
args: ["-l", "-c"],
|
|
env: {
|
|
PATH: Bun.env.PATH ?? "",
|
|
HOME: shellDir,
|
|
},
|
|
prefix: undefined,
|
|
});
|
|
|
|
try {
|
|
const result = await executeBash("pi_shell_alias", {
|
|
cwd: tempDir,
|
|
timeout: 5000,
|
|
sessionKey: "zsh-shell-path",
|
|
useUserShell: true,
|
|
});
|
|
|
|
expect(result.cancelled).toBe(false);
|
|
expect(result.exitCode).toBe(0);
|
|
expect(result.output.trim()).toBe("zsh-alias-ok");
|
|
} finally {
|
|
removeSyncWithRetries(shellDir);
|
|
}
|
|
});
|
|
|
|
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;
|
|
}
|
|
|
|
// Redirect the backgrounded job's stdout so it doesn't hold the executor's
|
|
// output pipe open (which would add the ~250ms background-drain grace);
|
|
// `$!` still reports the real external PID, which is all this test checks.
|
|
const result = await executeBash('python3 -c "import time; time.sleep(10)" >/dev/null 2>&1 & 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("does not arm a deadline when timeout is zero", async () => {
|
|
if (process.platform === "win32") {
|
|
return;
|
|
}
|
|
const result = await executeBash("sleep 1.2; echo done", { cwd: tempDir, timeout: 0 });
|
|
expect(result.cancelled).toBe(false);
|
|
expect(result.output.trim()).toBe("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 and quarantines the session key when native abort cleanup stalls", async () => {
|
|
if (process.platform === "win32") {
|
|
return;
|
|
}
|
|
|
|
const originalRun = piNatives.Shell.prototype.run;
|
|
let runCalls = 0;
|
|
const dispatched = Promise.withResolvers<void>();
|
|
vi.spyOn(piNatives.Shell.prototype, "run").mockImplementation(function (this: Shell, options, onChunk) {
|
|
runCalls++;
|
|
if (runCalls === 1) {
|
|
onChunk?.(null, "started\n");
|
|
dispatched.resolve();
|
|
return new Promise(() => {});
|
|
}
|
|
return originalRun.call(this, options, onChunk);
|
|
});
|
|
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 dispatched.promise;
|
|
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();
|
|
|
|
const next = await executeBash("echo next", {
|
|
cwd: tempDir,
|
|
timeout: 5000,
|
|
sessionKey: "hung-native-abort",
|
|
});
|
|
expect(next.output.trim()).toBe("next");
|
|
expect(runCalls).toBe(1);
|
|
});
|
|
|
|
it("restores persistent sessions after native abort cleanup settles", async () => {
|
|
if (process.platform === "win32") {
|
|
return;
|
|
}
|
|
|
|
const nativeResult = Promise.withResolvers<{ exitCode: undefined; cancelled: true; timedOut: false }>();
|
|
const dispatched = Promise.withResolvers<void>();
|
|
vi.spyOn(piNatives.Shell.prototype, "run").mockImplementation((_options, onChunk) => {
|
|
onChunk?.(null, "started\n");
|
|
dispatched.resolve();
|
|
return nativeResult.promise;
|
|
});
|
|
vi.spyOn(piNatives.Shell.prototype, "abort").mockResolvedValue();
|
|
|
|
const controller = new AbortController();
|
|
const promise = executeBash("sleep 10", {
|
|
cwd: tempDir,
|
|
timeout: 5000,
|
|
signal: controller.signal,
|
|
sessionKey: "settled-native-abort",
|
|
});
|
|
await dispatched.promise;
|
|
controller.abort();
|
|
await promise;
|
|
|
|
nativeResult.resolve({ exitCode: undefined, cancelled: true, timedOut: false });
|
|
await Bun.sleep(0);
|
|
vi.restoreAllMocks();
|
|
|
|
await executeBash("export PI_AFTER_ABORT=still_persistent", {
|
|
cwd: tempDir,
|
|
timeout: 5000,
|
|
sessionKey: "settled-native-abort",
|
|
});
|
|
const next = await executeBash("printf '%s\n' \"$PI_AFTER_ABORT\"", {
|
|
cwd: tempDir,
|
|
timeout: 5000,
|
|
sessionKey: "settled-native-abort",
|
|
});
|
|
expect(next.output.trim()).toBe("still_persistent");
|
|
});
|
|
|
|
it("does not abort the native signal when the JavaScript timeout fallback returns streamed output", async () => {
|
|
// Compress the JS-side fallback timer (floored at 1000ms in the source) so
|
|
// the safety-net fires deterministically without a real 1s wait. Only long
|
|
// timers are shrunk — fs/subprocess setup keeps real scheduling — and the
|
|
// reported "1 seconds" derives from the configured timeout, not the timer.
|
|
const realSetTimeout = globalThis.setTimeout;
|
|
vi.spyOn(globalThis, "setTimeout").mockImplementation(((handler: () => void, ms?: number, ...rest: unknown[]) =>
|
|
realSetTimeout(
|
|
handler,
|
|
typeof ms === "number" && ms >= 1000 ? 5 : ms,
|
|
...rest,
|
|
)) as typeof globalThis.setTimeout);
|
|
|
|
let nativeSignal: AbortSignal | undefined;
|
|
vi.spyOn(piNatives.Shell.prototype, "run").mockImplementation((options, onChunk) => {
|
|
if (options.signal instanceof AbortSignal) {
|
|
nativeSignal = options.signal;
|
|
}
|
|
onChunk?.(null, "streamed-before-timeout\n");
|
|
return Promise.withResolvers<never>().promise;
|
|
});
|
|
const abortSpy = vi.spyOn(piNatives.Shell.prototype, "abort").mockResolvedValue();
|
|
|
|
const result = await executeBash("sleep 10", {
|
|
cwd: tempDir,
|
|
timeout: 1000,
|
|
sessionKey: "explicit-timeout-keeps-native-signal",
|
|
});
|
|
|
|
expect(result.cancelled).toBe(true);
|
|
expect(result.output).toContain("streamed-before-timeout");
|
|
expect(result.output).toContain("Command timed out after 1 seconds");
|
|
expect(nativeSignal).toBeDefined();
|
|
expect(nativeSignal?.aborted).toBe(false);
|
|
expect(abortSpy).not.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(50);
|
|
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();
|
|
// Abort only once the command is actually running in the persistent
|
|
// session. Aborting on a fixed timer races executeBash's async setup
|
|
// (settings + snapshot load); under load the abort can land in the
|
|
// early-abort short-circuit before the shell ever runs, leaving the
|
|
// session unreset — the source of the flaky "alive" result here.
|
|
const startMarker = path.join(tempDir, "reset-on-abort.started");
|
|
const abortPromise = executeBash(`touch ${startMarker}; sleep 10`, {
|
|
cwd: tempDir,
|
|
timeout: 5000,
|
|
signal: controller.signal,
|
|
sessionKey,
|
|
});
|
|
const startDeadline = Date.now() + 4000;
|
|
while (!fs.existsSync(startMarker) && Date.now() < startDeadline) {
|
|
await Bun.sleep(2);
|
|
}
|
|
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("runs overlapping calls on the same session key concurrently", async () => {
|
|
if (process.platform === "win32") return;
|
|
|
|
const sessionKey = "parallel-overlap";
|
|
const order: string[] = [];
|
|
const slow = executeBash('sleep 0.15 && echo "A-done"', { cwd: tempDir, timeout: 5000, sessionKey }).then(
|
|
result => {
|
|
order.push("slow");
|
|
return result;
|
|
},
|
|
);
|
|
const fast = executeBash('echo "B-done"', { cwd: tempDir, timeout: 5000, sessionKey }).then(result => {
|
|
order.push("fast");
|
|
return result;
|
|
});
|
|
|
|
const [slowResult, fastResult] = await Promise.all([slow, fast]);
|
|
expect(slowResult.exitCode).toBe(0);
|
|
expect(slowResult.output).toContain("A-done");
|
|
expect(fastResult.exitCode).toBe(0);
|
|
expect(fastResult.output).toContain("B-done");
|
|
// If the second call had queued behind the persistent session it could
|
|
// not finish before the 150ms sleep of the first.
|
|
expect(order).toEqual(["fast", "slow"]);
|
|
});
|
|
|
|
it("keeps the owner session usable when an overlapping call times out", async () => {
|
|
if (process.platform === "win32") return;
|
|
|
|
const sessionKey = "parallel-timeout-isolation";
|
|
const started = path.join(tempDir, "owner.started");
|
|
const release = path.join(tempDir, "owner.release");
|
|
// The owner holds the persistent session open (blocked on `release`) so the
|
|
// overlapping call is guaranteed to find the session busy and degrade to an
|
|
// isolated one-shot shell — the path whose timeout cleanup must NOT touch
|
|
// the owner's persistent session.
|
|
const owner = executeBash(
|
|
`touch ${shellQuote(started)}; while [ ! -f ${shellQuote(release)} ]; do sleep 0.02; done; echo "owner-done"`,
|
|
{ cwd: tempDir, timeout: 5000, sessionKey },
|
|
);
|
|
await pollUntil(() => fs.existsSync(started), Date.now() + 4000);
|
|
expect(fs.existsSync(started)).toBe(true);
|
|
|
|
// Overlaps the owner; degrades to an isolated shell and times out there.
|
|
const overlapping = await executeBash("sleep 5", { cwd: tempDir, timeout: 100, sessionKey });
|
|
expect(overlapping.cancelled).toBe(true);
|
|
|
|
// The overlapping timeout must not quarantine or delete the persistent
|
|
// session owned by the first call: release it and confirm it completes.
|
|
fs.writeFileSync(release, "");
|
|
const ownerResult = await owner;
|
|
expect(ownerResult.exitCode).toBe(0);
|
|
expect(ownerResult.output).toContain("owner-done");
|
|
|
|
const after = await executeBash('echo "still-ok"', { cwd: tempDir, timeout: 5000, sessionKey });
|
|
expect(after.exitCode).toBe(0);
|
|
expect(after.output).toContain("still-ok");
|
|
});
|
|
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 large output without freeze or OOM", async () => {
|
|
if (process.platform === "win32") return;
|
|
|
|
// Once raw output exceeds the truncation cap, the streaming + middle-elision
|
|
// path is volume-independent, so a few hundred KB exercises the same
|
|
// no-freeze / no-OOM contract the original 40MB did without paying several
|
|
// seconds to generate it. 100k lines of `seq` is ~690KB — an order of
|
|
// magnitude past the ~71KB head+tail cap asserted below.
|
|
const lineCount = 100_000;
|
|
let chunkCount = 0;
|
|
const start = Date.now();
|
|
const result = await executeBash(`seq 1 ${lineCount}`, {
|
|
cwd: tempDir,
|
|
timeout: 10_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 reflects every line even though the visible text is capped.
|
|
expect(result.totalLines).toBeGreaterThanOrEqual(lineCount);
|
|
|
|
// Truncated output stays bounded by head + tail + marker overhead
|
|
// (middle-elision keeps the head budget plus the tail spill window) — proof
|
|
// the full ~690KB stream was never accumulated in the visible buffer.
|
|
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);
|
|
|
|
// Chunks are coalesced by the read buffer, so onChunk fires orders of
|
|
// magnitude less often than once per line — the proof the stream is neither
|
|
// delivered line-by-line nor buffered whole.
|
|
expect(chunkCount).toBeLessThan(lineCount / 100);
|
|
|
|
// Should complete promptly (not frozen).
|
|
expect(elapsed).toBeLessThan(10_000);
|
|
}, 15_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("survives compound aliases from the user's shell snapshot (issue #3234)", async () => {
|
|
if (process.platform === "win32") return;
|
|
const bashPath = Bun.env.SHELL?.includes("bash") ? Bun.env.SHELL : "/bin/bash";
|
|
if (!fs.existsSync(bashPath)) return;
|
|
|
|
// Pre-seed a snapshot that mirrors Fedora's default `which` alias.
|
|
// Without the brush-compat scrub, brush's whitespace-only alias
|
|
// expander turns `(alias;` into the command name and `which` fails
|
|
// with `command not found: (alias;`. With the scrub, the broken
|
|
// alias is dropped and brush falls through to `$PATH`.
|
|
const snapshotPath = path.join(tempDir, "snapshot.sh");
|
|
fs.writeFileSync(
|
|
snapshotPath,
|
|
[
|
|
"unalias -a 2>/dev/null || true",
|
|
"alias -- which='(alias; declare -f) | /usr/bin/which --tty-only --read-alias --show-dot --show-tilde'",
|
|
"alias -- ll='ls -l'",
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
const rawSnapshot = fs.readFileSync(snapshotPath, "utf8");
|
|
const { content: scrubbed, dropped } = shellSnapshot.sanitizeSnapshotForBrush(rawSnapshot);
|
|
fs.writeFileSync(snapshotPath, scrubbed);
|
|
expect(dropped).toEqual(["which"]);
|
|
// Compatible aliases must still be installed in brush.
|
|
expect(scrubbed).toContain("alias -- ll='ls -l'");
|
|
|
|
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 result = await executeBash("which sh", {
|
|
cwd: tempDir,
|
|
timeout: 5000,
|
|
sessionKey: "brush-compound-alias-which",
|
|
});
|
|
|
|
expect(result.cancelled).toBe(false);
|
|
expect(result.exitCode).toBe(0);
|
|
expect(result.output).not.toContain("command not found");
|
|
expect(result.output.trim()).toMatch(/\/sh$/);
|
|
});
|
|
|
|
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 release = path.join(tempDir, "marker.release");
|
|
|
|
// The foreground command can only write its marker once `release` exists,
|
|
// which we never create until after the timeout fires. A killed process
|
|
// never reaches the write; an un-killed one would the moment we release it.
|
|
const result = await executeBash(releaseGuardedWrite(marker, release), {
|
|
cwd: tempDir,
|
|
timeout: 50,
|
|
});
|
|
|
|
expect(result.cancelled).toBe(true);
|
|
await expectMarkerNeverWritten(marker, release);
|
|
});
|
|
|
|
it("kills background jobs on timeout", async () => {
|
|
if (process.platform === "win32") return;
|
|
|
|
const marker = path.join(tempDir, "marker-bg.txt");
|
|
const release = path.join(tempDir, "marker-bg.release");
|
|
|
|
// The marker writer is a backgrounded subshell that survives the foreground
|
|
// `sleep` unless the whole process group is killed on timeout.
|
|
const result = await executeBash(`{ ${releaseGuardedWrite(marker, release)}; } & sleep 10`, {
|
|
cwd: tempDir,
|
|
timeout: 50,
|
|
});
|
|
|
|
expect(result.cancelled).toBe(true);
|
|
await expectMarkerNeverWritten(marker, release);
|
|
});
|
|
|
|
it("kills background jobs on abort", async () => {
|
|
if (process.platform === "win32") return;
|
|
|
|
const marker = path.join(tempDir, "marker-bg-abort.txt");
|
|
const release = path.join(tempDir, "marker-bg-abort.release");
|
|
const started = path.join(tempDir, "marker-bg-abort.started");
|
|
const controller = new AbortController();
|
|
|
|
const promise = executeBash(`{ ${releaseGuardedWrite(marker, release, started)}; } & sleep 10`, {
|
|
cwd: tempDir,
|
|
timeout: 10000,
|
|
signal: controller.signal,
|
|
});
|
|
|
|
// Abort only once the backgrounded subshell is actually running.
|
|
await pollUntil(() => fs.existsSync(started), Date.now() + 4000);
|
|
expect(fs.existsSync(started)).toBe(true);
|
|
controller.abort();
|
|
const result = await promise;
|
|
|
|
expect(result.cancelled).toBe(true);
|
|
expect(result.output).toContain("Command cancelled");
|
|
await expectMarkerNeverWritten(marker, release);
|
|
});
|
|
|
|
it("kills spawned process on abort (not just orphans it)", async () => {
|
|
if (process.platform === "win32") return;
|
|
|
|
const marker = path.join(tempDir, "marker.txt");
|
|
const release = path.join(tempDir, "marker.release");
|
|
const started = path.join(tempDir, "marker.started");
|
|
const controller = new AbortController();
|
|
|
|
const promise = executeBash(releaseGuardedWrite(marker, release, started), {
|
|
cwd: tempDir,
|
|
timeout: 10000,
|
|
signal: controller.signal,
|
|
});
|
|
|
|
// Abort only once the foreground command is actually running.
|
|
await pollUntil(() => fs.existsSync(started), Date.now() + 4000);
|
|
expect(fs.existsSync(started)).toBe(true);
|
|
controller.abort();
|
|
const result = await promise;
|
|
|
|
expect(result.cancelled).toBe(true);
|
|
expect(result.output).toContain("Command cancelled");
|
|
await expectMarkerNeverWritten(marker, release);
|
|
});
|
|
});
|
|
|
|
describe("executeBash :async: background retention", () => {
|
|
let tmp: string;
|
|
|
|
beforeEach(async () => {
|
|
tmp = makeTempDir();
|
|
resetSettingsForTest();
|
|
await Settings.init({ inMemory: true, cwd: tmp });
|
|
});
|
|
|
|
afterEach(() => {
|
|
resetSettingsForTest();
|
|
vi.restoreAllMocks();
|
|
if (fs.existsSync(tmp)) removeSyncWithRetries(tmp);
|
|
});
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"keeps a per-job :async: shell's plain-`&` background process alive across turns",
|
|
async () => {
|
|
const pidFile = path.join(tmp, "pid");
|
|
const sleepBin = fs.existsSync("/bin/sleep") ? "/bin/sleep" : "sleep";
|
|
let pid: number | undefined;
|
|
try {
|
|
// A per-job `:async:` key: its shell is removed from the reuse map at
|
|
// teardown, which would SIGKILL the backgrounded child (kill-on-drop).
|
|
// A plain `&` job stays a child of the shell, so `liveBackgroundJobCount`
|
|
// sees it and the retain logic keeps the shell alive while the child
|
|
// runs. `$!` is the external child's own pid (no transparent wrapper to
|
|
// unwrap), so it is the process we assert on.
|
|
const res = await executeBash(`${sleepBin} 30 >/dev/null 2>&1 & echo $! > ${shellQuote(pidFile)}`, {
|
|
sessionKey: "retain-probe:async:job1",
|
|
cwd: tmp,
|
|
});
|
|
expect(res.cancelled).toBe(false);
|
|
pid = Number.parseInt(fs.readFileSync(pidFile, "utf8").trim(), 10);
|
|
expect(Number.isInteger(pid)).toBe(true);
|
|
|
|
// A later turn on a different per-job shell must not have killed it.
|
|
await executeBash("true", { sessionKey: "retain-probe:async:job2", cwd: tmp });
|
|
|
|
let alive = true;
|
|
try {
|
|
process.kill(pid, 0);
|
|
} catch {
|
|
alive = false;
|
|
}
|
|
expect(alive).toBe(true);
|
|
} finally {
|
|
if (pid !== undefined) {
|
|
try {
|
|
process.kill(pid, "SIGKILL");
|
|
} catch {}
|
|
}
|
|
}
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"keeps a nohup-detached background process alive across turns (reparenting)",
|
|
async () => {
|
|
const pidFile = path.join(tmp, "nohup-pid");
|
|
const sleepBin = fs.existsSync("/bin/sleep") ? "/bin/sleep" : "sleep";
|
|
let pid: number | undefined;
|
|
try {
|
|
// `nohup cmd &` is a transparent background wrapper: brush unwraps it and
|
|
// double-forks the operand so it reparents to init and survives teardown
|
|
// independently of the retain map. The shell only ever tracked the
|
|
// short-lived intermediate fork, so `$!` is NOT the surviving process —
|
|
// the operand writes its own pid before `exec`ing the long sleep, and
|
|
// that pid (unchanged across exec) is the one we assert stays alive.
|
|
const operand = `echo $$ > ${pidFile}; exec ${sleepBin} 30`;
|
|
const res = await executeBash(`nohup sh -c ${shellQuote(operand)} >/dev/null 2>&1 &`, {
|
|
sessionKey: "reparent-probe:async:job1",
|
|
cwd: tmp,
|
|
});
|
|
expect(res.cancelled).toBe(false);
|
|
|
|
await pollUntil(() => fs.existsSync(pidFile), Date.now() + 4000);
|
|
pid = Number.parseInt(fs.readFileSync(pidFile, "utf8").trim(), 10);
|
|
expect(Number.isInteger(pid)).toBe(true);
|
|
|
|
// A later turn on a different per-job shell must not have killed it.
|
|
await executeBash("true", { sessionKey: "reparent-probe:async:job2", cwd: tmp });
|
|
|
|
let alive = true;
|
|
try {
|
|
process.kill(pid, 0);
|
|
} catch {
|
|
alive = false;
|
|
}
|
|
expect(alive).toBe(true);
|
|
} finally {
|
|
if (pid !== undefined) {
|
|
try {
|
|
process.kill(pid, "SIGKILL");
|
|
} catch {}
|
|
}
|
|
}
|
|
},
|
|
);
|
|
});
|