mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 03:45:46 -06:00
312 lines
12 KiB
TypeScript
312 lines
12 KiB
TypeScript
import { ChildProcess } from "node:child_process";
|
|
import { constants } from "node:os";
|
|
import { expect, test, vi } from "vitest";
|
|
import {
|
|
getActiveBackgroundExecSessionCount,
|
|
listRunningSessions,
|
|
} from "../../../../src/agents/bash-process-registry.js";
|
|
import { resetProcessRegistryForTests } from "../../../../src/agents/bash-process-registry.test-support.js";
|
|
import { createExecTool, createProcessTool } from "../../../../src/agents/bash-tools.js";
|
|
import { getProcessSupervisor } from "../../../../src/process/supervisor/index.js";
|
|
|
|
type ExecTool = ReturnType<typeof createExecTool>;
|
|
type ProcessTool = ReturnType<typeof createProcessTool>;
|
|
type ToolResult = Awaited<ReturnType<ExecTool["execute"]>>;
|
|
type ProcessDetails = {
|
|
aggregated?: string;
|
|
exitReason?: string;
|
|
pid?: number;
|
|
sessionId?: string;
|
|
sessions?: Array<{ sessionId: string; status: string }>;
|
|
status?: string;
|
|
timedOut?: boolean;
|
|
};
|
|
|
|
const POLL_OPTIONS = { timeout: 10_000, interval: 25 };
|
|
|
|
function textOf(result: { content: Array<{ type: string; text?: string }> }): string {
|
|
return result.content.find((part) => part.type === "text")?.text ?? "";
|
|
}
|
|
|
|
function shellQuote(value: string): string {
|
|
return `'${value.replaceAll("'", process.platform === "win32" ? "''" : "'\\''")}'`;
|
|
}
|
|
|
|
function nodeEvalCommand(source: string): string {
|
|
const node = shellQuote(process.execPath);
|
|
const script = shellQuote(source);
|
|
return process.platform === "win32" ? `& ${node} -e ${script}` : `${node} -e ${script}`;
|
|
}
|
|
|
|
function requireSession(result: ToolResult): { pid: number; sessionId: string } {
|
|
const details = result.details as ProcessDetails;
|
|
expect(details.status).toBe("running");
|
|
expect(details.sessionId).toEqual(expect.any(String));
|
|
expect(details.pid).toEqual(expect.any(Number));
|
|
return { pid: details.pid as number, sessionId: details.sessionId as string };
|
|
}
|
|
|
|
function pidExists(pid: number): boolean {
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function pollTerminal(processTool: ProcessTool, sessionId: string) {
|
|
let terminal: Awaited<ReturnType<ProcessTool["execute"]>> | undefined;
|
|
await expect
|
|
.poll(async () => {
|
|
terminal = await processTool.execute(`poll-${sessionId}`, {
|
|
action: "poll",
|
|
sessionId,
|
|
timeout: 250,
|
|
});
|
|
return (terminal.details as ProcessDetails).status;
|
|
}, POLL_OPTIONS)
|
|
.not.toBe("running");
|
|
if (!terminal) {
|
|
throw new Error(`process ${sessionId} never produced a terminal result`);
|
|
}
|
|
return terminal;
|
|
}
|
|
|
|
async function clearFinished(processTool: ProcessTool, sessionId: string): Promise<void> {
|
|
const cleared = await processTool.execute(`clear-${sessionId}`, {
|
|
action: "clear",
|
|
sessionId,
|
|
});
|
|
expect(cleared.details).toMatchObject({ status: "completed" });
|
|
}
|
|
|
|
test("OpenClaw executes and controls the complete real process lifecycle", async () => {
|
|
resetProcessRegistryForTests();
|
|
const scopeKey = `agent:qa:exec-lifecycle-${process.pid}`;
|
|
const execTool = createExecTool({
|
|
host: "gateway",
|
|
security: "full",
|
|
ask: "off",
|
|
allowBackground: true,
|
|
backgroundMs: 20,
|
|
notifyOnExit: false,
|
|
scopeKey,
|
|
});
|
|
const foregroundExecTool = createExecTool({
|
|
host: "gateway",
|
|
security: "full",
|
|
ask: "off",
|
|
allowBackground: false,
|
|
notifyOnExit: false,
|
|
scopeKey,
|
|
});
|
|
const processTool = createProcessTool({ scopeKey });
|
|
const cleanupPids = new Set<number>();
|
|
|
|
try {
|
|
const missingRunId = `missing-command-${process.pid}`;
|
|
await expect(
|
|
getProcessSupervisor().spawn({
|
|
mode: "child",
|
|
argv: ["/definitely/not/a/real-openclaw-command"],
|
|
env: { OPENCLAW_CHILD_OOM_SCORE_ADJ: "0" },
|
|
runId: missingRunId,
|
|
sessionId: missingRunId,
|
|
backendId: "qa-process-lifecycle",
|
|
}),
|
|
).rejects.toMatchObject({ code: "ENOENT" });
|
|
expect(getProcessSupervisor().getRecord(missingRunId)).toMatchObject({
|
|
state: "exited",
|
|
terminationReason: "spawn-error",
|
|
});
|
|
|
|
const shellMarker = `shell-route-${process.pid}`;
|
|
const foregroundCommand =
|
|
process.platform === "win32"
|
|
? `Write-Output -NoNewline ${shellQuote(shellMarker)}; Write-Output -NoNewline "|$env:OPENCLAW_SHELL"`
|
|
: `printf '%s' ${shellQuote(shellMarker)} && printf '|%s' "$OPENCLAW_SHELL"`;
|
|
const foreground = await foregroundExecTool.execute("foreground-shell", {
|
|
command: foregroundCommand,
|
|
});
|
|
expect(foreground.details).toMatchObject({ status: "completed", exitCode: 0 });
|
|
expect(textOf(foreground)).toContain(`${shellMarker}|exec`);
|
|
|
|
const backgroundStart = `background-start-${process.pid}`;
|
|
const backgroundEnd = `background-end-${process.pid}`;
|
|
const background = await execTool.execute("explicit-background", {
|
|
command: nodeEvalCommand(
|
|
`process.stdout.write(${JSON.stringify(backgroundStart + "\n")});` +
|
|
`setTimeout(() => process.stdout.write(${JSON.stringify(backgroundEnd + "\n")}), 350);`,
|
|
),
|
|
background: true,
|
|
});
|
|
const backgroundSession = requireSession(background);
|
|
cleanupPids.add(backgroundSession.pid);
|
|
|
|
const listed = await processTool.execute("list-background", { action: "list" });
|
|
expect((listed.details as ProcessDetails).sessions).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
sessionId: backgroundSession.sessionId,
|
|
status: "running",
|
|
}),
|
|
]),
|
|
);
|
|
|
|
await expect
|
|
.poll(async () => {
|
|
const log = await processTool.execute("log-background", {
|
|
action: "log",
|
|
sessionId: backgroundSession.sessionId,
|
|
});
|
|
return textOf(log);
|
|
}, POLL_OPTIONS)
|
|
.toContain(backgroundStart);
|
|
|
|
const backgroundTerminal = await pollTerminal(processTool, backgroundSession.sessionId);
|
|
expect(backgroundTerminal.details).toMatchObject({
|
|
status: "completed",
|
|
exitCode: 0,
|
|
});
|
|
expect((backgroundTerminal.details as ProcessDetails).aggregated).toContain(backgroundEnd);
|
|
await clearFinished(processTool, backgroundSession.sessionId);
|
|
cleanupPids.delete(backgroundSession.pid);
|
|
|
|
const yieldedMarker = `yielded-terminal-${process.pid}`;
|
|
const yielded = await execTool.execute("yielded-background", {
|
|
command: nodeEvalCommand(
|
|
`setTimeout(() => process.stdout.write(${JSON.stringify(yieldedMarker + "\n")}), 350);`,
|
|
),
|
|
yieldMs: 20,
|
|
});
|
|
const yieldedSession = requireSession(yielded);
|
|
cleanupPids.add(yieldedSession.pid);
|
|
const yieldedTerminal = await pollTerminal(processTool, yieldedSession.sessionId);
|
|
expect(yieldedTerminal.details).toMatchObject({ status: "completed", exitCode: 0 });
|
|
expect((yieldedTerminal.details as ProcessDetails).aggregated).toContain(yieldedMarker);
|
|
await clearFinished(processTool, yieldedSession.sessionId);
|
|
cleanupPids.delete(yieldedSession.pid);
|
|
|
|
const timedOut = await foregroundExecTool.execute("foreground-timeout", {
|
|
command: nodeEvalCommand("setTimeout(() => {}, 5000);"),
|
|
timeoutSeconds: 0.05,
|
|
});
|
|
expect(timedOut.details).toMatchObject({
|
|
status: "failed",
|
|
exitReason: "overall-timeout",
|
|
timedOut: true,
|
|
});
|
|
expect(textOf(timedOut)).toContain("Command timed out after 0.05 seconds.");
|
|
|
|
const ptyMarker = `pty-route-${process.pid}`;
|
|
const pty = await foregroundExecTool.execute("foreground-pty", {
|
|
command: nodeEvalCommand(
|
|
`process.stdout.write(${JSON.stringify(ptyMarker + ":")} + String(Boolean(process.stdout.isTTY)) + ":" + (process.env.OPENCLAW_SHELL || ""));`,
|
|
),
|
|
pty: true,
|
|
});
|
|
expect(pty.details).toMatchObject({ status: "completed", exitCode: 0 });
|
|
expect(textOf(pty)).toContain(`${ptyMarker}:true:exec`);
|
|
|
|
const killMarker = `kill-target-${process.pid}`;
|
|
const spawnedChildren = new Map<number, ChildProcess>();
|
|
const originalEmit = ChildProcess.prototype.emit;
|
|
const captureSpawn = vi.spyOn(ChildProcess.prototype, "emit").mockImplementation(function (
|
|
this: ChildProcess,
|
|
event,
|
|
...args
|
|
) {
|
|
if (event === "spawn" && this.pid !== undefined) {
|
|
spawnedChildren.set(this.pid, this);
|
|
}
|
|
return originalEmit.call(this, event, ...args);
|
|
});
|
|
let killTarget: ToolResult;
|
|
try {
|
|
const childCommand = nodeEvalCommand(
|
|
`process.stdout.write(${JSON.stringify(killMarker + "\n")});setInterval(() => {}, 1000);`,
|
|
);
|
|
killTarget = await execTool.execute("kill-background", {
|
|
command: process.platform === "win32" ? childCommand : `exec ${childCommand}`,
|
|
background: true,
|
|
});
|
|
} finally {
|
|
captureSpawn.mockRestore();
|
|
}
|
|
const killedSession = requireSession(killTarget);
|
|
cleanupPids.add(killedSession.pid);
|
|
const child = spawnedChildren.get(killedSession.pid);
|
|
if (!child) {
|
|
throw new Error(`missing spawned child ${killedSession.pid}`);
|
|
}
|
|
const handle = (child as ChildProcess & { _handle: { kill: (signal: number) => number } })
|
|
._handle;
|
|
const originalKill = handle.kill;
|
|
const observedErrors: Array<NodeJS.ErrnoException> = [];
|
|
child.on("error", (error) => {
|
|
observedErrors.push(error);
|
|
});
|
|
const errorListenerCount = child.listenerCount("error");
|
|
|
|
try {
|
|
handle.kill = () => -constants.errno.EPERM;
|
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
expect(child.kill("SIGTERM")).toBe(false);
|
|
expect(child.listenerCount("error")).toBe(errorListenerCount);
|
|
expect(observedErrors[attempt]).toMatchObject({ code: "EPERM", syscall: "kill" });
|
|
await Promise.resolve();
|
|
expect(getProcessSupervisor().getRecord(killedSession.sessionId)).toMatchObject({
|
|
state: "running",
|
|
pid: killedSession.pid,
|
|
});
|
|
expect(listRunningSessions()).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ id: killedSession.sessionId, exited: false }),
|
|
]),
|
|
);
|
|
expect(getActiveBackgroundExecSessionCount()).toBe(1);
|
|
expect(pidExists(killedSession.pid)).toBe(true);
|
|
}
|
|
} finally {
|
|
handle.kill = originalKill;
|
|
}
|
|
|
|
const killed = await processTool.execute("kill-session", {
|
|
action: "kill",
|
|
sessionId: killedSession.sessionId,
|
|
});
|
|
expect(killed.details).toMatchObject({ status: "completed" });
|
|
const killedTerminal = await pollTerminal(processTool, killedSession.sessionId);
|
|
expect(killedTerminal.details).toMatchObject({
|
|
status: "failed",
|
|
exitReason: "manual-cancel",
|
|
});
|
|
await clearFinished(processTool, killedSession.sessionId);
|
|
await expect.poll(() => pidExists(killedSession.pid), POLL_OPTIONS).toBe(false);
|
|
expect(child.listenerCount("error")).toBe(0);
|
|
expect(child.listenerCount("exit")).toBe(0);
|
|
expect(child.listenerCount("close")).toBe(0);
|
|
cleanupPids.delete(killedSession.pid);
|
|
|
|
const finalList = await processTool.execute("list-final", { action: "list" });
|
|
expect((finalList.details as ProcessDetails).sessions).toEqual([]);
|
|
expect(listRunningSessions().filter((session) => session.scopeKey === scopeKey)).toEqual([]);
|
|
expect(getActiveBackgroundExecSessionCount()).toBe(0);
|
|
} finally {
|
|
for (const session of listRunningSessions().filter((entry) => entry.scopeKey === scopeKey)) {
|
|
await processTool.execute(`cleanup-${session.id}`, {
|
|
action: "remove",
|
|
sessionId: session.id,
|
|
});
|
|
}
|
|
for (const pid of cleanupPids) {
|
|
if (pidExists(pid)) {
|
|
process.kill(pid, "SIGKILL");
|
|
}
|
|
}
|
|
await expect.poll(() => [...cleanupPids].filter(pidExists).length, POLL_OPTIONS).toBe(0);
|
|
resetProcessRegistryForTests();
|
|
}
|
|
}, 30_000);
|