rea/tests/boundary/process/ownedCommand.test.ts

470 lines
16 KiB
TypeScript

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<ReturnType<typeof spawnOwnedProviderProcess>>
| 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 },
});
});