import { randomUUID } from "node:crypto"; import { EventEmitter } from "node:events"; import { PassThrough } from "node:stream"; import { setTimeout as delay } from "node:timers/promises"; import { beforeAll, expect, it, vi } from "vitest"; import { OwnedCommandFailure, runOwnedCommand, } from "../../../src/process/OwnedCommand.js"; import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js"; import { cleanupOwnedProcessGroup, type ProcessCleanupResult, } from "../../../src/process/ProcessOwnership.js"; import { prepareProcessOwnershipInspection, systemProcessOwnershipHost, } from "../../../src/process/ProcessOwnershipObservation.js"; import { waitForProviderProcessReady } from "../../fixtures/providerProcess.js"; const command = (script: string) => ({ command: process.execPath, arguments: ["-e", script], runId: `rea-owned-command-test-${randomUUID()}`, }); // Output/exit cases start with a prepared inspector; startup deadline cases // below inject their own preparation so they remain independent of compiler speed. beforeAll(() => prepareProcessOwnershipInspection()); it("includes ownership preparation in the command deadline before provider creation", async () => { let prepared = false; await expect( runOwnedCommand( command("process.exit(0)"), { timeoutMs: 20, diagnosticBytes: 1024, }, { launcher: async (input) => { await delay(80, undefined, { signal: input.signal }); prepared = true; throw new Error( "Slow preparation must be aborted before provider creation", ); }, }, ), ).rejects.toMatchObject({ reason: "timeout", snapshot: null }); expect(prepared).toBe(false); }); it("passes cancellation into native ownership preparation before provider creation", async () => { const controller = new AbortController(); await expect( runOwnedCommand( command("process.exit(0)"), { timeoutMs: 2000, diagnosticBytes: 1024 }, { signal: controller.signal, launcher: async (input) => { expect(input.signal?.aborted).toBe(false); controller.abort(); input.signal?.throwIfAborted(); throw new Error("Cancelled preparation must not launch a provider"); }, }, ), ).rejects.toMatchObject({ reason: "cancelled", snapshot: null }); }); it("bounds retained diagnostics across both streams during a real output burst", async () => { try { await runOwnedCommand( command( 'process.stdout.write("a".repeat(256 * 1024)); process.stderr.write("b".repeat(256 * 1024))', ), { timeoutMs: 2_000, diagnosticBytes: 1024 }, ); throw new Error("An oversized producer must fail"); } catch (cause: unknown) { if (!(cause instanceof OwnedCommandFailure)) throw cause; expect(cause.reason).toBe("output-limit"); expect(cause.snapshot).not.toBeNull(); expect( (cause.snapshot?.stdout.bytes ?? 0) + (cause.snapshot?.stderr.bytes ?? 0), ).toBeLessThanOrEqual(1024); } }); it("collects a real owned command and verifies its exit before returning diagnostics", async () => { const result = await runOwnedCommand( command('process.stdout.write("observed-output")'), { timeoutMs: 2_000, diagnosticBytes: 1024 }, ); expect(result).toMatchObject({ exitCode: 0, signal: null, stdout: { text: "observed-output", bytes: 15 }, }); }); it("fails an accepted nonzero command when an output stream errors and cleans it up", async () => { const stdout = new PassThrough(); const stderr = new PassThrough(); class FakeProcess extends EventEmitter { readonly stdout = stdout; readonly stderr = stderr; exitCode: number | null = null; signalCode: NodeJS.Signals | null = null; readonly pid = 41234; kill(): boolean { return true; } } const child = new FakeProcess(); const cleanup = vi.fn(async () => ({ cleaned: true as const, signaled: false, })); const request = command("unused fake launcher command"); const failure = runOwnedCommand( request, { timeoutMs: 2_000, diagnosticBytes: 1024, acceptNonZeroExit: true }, { launcher: async () => { setImmediate(() => { stdout.write("partial output"); stdout.destroy(new Error("fixture read failure")); stderr.end(); child.exitCode = 7; child.emit("exit", 7, null); child.emit("close", 7, null); }); return { process: child, ownership: { runId: request.runId, leaderPid: child.pid, processGroupId: child.pid, }, cleanup, }; }, }, ); await expect(failure).rejects.toMatchObject({ reason: "process", message: "stdout stream failed: fixture read failure", snapshot: { stdout: { text: "partial output" }, exitCode: 7 }, }); expect(cleanup).toHaveBeenCalledOnce(); }); it("marks an output collection error when the process itself exits zero", async () => { const stdout = new PassThrough(); const stderr = new PassThrough(); class FakeProcess extends EventEmitter { readonly stdout = stdout; readonly stderr = stderr; exitCode: number | null = null; signalCode: NodeJS.Signals | null = null; readonly pid = 41235; kill(): boolean { return true; } } const child = new FakeProcess(); const request = command("unused fake launcher command"); const failure = runOwnedCommand( request, { timeoutMs: 2_000, diagnosticBytes: 1024, acceptNonZeroExit: true }, { launcher: async () => { setImmediate(() => { stdout.write("partial output"); stdout.destroy(new Error("fixture read failure")); stderr.end(); child.exitCode = 0; child.emit("exit", 0, null); child.emit("close", 0, null); }); return { process: child, ownership: { runId: request.runId, leaderPid: child.pid, processGroupId: child.pid, }, cleanup: async () => ({ cleaned: true, signaled: false }), }; }, }, ); await expect(failure).rejects.toMatchObject({ reason: "process", collectionFailure: true, message: "stdout stream failed: fixture read failure", snapshot: { stdout: { text: "partial output" }, exitCode: 0 }, }); }); it.skipIf(process.platform === "win32")( "sweeps detached run-token descendants and preserves a pre-existing unrelated process", async () => { const runId = `rea-owned-descendant-test-${randomUUID()}`; const unrelated = await spawnOwnedProviderProcess({ command: process.execPath, arguments: ["-e", "setInterval(() => undefined, 1000)"], runId: `${runId}-unrelated`, hostEnvironment: process.env, }); let descendantPid: number | undefined; try { const result = await runOwnedCommand( { ...command(` const { spawn } = require("node:child_process"); const child = spawn(process.execPath, ["-e", "setInterval(() => undefined, 1000)"], { detached: true, stdio: "ignore", }); child.unref(); process.stdout.write(String(child.pid)); `), runId, sweepTokenOwnedProcesses: true, }, { timeoutMs: 5_000, diagnosticBytes: 1024 }, ); descendantPid = Number(result.stdout.text); expect(Number.isSafeInteger(descendantPid)).toBe(true); let remainsLive = true; const deadline = Date.now() + 2_000; while (remainsLive && Date.now() < deadline) { remainsLive = (await systemProcessOwnershipHost.listProcesses()).some( ({ pid }) => pid === descendantPid, ); if (remainsLive) await delay(25); } expect(remainsLive).toBe(false); expect(() => process.kill(unrelated.process.pid!, 0)).not.toThrow(); } finally { if (descendantPid !== undefined) { try { process.kill(descendantPid, "SIGKILL"); } catch { // The run-token sweep may already have removed it. } } await cleanupOwnedProcessGroup(unrelated.ownership); } }, ); it.skipIf(process.platform === "win32")( "fails before provider spawn when a requested ownership baseline cannot be captured", async () => { const baseline = vi .spyOn(systemProcessOwnershipHost, "captureBaseline") .mockRejectedValue(new Error("baseline unavailable")); try { await expect( runOwnedCommand( { ...command("process.exit(0)"), sweepTokenOwnedProcesses: true }, { timeoutMs: 2_000, diagnosticBytes: 1024 }, ), ).rejects.toThrow("baseline unavailable"); } finally { baseline.mockRestore(); } }, ); it("cancels a real acquired process and independently releases it", async () => { const controller = new AbortController(); let pid: number | undefined; const result = runOwnedCommand( command( 'process.stdout.write("ready\\n"); setInterval(() => undefined, 1000)', ), { timeoutMs: 5_000, diagnosticBytes: 1024 }, { signal: controller.signal, launcher: async (input) => { const process = await spawnOwnedProviderProcess(input); pid = process.process.pid; await waitForProviderProcessReady(process.process); setImmediate(() => controller.abort()); return process; }, }, ); await expect(result).rejects.toMatchObject({ reason: "cancelled", cleanupFailure: null, }); if (pid === undefined) throw new Error("Process was not acquired"); const acquiredPid = pid; expect(() => process.kill(acquiredPid, 0)).toThrow(); }); it("returns a timeout for a running process after independent cleanup", async () => { await expect( runOwnedCommand(command("setInterval(() => undefined, 1000)"), { timeoutMs: 50, diagnosticBytes: 1024, }), ).rejects.toMatchObject({ reason: "timeout", cleanupFailure: null }); }); it.skipIf(process.platform === "win32").each(["cancelled", "timeout"] as const)( "retains the real per-member cleanup ledger after %s and settles after cooperative exit", async (reason) => { const childScript = 'process.stdin.once("data", () => process.exit(0)); process.stdout.write("child-ready\\n")'; const request = { ...command(` const { spawn } = require("node:child_process"); const child = spawn(process.execPath, ["-e", ${JSON.stringify(childScript)}], { env: { ...process.env, REA_PROCESS_RUN_ID: "different-child-owner" }, stdio: ["pipe", "inherit", "inherit"], }); const release = () => child.stdin.end("release"); process.stdin.once("data", release); const failsafe = setTimeout(release, 10000); child.once("exit", () => { clearTimeout(failsafe); process.stdin.destroy(); }); `), stdin: "pipe" as const, }; const controller = new AbortController(); let launched: | Awaited> | undefined; let cleanup: ProcessCleanupResult | undefined; let failure: OwnedCommandFailure | undefined; try { await runOwnedCommand( request, { timeoutMs: 2000, diagnosticBytes: 1024 }, { signal: controller.signal, launcher: async (input) => { const acquired = await spawnOwnedProviderProcess(input); launched = acquired; acquired.process.stdout?.on("data", (chunk: Buffer) => { if ( reason === "cancelled" && chunk.toString().includes("child-ready") ) controller.abort(); }); return { ...acquired, cleanup: async () => { const observed = await cleanupOwnedProcessGroup( acquired.ownership, ); cleanup ??= observed; return observed; }, }; }, }, ).catch((cause: unknown) => { if (!(cause instanceof OwnedCommandFailure)) throw cause; failure = cause; }); if (failure === undefined || launched === undefined) throw new Error( "The acquired command must retain its failed cleanup owner", ); expect(failure.reason).toBe(reason); expect(failure.snapshot?.stdout.text).toContain("child-ready"); expect(cleanup).toMatchObject({ cleaned: false, failures: [expect.objectContaining({ reason: "run-token-mismatch" })], }); expect(failure.cleanupResult).toEqual(cleanup); expect(failure.resources).toEqual([ request.runId, `pid:${launched.ownership.leaderPid}`, `process-group:${launched.ownership.processGroupId}`, ]); // An unresolved member does not grant authority to signal the shared group. const acquiredPid = launched.ownership.leaderPid; expect(() => process.kill(acquiredPid, 0)).not.toThrow(); if (cleanup?.cleaned === false) { for (const member of cleanup.failures ?? []) { expect(() => process.kill(member.pid, 0)).not.toThrow(); expect(failure.cleanupFailure).toContain(String(member.pid)); } } } finally { // Release through the fixture's acquired input channel, never by an unverified PID. launched?.process.stdin?.end("release"); if (failure?.cleanupOwner !== undefined) { expect(await failure.cleanupOwner.waitForOutputClose(5000)).toBe(true); expect(await failure.cleanupOwner.stop()).toEqual({ status: "verified-cleanup", }); } } }, ); it("honors cancellation during cleanup after a successful command", async () => { const controller = new AbortController(); await expect( runOwnedCommand( command("process.exit(0)"), { timeoutMs: 2_000, diagnosticBytes: 1024 }, { signal: controller.signal, launcher: async (input) => { const launched = await spawnOwnedProviderProcess(input); return { ...launched, cleanup: async () => { const result = await cleanupOwnedProcessGroup(launched.ownership); controller.abort(); return result; }, }; }, }, ), ).rejects.toMatchObject({ reason: "cancelled", cleanupFailure: null }); }); it("rejects oversized diagnostics even when the child exits immediately", async () => { await expect( runOwnedCommand(command('process.stdout.write("x".repeat(2048))'), { timeoutMs: 2_000, diagnosticBytes: 1024, }), ).rejects.toMatchObject({ reason: "output-limit", cleanupFailure: null }); }); it("preserves original command failure together with failed cleanup resource identities", async () => { const request = command("process.exit(2)"); await expect( runOwnedCommand( request, { timeoutMs: 2_000, diagnosticBytes: 1024 }, { launcher: async (input) => { const process = await spawnOwnedProviderProcess(input); return { ...process, cleanup: async () => ({ cleaned: false as const, reason: "synthetic cleanup could not be confirmed", }), }; }, }, ), ).rejects.toMatchObject({ reason: "process", cleanupFailure: "synthetic cleanup could not be confirmed", resources: expect.arrayContaining([request.runId]), snapshot: { exitCode: 2 }, }); });