Onur cd44a0d01e
fix: codex and similar processes keep dying on pty, solved by refactoring process spawning (#14257)
* 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)
2026-02-16 02:32:05 +01:00

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" });
});
});