* exec: clean up PTY resources on timeout and exit * cli: harden resume cleanup and watchdog stalled runs * cli: productionize PTY and resume reliability paths * docs: add PTY process supervision architecture plan * docs: rewrite PTY supervision plan as pre-rewrite baseline * docs: switch PTY supervision plan to one-go execution * docs: add one-line root cause to PTY supervision plan * docs: add OS contracts and test matrix to PTY supervision plan * docs: define process-supervisor package placement and scope * docs: tie supervisor plan to existing CI lanes * docs: place PTY supervisor plan under src/process * refactor(process): route exec and cli runs through supervisor * docs(process): refresh PTY supervision plan * wip * fix(process): harden supervisor timeout and PTY termination * fix(process): harden supervisor adapters env and wait handling * ci: avoid failing formal conformance on comment permissions * test(ui): fix cron request mock argument typing * fix(ui): remove leftover conflict marker * fix: supervise PTY processes (#14257) (openclaw#14257) (thanks @onutc)
117 lines
3.7 KiB
TypeScript
117 lines
3.7 KiB
TypeScript
import type { ChildProcess } from "node:child_process";
|
|
import { EventEmitter } from "node:events";
|
|
import { PassThrough } from "node:stream";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const { spawnWithFallbackMock, killProcessTreeMock } = vi.hoisted(() => ({
|
|
spawnWithFallbackMock: vi.fn(),
|
|
killProcessTreeMock: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../../spawn-utils.js", () => ({
|
|
spawnWithFallback: (...args: unknown[]) => spawnWithFallbackMock(...args),
|
|
}));
|
|
|
|
vi.mock("../../kill-tree.js", () => ({
|
|
killProcessTree: (...args: unknown[]) => killProcessTreeMock(...args),
|
|
}));
|
|
|
|
function createStubChild(pid = 1234) {
|
|
const child = new EventEmitter() as ChildProcess;
|
|
child.stdin = new PassThrough() as ChildProcess["stdin"];
|
|
child.stdout = new PassThrough() as ChildProcess["stdout"];
|
|
child.stderr = new PassThrough() as ChildProcess["stderr"];
|
|
child.pid = pid;
|
|
child.killed = false;
|
|
const killMock = vi.fn(() => true);
|
|
child.kill = killMock as ChildProcess["kill"];
|
|
return { child, killMock };
|
|
}
|
|
|
|
describe("createChildAdapter", () => {
|
|
beforeEach(() => {
|
|
spawnWithFallbackMock.mockReset();
|
|
killProcessTreeMock.mockReset();
|
|
});
|
|
|
|
it("uses process-tree kill for default SIGKILL", async () => {
|
|
const { child, killMock } = createStubChild(4321);
|
|
spawnWithFallbackMock.mockResolvedValue({
|
|
child,
|
|
usedFallback: false,
|
|
});
|
|
const { createChildAdapter } = await import("./child.js");
|
|
const adapter = await createChildAdapter({
|
|
argv: ["node", "-e", "setTimeout(() => {}, 1000)"],
|
|
stdinMode: "pipe-open",
|
|
});
|
|
|
|
const spawnArgs = spawnWithFallbackMock.mock.calls[0]?.[0] as {
|
|
options?: { detached?: boolean };
|
|
fallbacks?: Array<{ options?: { detached?: boolean } }>;
|
|
};
|
|
expect(spawnArgs.options?.detached).toBe(true);
|
|
expect(spawnArgs.fallbacks?.[0]?.options?.detached).toBe(false);
|
|
|
|
adapter.kill();
|
|
|
|
expect(killProcessTreeMock).toHaveBeenCalledWith(4321);
|
|
expect(killMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("uses direct child.kill for non-SIGKILL signals", async () => {
|
|
const { child, killMock } = createStubChild(7654);
|
|
spawnWithFallbackMock.mockResolvedValue({
|
|
child,
|
|
usedFallback: false,
|
|
});
|
|
const { createChildAdapter } = await import("./child.js");
|
|
const adapter = await createChildAdapter({
|
|
argv: ["node", "-e", "setTimeout(() => {}, 1000)"],
|
|
stdinMode: "pipe-open",
|
|
});
|
|
|
|
adapter.kill("SIGTERM");
|
|
|
|
expect(killProcessTreeMock).not.toHaveBeenCalled();
|
|
expect(killMock).toHaveBeenCalledWith("SIGTERM");
|
|
});
|
|
|
|
it("keeps inherited env when no override env is provided", async () => {
|
|
const { child } = createStubChild(3333);
|
|
spawnWithFallbackMock.mockResolvedValue({
|
|
child,
|
|
usedFallback: false,
|
|
});
|
|
const { createChildAdapter } = await import("./child.js");
|
|
await createChildAdapter({
|
|
argv: ["node", "-e", "process.exit(0)"],
|
|
stdinMode: "pipe-open",
|
|
});
|
|
|
|
const spawnArgs = spawnWithFallbackMock.mock.calls[0]?.[0] as {
|
|
options?: { env?: NodeJS.ProcessEnv };
|
|
};
|
|
expect(spawnArgs.options?.env).toBeUndefined();
|
|
});
|
|
|
|
it("passes explicit env overrides as strings", async () => {
|
|
const { child } = createStubChild(4444);
|
|
spawnWithFallbackMock.mockResolvedValue({
|
|
child,
|
|
usedFallback: false,
|
|
});
|
|
const { createChildAdapter } = await import("./child.js");
|
|
await createChildAdapter({
|
|
argv: ["node", "-e", "process.exit(0)"],
|
|
env: { FOO: "bar", COUNT: "12", DROP_ME: undefined },
|
|
stdinMode: "pipe-open",
|
|
});
|
|
|
|
const spawnArgs = spawnWithFallbackMock.mock.calls[0]?.[0] as {
|
|
options?: { env?: Record<string, string> };
|
|
};
|
|
expect(spawnArgs.options?.env).toEqual({ FOO: "bar", COUNT: "12" });
|
|
});
|
|
});
|