// Child adapter tests cover adapting child processes to supervisor runs. import fs from "node:fs"; import { writeFile } from "node:fs/promises"; import path from "node:path"; import { PassThrough, Writable } from "node:stream"; import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useAutoCleanupTempDirTracker } from "../../../../test/helpers/temp-dir.js"; import { closeOwnedStdioProcess } from "../../owned-stdio.js"; import { createChildAdapterHarness, createStubChild, createWindowsNpmShim, firstMockArg, firstSpawnWithFallbackParams, readyChildAdapter, setPlatform, } from "./child.test-support.js"; import { expectRealExitWinsOverSigkillFallback, expectWaitStaysPendingUntilSigkillFallback, mockLinuxOomWrapperShell, } from "./test-support.js"; type CreateWindowsOutputDecoder = typeof import("../../../infra/windows-encoding.js").createWindowsOutputDecoder; const { spawnWithFallbackMock, signalProcessTreeMock, killProcessTreeMock, createWindowsOutputDecoderMock, createServiceChildRelayAdapterMock, } = vi.hoisted(() => ({ spawnWithFallbackMock: vi.fn(), killProcessTreeMock: vi.fn(), signalProcessTreeMock: vi.fn( (_pid: number, _signal: string, opts?: { onComplete?: () => void }) => { opts?.onComplete?.(); }, ), createWindowsOutputDecoderMock: vi.fn(() => ({ decode: (chunk: Buffer | string) => (Buffer.isBuffer(chunk) ? chunk.toString("utf8") : chunk), flush: () => "", })), createServiceChildRelayAdapterMock: vi.fn(), })); vi.mock("../../spawn-utils.js", () => ({ spawnWithFallback: spawnWithFallbackMock, })); vi.mock("../../kill-tree.js", () => ({ signalProcessTree: signalProcessTreeMock, killProcessTree: killProcessTreeMock, })); vi.mock("../../../infra/windows-encoding.js", () => ({ createWindowsOutputDecoder: createWindowsOutputDecoderMock, })); vi.mock("../service-child-relay-host.js", () => ({ createServiceChildRelayAdapter: createServiceChildRelayAdapterMock, })); let startChildAdapter: ReturnType; let getWindowsInstallRoots: typeof import("../../../infra/windows-install-roots.js").getWindowsInstallRoots; const tempDirs = useAutoCleanupTempDirTracker(afterEach); function createAdapterHarness(params?: Parameters[2]) { return createChildAdapterHarness(startChildAdapter, spawnWithFallbackMock, params); } function expectedTrustedCmdExe(): string { return path.win32.join(getWindowsInstallRoots().systemRoot, "System32", "cmd.exe"); } describe("createChildAdapter", () => { const originalServiceMarker = process.env.OPENCLAW_SERVICE_MARKER; const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); beforeEach(async () => { vi.resetModules(); const accessSync = fs.accessSync.bind(fs); vi.spyOn(fs, "accessSync").mockImplementation((filePath, mode) => { if (String(filePath).toLowerCase() === "c:\\windows\\system32\\reg.exe") { throw new Error("registry lookup disabled for test"); } return accessSync(filePath, mode); }); ({ getWindowsInstallRoots } = await import("../../../infra/windows-install-roots.js")); startChildAdapter = readyChildAdapter((await import("./child.js")).createChildAdapter); spawnWithFallbackMock.mockClear(); signalProcessTreeMock.mockClear(); killProcessTreeMock.mockReset(); createServiceChildRelayAdapterMock.mockClear(); createWindowsOutputDecoderMock.mockClear(); createWindowsOutputDecoderMock.mockImplementation(() => ({ decode: (chunk: Buffer | string) => (Buffer.isBuffer(chunk) ? chunk.toString("utf8") : chunk), flush: () => "", })); createServiceChildRelayAdapterMock.mockResolvedValue({ pid: 9999, onStdout: vi.fn(), onStderr: vi.fn(), wait: vi.fn(), kill: vi.fn(), dispose: vi.fn(), }); delete process.env.OPENCLAW_SERVICE_MARKER; vi.useRealTimers(); }); afterAll(() => { if (originalServiceMarker === undefined) { delete process.env.OPENCLAW_SERVICE_MARKER; } else { process.env.OPENCLAW_SERVICE_MARKER = originalServiceMarker; } }); afterEach(() => { vi.restoreAllMocks(); if (originalPlatformDescriptor) { Object.defineProperty(process, "platform", originalPlatformDescriptor); } vi.useRealTimers(); }); it("uses process-tree kill for default SIGKILL", async () => { const { adapter, killMock } = await createAdapterHarness({ pid: 4321 }); const spawnArgs = firstSpawnWithFallbackParams(spawnWithFallbackMock); // On Windows, detached defaults to false (headless Scheduled Task compat); // on POSIX, detached is true with a no-detach fallback. if (process.platform === "win32") { expect(spawnArgs.options?.detached).toBe(false); expect(spawnArgs.fallbacks).toStrictEqual([]); } else { expect(spawnArgs.options?.detached).toBe(true); expect(spawnArgs.fallbacks?.[0]?.detached).toBe(false); } adapter.kill(); await Promise.resolve(); // Detachment flag is now passed to signalProcessTree so it knows whether // it can safely group-kill via -pid. (#71662) const expectedDetached = process.platform !== "win32" && !process.env.OPENCLAW_SERVICE_MARKER; expect(signalProcessTreeMock).toHaveBeenCalledWith( 4321, "SIGKILL", expect.objectContaining({ detached: expectedDetached }), ); expect(killMock).toHaveBeenCalledWith("SIGKILL"); }); it("creates owned worker trees in a dedicated POSIX process group without fallback", async () => { process.env.OPENCLAW_SERVICE_MARKER = "service-managed"; const { child, disconnectMock, sendMock } = createStubChild(); spawnWithFallbackMock.mockResolvedValue({ child, usedFallback: false }); const adapter = await startChildAdapter({ argv: ["node", "worker"], ownedWorker: true, input: "{}", }); const spawnArgs = firstSpawnWithFallbackParams(spawnWithFallbackMock); expect(spawnArgs.options?.detached).toBe(process.platform !== "win32"); expect(spawnArgs.fallbacks).toEqual([]); expect(spawnArgs.options?.stdio).toEqual(["pipe", "pipe", "pipe", "ipc"]); await adapter.openStartGate?.(); expect(sendMock).toHaveBeenCalledWith( { type: "openclaw-worker-start-v1" }, expect.any(Function), ); adapter.closeStartGate?.(); expect(disconnectMock).toHaveBeenCalledOnce(); }); it.each([ { order: "disconnect first", events: ["disconnect", "exit", "stdout", "stderr"] }, { order: "disconnect last", events: ["stdout", "stderr", "exit", "disconnect"] }, { order: "exit last", events: ["disconnect", "stdout", "stderr", "exit"] }, ] as const)( "settles owned POSIX workers after all resources close ($order)", async ({ events }) => { vi.useFakeTimers(); setPlatform("darwin"); const { child, disconnectMock, emitExit, killMock } = createStubChild(); // Node marks connected false before a queued IPC disconnect has completed. disconnectMock.mockImplementation(() => { Object.defineProperty(child, "connected", { value: false, configurable: true, writable: true, }); }); spawnWithFallbackMock.mockResolvedValue({ child, usedFallback: false }); const adapter = await startChildAdapter({ argv: ["node", "worker"], ownedWorker: true, stdinMode: "pipe-open", }); const settled = vi.fn(); const wait = adapter.wait(); void wait.then(settled); try { adapter.closeStartGate?.(); await vi.advanceTimersByTimeAsync(0); expect(settled).not.toHaveBeenCalled(); for (const [index, event] of events.entries()) { if (event === "exit") { emitExit(7); } else if (event === "disconnect") { child.emit("disconnect"); } else { child[event]?.emit("end"); await vi.advanceTimersByTimeAsync(0); expect(settled).not.toHaveBeenCalled(); child[event]?.emit("close"); } await vi.advanceTimersByTimeAsync(0); if (index < events.length - 1) { expect(settled).not.toHaveBeenCalled(); } } expect(settled).toHaveBeenCalledExactlyOnceWith({ code: 7, signal: null }); await expect(wait).resolves.toEqual({ code: 7, signal: null }); expect(signalProcessTreeMock).not.toHaveBeenCalled(); expect(killMock).not.toHaveBeenCalled(); } finally { adapter.dispose(); } }, ); it("keeps ordinary POSIX child waits bound to close after exit and pipe closure", async () => { vi.useFakeTimers(); setPlatform("darwin"); const { child, emitClose, emitExit } = createStubChild(); spawnWithFallbackMock.mockResolvedValue({ child, usedFallback: false }); const adapter = await startChildAdapter({ argv: ["node", "-e", "process.exit(0)"] }); const settled = vi.fn(); const wait = adapter.wait(); void wait.then(settled); try { emitExit(0); child.stdout?.emit("close"); child.stderr?.emit("close"); child.emit("disconnect"); await vi.advanceTimersByTimeAsync(0); expect(settled).not.toHaveBeenCalled(); emitClose(0); await expect(wait).resolves.toEqual({ code: 0, signal: null }); } finally { adapter.dispose(); } }); it("keeps ordinary children supervised through repeated operational errors", async () => { const { adapter, child, emitClose, emitExit } = await createAdapterHarness({ pid: 7865, argv: ["node", "-e", "setInterval(() => {}, 1000)"], }); const resolved = vi.fn(); const rejected = vi.fn(); const wait = adapter.wait(); void wait.then(resolved, rejected); try { for (let attempt = 0; attempt < 2; attempt += 1) { const error = Object.assign(new Error("kill EPERM"), { code: "EPERM" }); expect(() => child.emit("error", error)).not.toThrow(); expect(child.listenerCount("error")).toBe(1); expect(child.listenerCount("exit")).toBe(1); expect(child.listenerCount("close")).toBe(1); await Promise.resolve(); expect(resolved).not.toHaveBeenCalled(); expect(rejected).not.toHaveBeenCalled(); } emitExit(0); emitClose(0); await expect(wait).resolves.toEqual({ code: 0, signal: null }); } finally { adapter.dispose(); } expect(child.listenerCount("error")).toBe(0); expect(child.listenerCount("exit")).toBe(0); expect(child.listenerCount("close")).toBe(0); }); it.each([ { first: "error", waitBefore: true }, { first: "error", waitBefore: false }, { first: "close", waitBefore: true }, { first: "close", waitBefore: false }, ] as const)( "keeps the first owned worker $first outcome (waitBefore=$waitBefore)", async ({ first, waitBefore }) => { const { child, disconnectMock, emitClose } = createStubChild(7866); spawnWithFallbackMock.mockResolvedValue({ child, usedFallback: false }); const adapter = await startChildAdapter({ argv: ["node", "worker"], ownedWorker: true, }); const error = Object.assign(new Error("kill EPERM"), { code: "EPERM" }); const pending = Promise.allSettled(waitBefore ? [adapter.wait(), adapter.wait()] : []); try { if (first === "error") { child.emit("error", error); emitClose(0); } else { emitClose(0); child.emit("error", error); } // Cross a turn before late waits so an unhandled eager rejection fails the test. await new Promise((resolve) => { setImmediate(resolve); }); const outcomes = [ ...(await pending), ...(await Promise.allSettled([adapter.wait(), adapter.wait()])), ]; let firstResult: Awaited> | undefined; for (const outcome of outcomes) { if (first === "error") { expect(outcome.status).toBe("rejected"); if (outcome.status === "rejected") { expect(outcome.reason).toBe(error); } } else { expect(outcome).toStrictEqual({ status: "fulfilled", value: { code: 0, signal: null }, }); if (outcome.status === "fulfilled") { firstResult ??= outcome.value; expect(outcome.value).toBe(firstResult); } } } } finally { adapter.dispose(); } expect(disconnectMock).toHaveBeenCalledOnce(); expect(child.listenerCount("error")).toBe(0); }, ); it("writes secret input to an extra descriptor and zeroes the transient buffer", async () => { setPlatform("win32"); const { child } = createStubChild(); const secretStream = new PassThrough(); const chunks: Buffer[] = []; secretStream.on("data", (chunk: Buffer) => { chunks.push(Buffer.from(chunk)); }); Object.defineProperty(child, "stdio", { value: [child.stdin, child.stdout, child.stderr, secretStream], configurable: true, }); spawnWithFallbackMock.mockResolvedValue({ child, usedFallback: false, }); const transient = Buffer.from("selected-secret", "utf8"); await startChildAdapter({ argv: ["claude", "-p"], stdinMode: "pipe-open", secretInput: { fd: 3, createData: () => transient, }, }); expect(firstSpawnWithFallbackParams(spawnWithFallbackMock).options?.stdio).toEqual([ "pipe", "pipe", "pipe", process.platform === "win32" ? "overlapped" : "pipe", ]); expect(Buffer.concat(chunks).toString("utf8")).toBe("selected-secret"); expect(transient.equals(Buffer.alloc(transient.length))).toBe(true); }); it("captures child close while secret input delivery is still pending", async () => { setPlatform("win32"); const { child, emitClose } = createStubChild(); const secretStream = new Writable({ write(_chunk, _encoding, callback) { emitClose(0); setImmediate(callback); }, }); Object.defineProperty(child, "stdio", { value: [child.stdin, child.stdout, child.stderr, secretStream], configurable: true, }); spawnWithFallbackMock.mockResolvedValue({ child, usedFallback: false, }); const adapter = await startChildAdapter({ argv: ["claude", "-p"], stdinMode: "pipe-open", secretInput: { fd: 3, createData: () => Buffer.from("selected-secret"), }, }); await expect(adapter.wait()).resolves.toEqual({ code: 0, signal: null }); }); it("uses overlapped I/O for a Windows secret descriptor", async () => { setPlatform("win32"); const { child } = createStubChild(); Object.defineProperty(child, "stdio", { value: [child.stdin, child.stdout, child.stderr, new PassThrough()], configurable: true, }); spawnWithFallbackMock.mockResolvedValue({ child, usedFallback: false, }); await startChildAdapter({ argv: ["claude.exe", "-p"], secretInput: { fd: 3, createData: () => Buffer.from("secret"), }, }); expect(firstSpawnWithFallbackParams(spawnWithFallbackMock).options?.stdio?.[3]).toBe( "overlapped", ); }); it("keeps macOS no-detach hard kills on the direct signal path", async () => { setPlatform("darwin"); // Simulate the fallback scenario: spawnWithFallback retried with // detached:false because the initial detached spawn failed. The kill // closure must NOT group-kill since the child shares the gateway's group. const { child, killMock } = createStubChild(8888); spawnWithFallbackMock.mockResolvedValue({ child, usedFallback: true, }); const adapter = await startChildAdapter({ argv: ["node", "-e", "setTimeout(() => {}, 1000)"], stdinMode: "pipe-open", }); adapter.kill(); await Promise.resolve(); expect(signalProcessTreeMock).toHaveBeenCalledWith( 8888, "SIGKILL", expect.objectContaining({ detached: false }), ); expect(killMock).toHaveBeenCalledWith("SIGKILL"); }); it.each(["linux", "darwin", "freebsd"] as const)( "selects the exact service relay instead of direct shared-group signaling on %s", async (platform) => { setPlatform(platform); process.env.OPENCLAW_SERVICE_MARKER = "1"; try { await startChildAdapter({ argv: ["node", "-e", "setTimeout(() => {}, 1000)"], exactEnv: true, stdinMode: "pipe-open", }); expect(createServiceChildRelayAdapterMock).toHaveBeenCalledWith( expect.objectContaining({ command: "node", args: ["-e", "setTimeout(() => {}, 1000)"], stdinMode: "pipe-open", }), ); expect(spawnWithFallbackMock).not.toHaveBeenCalled(); expect(signalProcessTreeMock).not.toHaveBeenCalled(); } finally { delete process.env.OPENCLAW_SERVICE_MARKER; } }, ); it("uses process-tree kill for graceful SIGTERM cancellation", async () => { const { adapter, killMock } = await createAdapterHarness({ pid: 7654 }); adapter.kill("SIGTERM"); const expectedDetached = process.platform !== "win32" && !process.env.OPENCLAW_SERVICE_MARKER; expect(signalProcessTreeMock).toHaveBeenCalledWith(7654, "SIGTERM", { detached: expectedDetached, }); expect(killMock).not.toHaveBeenCalled(); }); it("keeps macOS no-detach TERM on the direct signal path", async () => { setPlatform("darwin"); const { child, killMock } = createStubChild(8765); spawnWithFallbackMock.mockResolvedValue({ child, usedFallback: true, }); const adapter = await startChildAdapter({ argv: ["node", "-e", "setTimeout(() => {}, 1000)"], stdinMode: "pipe-open", }); adapter.kill("SIGTERM"); expect(signalProcessTreeMock).toHaveBeenCalledWith(8765, "SIGTERM", { detached: false, }); expect(killMock).not.toHaveBeenCalled(); }); it("retains one Linux fallback snapshot and forces it after root close and disposal", async () => { setPlatform("linux"); const { child, emitExit, emitClose } = createStubChild(8765); const force = vi.fn(); killProcessTreeMock.mockReturnValue({ force }); spawnWithFallbackMock.mockResolvedValue({ child, usedFallback: true }); const adapter = await startChildAdapter({ argv: ["node", "worker.js"] }); adapter.kill("SIGTERM"); adapter.kill("SIGTERM"); expect(killProcessTreeMock).toHaveBeenCalledExactlyOnceWith(8765, { detached: false, graceMs: 5_000, force: false, }); emitExit(0); emitClose(0); await adapter.wait(); adapter.dispose(); adapter.kill("SIGKILL"); expect(force).toHaveBeenCalledOnce(); expect(killProcessTreeMock).toHaveBeenCalledOnce(); expect(signalProcessTreeMock).not.toHaveBeenCalled(); }); it("does not rediscover a Linux fallback tree when its initial identity was unavailable", async () => { setPlatform("linux"); const { child, killMock } = createStubChild(8765); spawnWithFallbackMock.mockResolvedValue({ child, usedFallback: true }); const adapter = await startChildAdapter({ argv: ["node", "worker.js"] }); adapter.kill("SIGTERM"); adapter.kill("SIGKILL"); await Promise.resolve(); expect(killProcessTreeMock).toHaveBeenCalledOnce(); expect(signalProcessTreeMock).not.toHaveBeenCalled(); // ChildProcess owns the live direct child; no numeric tree re-enumeration. expect(killMock).toHaveBeenCalledWith("SIGKILL"); }); it("does not discover a Linux fallback tree after the root has exited", async () => { setPlatform("linux"); const { child, emitExit } = createStubChild(8765); spawnWithFallbackMock.mockResolvedValue({ child, usedFallback: true }); const adapter = await startChildAdapter({ argv: ["node", "worker.js"] }); emitExit(0); adapter.kill("SIGTERM"); adapter.kill("SIGKILL"); await Promise.resolve(); expect(killProcessTreeMock).not.toHaveBeenCalled(); expect(signalProcessTreeMock).not.toHaveBeenCalled(); }); it("uses one identity-bound immediate hard kill for a live Linux fallback root", async () => { setPlatform("linux"); const { child } = createStubChild(8765); spawnWithFallbackMock.mockResolvedValue({ child, usedFallback: true }); const adapter = await startChildAdapter({ argv: ["node", "worker.js"] }); adapter.kill("SIGKILL"); await Promise.resolve(); expect(killProcessTreeMock).toHaveBeenCalledExactlyOnceWith(8765, { detached: false, graceMs: 5_000, force: true, }); expect(signalProcessTreeMock).not.toHaveBeenCalled(); }); it("uses direct child.kill for non-SIGTERM and non-SIGKILL signals", async () => { const { adapter, killMock } = await createAdapterHarness({ pid: 7654 }); adapter.kill("SIGINT"); expect(signalProcessTreeMock).not.toHaveBeenCalled(); expect(killMock).toHaveBeenCalledWith("SIGINT"); }); it("preserves inherited stdin when no input pipe is requested", async () => { const { child } = createStubChild(5656); child.stdin = null; spawnWithFallbackMock.mockResolvedValue({ child, usedFallback: false, }); const adapter = await startChildAdapter({ argv: ["node", "-e", "setTimeout(() => {}, 1000)"], }); const spawnArgs = firstSpawnWithFallbackParams(spawnWithFallbackMock); expect(spawnArgs.options?.stdio?.[0]).toBe("inherit"); expect(adapter.stdin).toBeUndefined(); }); it("reports stdin as non-writable after end or destroy", async () => { const { adapter } = await createAdapterHarness({ pid: 6767 }); expect(adapter.stdin?.writable).toBe(true); expect(adapter.stdin?.writableEnded).toBe(false); adapter.stdin?.end(); expect(adapter.stdin?.writable).toBe(false); expect(adapter.stdin?.writableEnded).toBe(true); const writeCallback = vi.fn(); adapter.stdin?.write("late", writeCallback); expect(firstMockArg(writeCallback, "write callback")).toBeInstanceOf(Error); adapter.stdin?.destroy?.(); expect(adapter.stdin?.destroyed).toBe(true); expect(adapter.stdin?.writable).toBe(false); }); it("reports pipe-closed stdin as ended", async () => { const { adapter } = await createAdapterHarness({ pid: 3434, argv: ["node", "-e", "process.exit(0)"], stdinMode: "pipe-closed", }); expect(adapter.stdin?.writable).toBe(false); expect(adapter.stdin?.writableEnded).toBe(true); }); it("disposes only decoder-owned output listeners after the SIGKILL fallback", async () => { vi.useFakeTimers(); const flush = vi.fn(() => "flushed tail"); createWindowsOutputDecoderMock.mockImplementation(() => ({ decode: (chunk: Buffer | string) => (Buffer.isBuffer(chunk) ? chunk.toString("utf8") : chunk), flush, })); const { adapter, child } = await createAdapterHarness({ pid: 4567 }); const stdout = vi.fn(); const stderr = vi.fn(); const stdoutClose = vi.fn(); const stderrClose = vi.fn(); const stdoutPipe = child.stdout as PassThrough; const stderrPipe = child.stderr as PassThrough; stdoutPipe.on("close", stdoutClose); stderrPipe.on("close", stderrClose); adapter.onStdout(stdout); adapter.onStderr(stderr); stdoutPipe.write("drained stdout"); stderrPipe.write("drained stderr"); expect(stdout).toHaveBeenCalledExactlyOnceWith("drained stdout"); expect(stderr).toHaveBeenCalledExactlyOnceWith("drained stderr"); await expectWaitStaysPendingUntilSigkillFallback(adapter.wait(), () => { adapter.kill(); }); const stdoutCloseListeners = stdoutPipe.listenerCount("close"); const stderrCloseListeners = stderrPipe.listenerCount("close"); const queuedError = new Error("queued output stream error"); expect(stderrPipe.destroy(queuedError)).toBe(stderrPipe); expect(adapter.dispose()).toBeUndefined(); expect(stdoutPipe.destroyed).toBe(true); expect(stderrPipe.destroyed).toBe(true); expect(stderrPipe.errored).toBe(queuedError); expect(stderrPipe.listenerCount("error")).toBe(1); expect(stdoutPipe.listenerCount("close")).toBe(stdoutCloseListeners - 1); expect(stderrPipe.listenerCount("close")).toBe(stderrCloseListeners - 1); stdoutPipe.emit("data", Buffer.from("late stdout")); stderrPipe.emit("data", Buffer.from("late stderr")); await vi.runAllTimersAsync(); expect(stdout).toHaveBeenCalledOnce(); expect(stderr).toHaveBeenCalledOnce(); expect(flush).not.toHaveBeenCalled(); expect(stdoutClose).toHaveBeenCalledOnce(); expect(stderrClose).toHaveBeenCalledOnce(); }); it("prefers real child close over the SIGKILL fallback settle", async () => { vi.useFakeTimers(); const { adapter, emitClose, killMock } = await createAdapterHarness({ pid: 2468 }); await expectRealExitWinsOverSigkillFallback({ waitPromise: adapter.wait(), triggerKill: () => { adapter.kill(); }, emitExit: () => { emitClose(0, "SIGKILL"); }, expected: { code: 0, signal: "SIGKILL" }, }); expect(killMock).toHaveBeenCalledWith("SIGKILL"); }); it("does not renew the Windows hard-kill fallback or invent extinction on repeated KILL", async () => { vi.useFakeTimers(); setPlatform("win32"); signalProcessTreeMock.mockImplementationOnce(() => {}).mockImplementationOnce(() => {}); const { adapter } = await createAdapterHarness({ pid: 9756 }); const settled = vi.fn(); void adapter.wait().then(settled); expect(adapter.waitForExtinction).toBeUndefined(); const closing = closeOwnedStdioProcess(adapter, { force: true }); const rejected = expect(closing).rejects.toThrow("cannot confirm descendant extinction"); await vi.advanceTimersByTimeAsync(3_000); adapter.kill("SIGKILL"); await vi.advanceTimersByTimeAsync(999); expect(settled).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(1); expect(settled).toHaveBeenCalledExactlyOnceWith({ code: null, signal: "SIGKILL" }); await rejected; adapter.kill("SIGKILL"); await vi.advanceTimersByTimeAsync(4_000); expect(settled).toHaveBeenCalledOnce(); expect(adapter.waitForExtinction).toBeUndefined(); adapter.dispose(); }); it("waits for Windows tree-kill completion before forced stream settlement", async () => { vi.useFakeTimers(); setPlatform("win32"); let resolveTreeKill: (() => void) | undefined; signalProcessTreeMock.mockImplementationOnce( (_pid: number, _signal: string, opts?: { onComplete?: () => void }) => { resolveTreeKill = opts?.onComplete; }, ); const { adapter, ...stub } = await createAdapterHarness({ pid: 9753, argv: ["node", "-e", "setInterval(() => {}, 1000)"], stdinMode: "pipe-closed", }); const settled = vi.fn(); void adapter.wait().then(settled); adapter.kill("SIGKILL"); stub.emitExit(null, "SIGKILL"); await vi.advanceTimersByTimeAsync(1_000); expect(settled).not.toHaveBeenCalled(); expect(stub.child.stdout?.destroyed).toBe(false); expect(stub.child.stderr?.destroyed).toBe(false); resolveTreeKill?.(); await Promise.resolve(); await vi.advanceTimersByTimeAsync(249); expect(settled).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(1); expect(settled).toHaveBeenCalledWith({ code: null, signal: "SIGKILL" }); expect(stub.child.stdout?.destroyed).toBe(true); expect(stub.child.stderr?.destroyed).toBe(true); }); it("blocks Windows child close until tree-kill completion", async () => { vi.useFakeTimers(); setPlatform("win32"); let resolveTreeKill: (() => void) | undefined; signalProcessTreeMock.mockImplementationOnce( (_pid: number, _signal: string, opts?: { onComplete?: () => void }) => { resolveTreeKill = opts?.onComplete; }, ); const { adapter, ...stub } = await createAdapterHarness({ pid: 9754, argv: ["node", "-e", "setInterval(() => {}, 1000)"], stdinMode: "pipe-closed", }); const settled = vi.fn(); void adapter.wait().then(settled); adapter.kill("SIGKILL"); stub.emitExit(null, "SIGKILL"); stub.emitClose(null, "SIGKILL"); await vi.advanceTimersByTimeAsync(1_000); expect(settled).not.toHaveBeenCalled(); resolveTreeKill?.(); await Promise.resolve(); await vi.advanceTimersByTimeAsync(250); expect(settled).toHaveBeenCalledWith({ code: null, signal: "SIGKILL" }); }); it.each([false, true])( "blocks drained Windows streams until tree-kill completion (ownedWorker=%s)", async (ownedWorker) => { vi.useFakeTimers(); setPlatform("win32"); let resolveTreeKill: (() => void) | undefined; signalProcessTreeMock.mockImplementationOnce( (_pid: number, _signal: string, opts?: { onComplete?: () => void }) => { resolveTreeKill = opts?.onComplete; }, ); const stub = createStubChild(9755); spawnWithFallbackMock.mockResolvedValue({ child: stub.child, usedFallback: false }); const adapter = await startChildAdapter({ argv: ["node", "-e", "setInterval(() => {}, 1000)"], stdinMode: "pipe-closed", ...(ownedWorker ? { ownedWorker: true } : {}), }); const settled = vi.fn(); void adapter.wait().then(settled); adapter.kill("SIGKILL"); adapter.closeStartGate?.(); stub.emitExit(null, "SIGKILL"); stub.child.stdout?.emit("end"); stub.child.stderr?.emit("end"); await Promise.resolve(); expect(settled).not.toHaveBeenCalled(); resolveTreeKill?.(); await vi.advanceTimersByTimeAsync(0); expect(settled).toHaveBeenCalledWith({ code: null, signal: "SIGKILL" }); }, ); it("preserves descendant output after ordinary Windows child exit", async () => { vi.useFakeTimers(); setPlatform("win32"); const { adapter, emitExit, child } = await createAdapterHarness({ pid: 8642, argv: ["openclaw", "version"], stdinMode: "pipe-closed", }); const stdout = vi.fn(); const stderr = vi.fn(); adapter.onStdout(stdout); adapter.onStderr(stderr); const settled = vi.fn(); void adapter.wait().then(settled); emitExit(0, null); await vi.advanceTimersByTimeAsync(1_000); expect(settled).not.toHaveBeenCalled(); expect(child.stdout?.destroyed).toBe(false); expect(child.stderr?.destroyed).toBe(false); const stdoutPipe = child.stdout as PassThrough; const stderrPipe = child.stderr as PassThrough; stdoutPipe.write("late stdout"); stderrPipe.write("late stderr"); stdoutPipe.end(); stderrPipe.end(); await vi.runAllTimersAsync(); expect(stdout).toHaveBeenCalledWith("late stdout"); expect(stderr).toHaveBeenCalledWith("late stderr"); expect(settled).toHaveBeenCalledWith({ code: 0, signal: null }); }); it("settles ordinary Windows exit when streams drain before exit and close is missing", async () => { setPlatform("win32"); const { adapter, ...stub } = await createAdapterHarness({ pid: 9756, argv: ["node", "-e", "process.exit(0)"], stdinMode: "pipe-closed", }); const waitPromise = adapter.wait(); const settled = vi.fn(); void waitPromise.then(settled); stub.child.stdout?.emit("end"); stub.child.stderr?.emit("end"); await Promise.resolve(); expect(settled).not.toHaveBeenCalled(); stub.emitExit(0, null); await expect(waitPromise).resolves.toEqual({ code: 0, signal: null }); }); it("keeps the service relay out of Windows child mode", async () => { setPlatform("win32"); process.env.OPENCLAW_SERVICE_MARKER = "openclaw"; await createAdapterHarness({ pid: 7777 }); const spawnArgs = firstSpawnWithFallbackParams(spawnWithFallbackMock); expect(spawnArgs.options?.detached).toBe(false); expect(spawnArgs.fallbacks ?? []).toStrictEqual([]); expect(createServiceChildRelayAdapterMock).not.toHaveBeenCalled(); }); it("keeps inherited env when no override env is provided on non-Linux", async () => { setPlatform("darwin"); await createAdapterHarness({ pid: 3333, argv: ["node", "-e", "process.exit(0)"], }); const spawnArgs = firstSpawnWithFallbackParams(spawnWithFallbackMock); expect(spawnArgs.argv).toEqual(["node", "-e", "process.exit(0)"]); expect(spawnArgs.options?.env).toBeUndefined(); }); it("wraps Windows command shims through trusted cmd.exe", async () => { setPlatform("win32"); await createAdapterHarness({ pid: 3335, argv: ["pnpm", "--version"], env: { PATH: "", PATHEXT: ".EXE;.CMD;.BAT" }, }); const spawnArgs = firstSpawnWithFallbackParams(spawnWithFallbackMock); expect(spawnArgs.argv).toEqual([ expectedTrustedCmdExe(), "/d", "/s", "/c", '""pnpm.cmd" "--version""', ]); expect(spawnArgs.options?.detached).toBe(false); expect(spawnArgs.options?.windowsHide).toBe(true); expect(spawnArgs.options?.windowsVerbatimArguments).toBe(true); expect(spawnArgs.fallbacks).toStrictEqual([]); }); it("unwraps Gemini's npm shim and preserves prompt argv on Windows", async () => { setPlatform("win32"); const { binDir, entrypoint } = await createWindowsNpmShim({ binDir: tempDirs.make("openclaw-child-shim-"), command: "gemini", packagePath: ["@google", "gemini-cli", "bundle", "gemini.js"], }); const nodePath = path.join(binDir, "node.exe"); await writeFile(nodePath, "", "utf8"); const prompt = "explain A&B | C > D and 100% coverage"; await createAdapterHarness({ pid: 3336, argv: ["gemini", "--prompt", prompt], env: { PATH: binDir, PATHEXT: ".EXE;.CMD;.BAT" }, }); const spawnArgs = firstSpawnWithFallbackParams(spawnWithFallbackMock); expect(spawnArgs.argv?.[0]?.toLowerCase()).toBe(nodePath.toLowerCase()); expect(spawnArgs.argv?.slice(1)).toEqual([entrypoint, "--prompt", prompt]); expect(spawnArgs.options?.windowsVerbatimArguments).toBeUndefined(); expect(spawnArgs.fallbacks).toStrictEqual([]); }); it("unwraps Claude's npm shim to its native executable on Windows", async () => { setPlatform("win32"); const { binDir, entrypoint } = await createWindowsNpmShim({ binDir: tempDirs.make("openclaw-child-shim-"), command: "claude", packagePath: ["@anthropic-ai", "claude-code", "bin", "claude.exe"], }); await createAdapterHarness({ pid: 3337, argv: ["claude", "--version"], env: { PATH: binDir, PATHEXT: ".EXE;.CMD;.BAT" }, }); const spawnArgs = firstSpawnWithFallbackParams(spawnWithFallbackMock); expect(spawnArgs.argv).toEqual([entrypoint, "--version"]); expect(spawnArgs.options?.windowsVerbatimArguments).toBeUndefined(); expect(spawnArgs.fallbacks).toStrictEqual([]); }); it("wraps Linux child spawns and strips shell-init env", async () => { const originalBashEnv = process.env.BASH_ENV; const originalEnv = process.env.ENV; const originalCdpath = process.env.CDPATH; setPlatform("linux"); const restoreLinuxShell = mockLinuxOomWrapperShell(); process.env.BASH_ENV = "/tmp/bashenv"; process.env.ENV = "/tmp/env"; process.env.CDPATH = "/tmp"; try { const { adapter } = await createAdapterHarness({ pid: 3334, argv: ["/usr/bin/node", "-e", "process.exit(0)"], }); expect(adapter.oomScoreWrapperSelected).toBe(true); } finally { restoreLinuxShell(); if (originalBashEnv === undefined) { delete process.env.BASH_ENV; } else { process.env.BASH_ENV = originalBashEnv; } if (originalEnv === undefined) { delete process.env.ENV; } else { process.env.ENV = originalEnv; } if (originalCdpath === undefined) { delete process.env.CDPATH; } else { process.env.CDPATH = originalCdpath; } } const spawnArgs = firstSpawnWithFallbackParams(spawnWithFallbackMock); expect(spawnArgs.argv?.slice(0, 4)).toEqual([ "/bin/sh", "-c", 'echo 1000 > /proc/self/oom_score_adj 2>/dev/null; exec "$0" "$@"', "/usr/bin/node", ]); expect(spawnArgs.argv?.slice(4)).toEqual(["-e", "process.exit(0)"]); if (!spawnArgs.options?.env) { throw new Error("expected child process env options"); } expect(spawnArgs.options.env.BASH_ENV).toBeUndefined(); expect(spawnArgs.options.env.ENV).toBeUndefined(); expect(spawnArgs.options.env.CDPATH).toBeUndefined(); }); it("keeps an exact Linux child environment out of the OOM shell wrapper", async () => { setPlatform("linux"); const restoreLinuxShell = mockLinuxOomWrapperShell(); const { child } = createStubChild(3335); spawnWithFallbackMock.mockResolvedValue({ child, usedFallback: false }); try { const adapter = await startChildAdapter({ argv: ["/usr/bin/node", "-e", "process.exit(0)"], env: { HOME: "/worker-home", PATH: "/usr/bin" }, exactEnv: true, stdinMode: "pipe-open", }); expect(adapter.oomScoreWrapperSelected).toBe(false); } finally { restoreLinuxShell(); } const spawnArgs = firstSpawnWithFallbackParams(spawnWithFallbackMock); expect(spawnArgs.argv).toEqual(["/usr/bin/node", "-e", "process.exit(0)"]); expect(spawnArgs.options?.env).toEqual({ HOME: "/worker-home", PATH: "/usr/bin" }); }); it("passes explicit env overrides as strings", async () => { await createAdapterHarness({ pid: 4444, argv: ["node", "-e", "process.exit(0)"], env: { FOO: "bar", COUNT: "12", DROP_ME: undefined }, }); const spawnArgs = firstSpawnWithFallbackParams(spawnWithFallbackMock); expect(spawnArgs.options?.env).toEqual({ FOO: "bar", COUNT: "12" }); }); it("uses a separate stdout decoder for each listener", async () => { const decoderOutputs = ["first", "second"]; createWindowsOutputDecoderMock.mockImplementation(() => { const output = decoderOutputs.shift() ?? ""; return { decode: () => output, flush: () => "", }; }); const { adapter, child } = await createAdapterHarness({ pid: 5555, argv: ["node", "-e", "process.exit(0)"], }); const first = vi.fn(); const second = vi.fn(); const raw = vi.fn(); adapter.onStdout(first, raw); adapter.onStdout(second); child.stdout?.emit("data", Buffer.from([0xb2])); expect(createWindowsOutputDecoderMock).toHaveBeenCalledTimes(2); expect(first).toHaveBeenCalledWith("first"); expect(second).toHaveBeenCalledWith("second"); expect(raw).toHaveBeenCalledWith(Buffer.from([0xb2])); }); it("guards stream errors before output listeners are registered", async () => { vi.useFakeTimers(); setPlatform("win32"); const { adapter, child, emitExit } = await createAdapterHarness({ pid: 6666 }); const stdoutErr = new Error("simulated stdout pipe error"); const stderrErr = new Error("simulated stderr pipe error"); const settled = vi.fn(); void adapter.wait().then(settled); emitExit(0, null); expect(() => child.stdout?.emit("error", stdoutErr)).not.toThrow(); expect(() => child.stderr?.emit("error", stderrErr)).not.toThrow(); await vi.advanceTimersByTimeAsync(300); expect(settled).not.toHaveBeenCalled(); adapter.onStdout(() => {}); adapter.onStdout(() => {}); adapter.onStderr(() => {}); adapter.onStderr(() => {}); expect(child.stdout?.listenerCount("error")).toBe(1); expect(child.stderr?.listenerCount("error")).toBe(1); child.stdout?.emit("close"); child.stderr?.emit("close"); await vi.advanceTimersByTimeAsync(0); expect(settled).toHaveBeenCalledWith({ code: 0, signal: null }); }); });