Files
rea/tests/providerProcess.test.ts
T
N0zoM1z0 70e1a0f532 refactor(process): extract reusable provider lifecycle primitives
Introduce provider-neutral ownership, private runtime roots, absolute startup deadlines, pending-operation correlation, bounded output capture, and verified TERM-to-KILL process supervision.\n\nMigrate Hopper onto the shared lifecycle pieces while keeping its Unix-socket protocol and health/shutdown parsing adapter-specific. Preserve exact ownership checks and fail-closed cleanup, including descendant cleanup after the launcher leader exits.\n\nAdd reusable process fixtures and deterministic coverage for startup timeout, cancellation, output bounds, exit diagnostics, cleanup escalation, repeated close, spawn failure, and resource disposal. Update architecture, provider evaluation, contributor guidance, and generated API documentation.
2026-07-15 23:20:48 +09:00

267 lines
9.1 KiB
TypeScript

import { getEventListeners } from "node:events";
import { access, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it, vi } from "vitest";
import { PendingOperations } from "../src/process/PendingOperations.js";
import { PrivateRuntimeRoot } from "../src/process/PrivateRuntimeRoot.js";
import { ProviderStartupDeadline } from "../src/process/ProviderDeadline.js";
import {
ProviderProcessSupervisor,
spawnOwnedProviderProcess,
type ProviderProcessDiagnostic,
} from "../src/process/ProviderProcess.js";
import {
spawnProviderProcessFixture,
stopProviderProcessFixture,
waitForProviderProcessReady,
} from "./fixtures/providerProcess.js";
const processFixturePath = fileURLToPath(
new URL("./fixtures/providerProcess.mjs", import.meta.url),
);
afterEach(() => {
vi.useRealTimers();
});
describe("provider process lifecycle primitives", () => {
it("allocates a private runtime root and removes it idempotently", async () => {
const runtime = await PrivateRuntimeRoot.create({
parent: tmpdir(),
prefix: "rea-provider-test-",
});
expect((await stat(runtime.path)).mode & 0o777).toBe(0o700);
const first = runtime.close();
const second = runtime.close();
expect(second).toBe(first);
await Promise.all([first, second]);
await expect(access(runtime.path)).rejects.toMatchObject({
code: "ENOENT",
});
});
it("clears the startup timer and external cancellation listener", async () => {
vi.useFakeTimers();
const controller = new AbortController();
const deadline = new ProviderStartupDeadline(1_000, controller.signal);
expect(getEventListeners(controller.signal, "abort")).toHaveLength(1);
const waiting = deadline.wait(1_000);
controller.abort();
await expect(waiting).resolves.toBe("aborted");
expect(deadline.cancelled).toBe(true);
expect(getEventListeners(controller.signal, "abort")).toHaveLength(0);
expect(vi.getTimerCount()).toBe(0);
deadline.dispose();
});
it("uses one absolute startup deadline across interval waits", async () => {
vi.useFakeTimers();
const deadline = new ProviderStartupDeadline(100);
const waiting = deadline.wait(1_000);
await vi.advanceTimersByTimeAsync(100);
await expect(waiting).resolves.toBe("aborted");
expect(deadline.timedOut).toBe(true);
expect(deadline.remainingMs()).toBe(0);
deadline.dispose();
expect(vi.getTimerCount()).toBe(0);
});
it("does not reclassify an elapsed deadline as later cancellation", async () => {
vi.useFakeTimers();
const controller = new AbortController();
const deadline = new ProviderStartupDeadline(50, controller.signal);
await vi.advanceTimersByTimeAsync(50);
controller.abort();
expect(deadline.signal.reason).toMatchObject({ name: "TimeoutError" });
expect(deadline.timedOut).toBe(true);
expect(deadline.cancelled).toBe(false);
expect(getEventListeners(controller.signal, "abort")).toHaveLength(0);
expect(vi.getTimerCount()).toBe(0);
deadline.dispose();
});
it("releases pending request timers and abort listeners on every outcome", async () => {
vi.useFakeTimers();
const operations = new PendingOperations<number, string>();
const controller = new AbortController();
const cancelled = operations.wait(1, {
timeoutMs: 1_000,
signal: controller.signal,
timeoutValue: () => "timeout",
cancelledValue: () => "cancelled",
});
expect(getEventListeners(controller.signal, "abort")).toHaveLength(1);
controller.abort();
await expect(cancelled).resolves.toBe("cancelled");
expect(getEventListeners(controller.signal, "abort")).toHaveLength(0);
const timedOut = operations.wait(2, {
timeoutMs: 50,
timeoutValue: () => "timeout",
cancelledValue: () => "cancelled",
});
await vi.advanceTimersByTimeAsync(50);
await expect(timedOut).resolves.toBe("timeout");
const failed = operations.wait(3, {
timeoutMs: 1_000,
timeoutValue: () => "timeout",
cancelledValue: () => "cancelled",
});
operations.failAll((key) => `failed:${String(key)}`);
await expect(failed).resolves.toBe("failed:3");
expect(operations.size).toBe(0);
expect(vi.getTimerCount()).toBe(0);
});
it("captures exact byte totals while bounding retained stdout and stderr", async () => {
const child = spawnProviderProcessFixture("burst", 4_096);
const diagnostics: ProviderProcessDiagnostic[] = [];
const supervisor = new ProviderProcessSupervisor(
{ process: child, ownsProcessLifetime: true },
{
maxOutputBytesPerStream: 64,
onDiagnostic: (event) => diagnostics.push(event),
},
);
await expect(supervisor.waitForExit(2_000)).resolves.toBe(true);
const snapshot = supervisor.snapshot();
expect(snapshot).toMatchObject({
stdout: { bytes: 4_096, retainedBytes: 64, truncated: true },
stderr: { bytes: 4_096, retainedBytes: 64, truncated: true },
exitCode: 23,
signal: null,
});
expect(snapshot.stdout.text).toBe("o".repeat(64));
expect(snapshot.stderr.text).toBe("e".repeat(64));
expect(diagnostics).toContainEqual(
expect.objectContaining({ type: "exit", code: 23 }),
);
await expect(supervisor.stop()).resolves.toEqual({
status: "already-exited",
});
});
it("shares double-stop and escalates a stubborn child from TERM to KILL", async () => {
const child = spawnProviderProcessFixture("stubborn");
const supervisor = new ProviderProcessSupervisor({
process: child,
ownsProcessLifetime: true,
});
await waitForProviderProcessReady(child);
const first = supervisor.stop({ terminationGraceMs: 20, killGraceMs: 500 });
const second = supervisor.stop({
terminationGraceMs: 20,
killGraceMs: 500,
});
expect(second).toBe(first);
await expect(first).resolves.toEqual({ status: "killed" });
expect(child.signalCode).toBe("SIGKILL");
expect(getEventListeners(child, "exit")).toHaveLength(0);
expect(getEventListeners(child, "close")).toHaveLength(0);
expect(getEventListeners(child, "error")).toHaveLength(0);
});
it("honors verified group cleanup instead of direct process signaling", async () => {
const child = spawnProviderProcessFixture("stubborn");
await waitForProviderProcessReady(child);
const cleanup = vi.fn(async () => {
child.kill("SIGKILL");
return { cleaned: true, signaled: true } as const;
});
const supervisor = new ProviderProcessSupervisor({
process: child,
ownsProcessLifetime: true,
cleanup,
});
await expect(supervisor.stop({ killGraceMs: 500 })).resolves.toEqual({
status: "verified-cleanup",
});
expect(cleanup).toHaveBeenCalledOnce();
});
it("cleans an owned group even after its launcher leader has exited", async () => {
const child = spawnProviderProcessFixture("exit", 0);
const cleanup = vi.fn(
async () => ({ cleaned: true, signaled: false }) as const,
);
const supervisor = new ProviderProcessSupervisor({
process: child,
ownsProcessLifetime: true,
cleanup,
});
await expect(supervisor.waitForExit(2_000)).resolves.toBe(true);
await expect(supervisor.stop()).resolves.toEqual({
status: "verified-cleanup",
});
expect(cleanup).toHaveBeenCalledOnce();
});
it("reports incomplete cleanup when a verified callback leaves the child alive", async () => {
const child = spawnProviderProcessFixture("stubborn");
await waitForProviderProcessReady(child);
const cleanup = vi.fn(
async () => ({ cleaned: true, signaled: false }) as const,
);
const supervisor = new ProviderProcessSupervisor({
process: child,
ownsProcessLifetime: true,
cleanup,
});
try {
await expect(supervisor.stop({ killGraceMs: 20 })).resolves.toEqual({
status: "incomplete",
reason: "verified process-group cleanup did not stop the launcher",
});
expect(cleanup).toHaveBeenCalledTimes(2);
} finally {
await stopProviderProcessFixture(child);
}
});
it("spawns an owned process group with exact identity coordinates", async () => {
const runId = "provider-process-run";
const spawned = await spawnOwnedProviderProcess({
command: process.execPath,
arguments: [processFixturePath, "graceful"],
runId,
});
try {
expect(spawned.ownership).toMatchObject({
runId,
leaderPid: spawned.process.pid,
processGroupId: spawned.process.pid,
expectedCommand: process.execPath,
expectedParentPid: process.pid,
});
expect(spawned.process.stdout).not.toBeNull();
expect(spawned.process.stderr).not.toBeNull();
} finally {
await stopProviderProcessFixture(spawned.process);
}
});
it("rejects deterministic spawn failures without producing a process", async () => {
await expect(
spawnOwnedProviderProcess({
command: `${tmpdir()}/rea-provider-command-that-does-not-exist`,
arguments: [],
runId: "missing-provider",
}),
).rejects.toMatchObject({ code: "ENOENT" });
});
});