Files
openclaw/src/agents/bash-tools.exec.background-abort.test.ts
2026-04-27 11:10:51 +01:00

297 lines
9.2 KiB
TypeScript

import { afterEach, beforeAll, beforeEach, expect, test, vi } from "vitest";
import { killProcessTree } from "../process/kill-tree.js";
const supervisorMockState = vi.hoisted(() => ({
cancelReasons: [] as Array<"manual-cancel" | "overall-timeout">,
spawnInputs: [] as Array<{ timeoutMs?: number }>,
}));
vi.mock("../process/supervisor/index.js", () => {
let counter = 0;
return {
getProcessSupervisor: () => ({
spawn: async (input: { timeoutMs?: number }) => {
supervisorMockState.spawnInputs.push(input);
const runId = `mock-run-${++counter}`;
let settled = false;
let settle = (_reason: "manual-cancel" | "overall-timeout", _timedOut: boolean) => {};
const waitPromise = new Promise<{
reason: "manual-cancel" | "overall-timeout";
exitCode: number | null;
exitSignal: NodeJS.Signals | number | null;
durationMs: number;
stdout: string;
stderr: string;
timedOut: boolean;
noOutputTimedOut: boolean;
}>((resolve) => {
settle = (reason, timedOut) => {
if (settled) {
return;
}
settled = true;
resolve({
reason,
exitCode: null,
exitSignal: null,
durationMs: input.timeoutMs ?? 0,
stdout: "",
stderr: "",
timedOut,
noOutputTimedOut: false,
});
};
if (input.timeoutMs !== undefined) {
setTimeout(() => settle("overall-timeout", true), 12);
}
});
return {
runId,
startedAtMs: Date.now(),
stdin: undefined,
wait: () => waitPromise,
cancel: () => {
supervisorMockState.cancelReasons.push("manual-cancel");
settle("manual-cancel", false);
},
};
},
cancel: vi.fn(),
cancelScope: vi.fn(),
reconcileOrphans: vi.fn(),
getRecord: vi.fn(),
}),
};
});
vi.mock("../infra/shell-env.js", () => ({
getShellPathFromLoginShell: vi.fn(() => null),
resolveShellEnvFallbackTimeoutMs: vi.fn(() => 0),
}));
vi.mock("./bash-tools.exec-host-gateway.js", () => ({
processGatewayAllowlist: vi.fn(async () => ({})),
}));
vi.mock("./bash-tools.exec-host-node.js", () => ({
executeNodeHostCommand: vi.fn(async () => {
throw new Error("node host not expected in background abort tests");
}),
}));
const BACKGROUND_HOLD_CMD =
process.platform === "win32" ? 'node -e "setTimeout(() => {}, 1000)"' : "exec sleep 1";
const ABORT_SETTLE_MS = process.platform === "win32" ? 200 : 0;
const POLL_INTERVAL_MS = process.platform === "win32" ? 15 : 5;
const FINISHED_WAIT_TIMEOUT_MS = process.platform === "win32" ? 8_000 : 1_000;
const BACKGROUND_TIMEOUT_SEC = process.platform === "win32" ? 0.2 : 0.02;
const TEST_EXEC_DEFAULTS = {
host: "gateway" as const,
security: "full" as const,
ask: "off" as const,
};
let createExecTool: typeof import("./bash-tools.exec.js").createExecTool;
let getFinishedSession: typeof import("./bash-process-registry.js").getFinishedSession;
let getSession: typeof import("./bash-process-registry.js").getSession;
let resetProcessRegistryForTests: typeof import("./bash-process-registry.js").resetProcessRegistryForTests;
type ExecToolExecuteParams = Parameters<ReturnType<typeof createExecTool>["execute"]>[1];
const createTestExecTool = (
defaults?: Parameters<typeof createExecTool>[0],
): ReturnType<typeof createExecTool> => createExecTool({ ...TEST_EXEC_DEFAULTS, ...defaults });
beforeAll(async () => {
({ createExecTool } = await import("./bash-tools.exec.js"));
({ getFinishedSession, getSession, resetProcessRegistryForTests } =
await import("./bash-process-registry.js"));
});
beforeEach(() => {
vi.clearAllMocks();
supervisorMockState.cancelReasons.length = 0;
supervisorMockState.spawnInputs.length = 0;
});
afterEach(() => {
resetProcessRegistryForTests();
});
async function waitForFinishedSession(sessionId: string) {
let finished = getFinishedSession(sessionId);
await expect
.poll(
() => {
finished = getFinishedSession(sessionId);
return Boolean(finished);
},
{
timeout: FINISHED_WAIT_TIMEOUT_MS,
interval: POLL_INTERVAL_MS,
},
)
.toBe(true);
return finished;
}
function cleanupRunningSession(sessionId: string) {
const running = getSession(sessionId);
const pid = running?.pid;
if (pid) {
killProcessTree(pid);
}
return running;
}
async function expectBackgroundSessionSurvivesAbort(params: {
tool: ReturnType<typeof createExecTool>;
executeParams: ExecToolExecuteParams;
}) {
const abortController = new AbortController();
const result = await params.tool.execute(
"toolcall",
params.executeParams,
abortController.signal,
);
expect(result.details.status).toBe("running");
const sessionId = (result.details as { sessionId: string }).sessionId;
abortController.abort();
if (ABORT_SETTLE_MS > 0) {
await new Promise((resolve) => setTimeout(resolve, ABORT_SETTLE_MS));
}
const running = getSession(sessionId);
const finished = getFinishedSession(sessionId);
try {
expect(supervisorMockState.cancelReasons).toEqual([]);
expect(finished).toBeUndefined();
expect(running?.exited).toBe(false);
} finally {
cleanupRunningSession(sessionId);
}
}
async function expectBackgroundSessionTimesOut(params: {
tool: ReturnType<typeof createExecTool>;
executeParams: ExecToolExecuteParams;
signal?: AbortSignal;
abortAfterStart?: boolean;
expectedTimeoutSec?: number;
}) {
const abortController = new AbortController();
const signal = params.signal ?? abortController.signal;
const result = await params.tool.execute("toolcall", params.executeParams, signal);
expect(result.details.status).toBe("running");
const sessionId = (result.details as { sessionId: string }).sessionId;
if (typeof params.expectedTimeoutSec === "number") {
expect(supervisorMockState.spawnInputs.at(-1)?.timeoutMs).toBe(
Math.floor(params.expectedTimeoutSec * 1000),
);
}
if (params.abortAfterStart) {
abortController.abort();
}
const finished = await waitForFinishedSession(sessionId);
try {
expect(finished).toBeTruthy();
expect(finished?.status).toBe("failed");
} finally {
cleanupRunningSession(sessionId);
}
}
test("background exec is not killed when tool signal aborts", async () => {
const tool = createTestExecTool({ allowBackground: true, backgroundMs: 0 });
await expectBackgroundSessionSurvivesAbort({
tool,
executeParams: { command: BACKGROUND_HOLD_CMD, background: true },
});
});
test("pty background exec is not killed when tool signal aborts", async () => {
const tool = createTestExecTool({ allowBackground: true, backgroundMs: 0 });
await expectBackgroundSessionSurvivesAbort({
tool,
executeParams: { command: BACKGROUND_HOLD_CMD, background: true, pty: true },
});
});
test("background exec still times out after tool signal abort", async () => {
const tool = createTestExecTool({ allowBackground: true, backgroundMs: 0 });
await expectBackgroundSessionTimesOut({
tool,
executeParams: {
command: BACKGROUND_HOLD_CMD,
background: true,
timeout: BACKGROUND_TIMEOUT_SEC,
},
abortAfterStart: true,
expectedTimeoutSec: BACKGROUND_TIMEOUT_SEC,
});
});
test("background exec without explicit timeout applies default timeout", async () => {
const tool = createTestExecTool({
allowBackground: true,
backgroundMs: 0,
timeoutSec: BACKGROUND_TIMEOUT_SEC,
});
await expectBackgroundSessionTimesOut({
tool,
executeParams: { command: BACKGROUND_HOLD_CMD, background: true },
expectedTimeoutSec: BACKGROUND_TIMEOUT_SEC,
});
});
test("background exec with timeout zero bypasses default timeout", async () => {
const tool = createTestExecTool({
allowBackground: true,
backgroundMs: 0,
timeoutSec: BACKGROUND_TIMEOUT_SEC,
});
const result = await tool.execute("toolcall", {
command: BACKGROUND_HOLD_CMD,
background: true,
timeout: 0,
});
expect(result.details.status).toBe("running");
const sessionId = (result.details as { sessionId: string }).sessionId;
expect(supervisorMockState.spawnInputs.at(-1)?.timeoutMs).toBeUndefined();
expect(getFinishedSession(sessionId)).toBeUndefined();
expect(getSession(sessionId)?.exited).toBe(false);
cleanupRunningSession(sessionId);
});
test("yielded background exec still times out", async () => {
const tool = createTestExecTool({ allowBackground: true, backgroundMs: 10 });
await expectBackgroundSessionTimesOut({
tool,
executeParams: {
command: BACKGROUND_HOLD_CMD,
yieldMs: 5,
timeout: BACKGROUND_TIMEOUT_SEC,
},
expectedTimeoutSec: BACKGROUND_TIMEOUT_SEC,
});
});
test("yieldMs exec without explicit timeout applies default timeout", async () => {
const tool = createTestExecTool({
allowBackground: true,
backgroundMs: 10,
timeoutSec: BACKGROUND_TIMEOUT_SEC,
});
await expectBackgroundSessionTimesOut({
tool,
executeParams: {
command: BACKGROUND_HOLD_CMD,
yieldMs: 5,
},
expectedTimeoutSec: BACKGROUND_TIMEOUT_SEC,
});
});