mirror of
https://github.com/morluto/rea.git
synced 2026-10-28 02:15:44 +00:00
Separate verified process cleanup from output closure. Bound additional retention from owned stop entry through cleanup retry, drain before detaching capture, and mark unreadable or abandoned output incomplete. Release owned event-loop references on drain timeout without changing the proven cleanup disposition; preserve borrowed handle reference state. Cover held and failed cleanup, late tail, bounded drain, stream errors, borrowed authority, and a real POSIX inherited-stderr release barrier. The real pipe control drops its final ten bytes on the baseline and retains them with this change. Focused consumer checks passed, including simulated Windows ownership; native Windows and real provider failure scope remain unverified. Android still constructs its startup error snapshot before retirement; that consumer correction remains separate work. Fixes #4840
493 lines
16 KiB
TypeScript
493 lines
16 KiB
TypeScript
import { EventEmitter } from "node:events";
|
|
import { PassThrough } from "node:stream";
|
|
import { expect, it, vi } from "vitest";
|
|
import { ProviderProcessSupervisor } from "./ProviderProcess.js";
|
|
import { redactCapturedTransportCredential } from "./ProviderDiagnosticRedaction.js";
|
|
|
|
it("retains final stderr between verified exit and output close during stop", async () => {
|
|
const producer = Object.assign(new EventEmitter(), {
|
|
stdout: new PassThrough(),
|
|
stderr: new PassThrough(),
|
|
exitCode: null,
|
|
signalCode: null,
|
|
kill: () => false,
|
|
});
|
|
let cleanupStarted: () => void = () => {};
|
|
const started = new Promise<void>((resolve) => {
|
|
cleanupStarted = resolve;
|
|
});
|
|
const owner = new ProviderProcessSupervisor({
|
|
process: producer,
|
|
ownsProcessLifetime: true,
|
|
cleanup: async () => {
|
|
producer.emit("exit", 2, null);
|
|
cleanupStarted();
|
|
return { cleaned: true, signaled: false };
|
|
},
|
|
});
|
|
const stopped = owner.stop();
|
|
expect(owner.stop()).toBe(stopped);
|
|
await started;
|
|
// Let cleanup observe exit before delivering the separately acknowledged output.
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
producer.stderr.end("final launcher diagnostic");
|
|
producer.stdout.end();
|
|
producer.emit("close", 2, null);
|
|
await expect(stopped).resolves.toEqual({ status: "verified-cleanup" });
|
|
expect(owner.snapshot()).toMatchObject({
|
|
stderr: { text: "final launcher diagnostic" },
|
|
exitCode: 2,
|
|
});
|
|
expect(await owner.waitForOutputClose(0)).toBe(true);
|
|
});
|
|
|
|
it("bounds late output retention and drain without changing verified cleanup", async () => {
|
|
vi.useFakeTimers();
|
|
const producer = Object.assign(new EventEmitter(), {
|
|
stdout: new PassThrough(),
|
|
stderr: Object.assign(new PassThrough(), { unref: vi.fn() }),
|
|
exitCode: null,
|
|
signalCode: null,
|
|
kill: () => false,
|
|
});
|
|
const owner = new ProviderProcessSupervisor({
|
|
process: producer,
|
|
ownsProcessLifetime: true,
|
|
cleanup: async () => {
|
|
producer.emit("exit", 0, null);
|
|
return { cleaned: true, signaled: false };
|
|
},
|
|
});
|
|
const checkpoint = owner.checkpointOutput();
|
|
try {
|
|
const stopped = owner.stop();
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
producer.stderr.write("x".repeat(65537));
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
await expect(stopped).resolves.toEqual({ status: "verified-cleanup" });
|
|
expect(owner.snapshot()).toMatchObject({
|
|
stderr: { bytes: 65536, observedBytes: 65537 },
|
|
outputIncomplete: true,
|
|
diagnosticTruncated: true,
|
|
});
|
|
expect(owner.snapshotOutputSince(checkpoint).complete).toBe(false);
|
|
expect(producer.stderr.destroyed).toBe(false);
|
|
expect(producer.stderr.unref).toHaveBeenCalledOnce();
|
|
producer.emit("close", 0, null);
|
|
expect(owner.snapshot().outputIncomplete).toBe(true);
|
|
} finally {
|
|
owner.dispose();
|
|
producer.stdout.destroy();
|
|
producer.stderr.destroy();
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("retries process cleanup with capture attached until output closes", async () => {
|
|
const producer = Object.assign(new EventEmitter(), {
|
|
stdout: new PassThrough(),
|
|
stderr: new PassThrough(),
|
|
exitCode: null,
|
|
signalCode: null,
|
|
kill: vi.fn(() => false),
|
|
});
|
|
let fails = true;
|
|
const owner = new ProviderProcessSupervisor({
|
|
process: producer,
|
|
ownsProcessLifetime: true,
|
|
cleanup: async () => {
|
|
if (fails) return { cleaned: false, reason: "identity unavailable" };
|
|
producer.emit("exit", 0, null);
|
|
return { cleaned: true, signaled: false };
|
|
},
|
|
});
|
|
await expect(owner.stop()).resolves.toMatchObject({ status: "incomplete" });
|
|
producer.stderr.write("retained during retry");
|
|
fails = false;
|
|
const retried = owner.stop();
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
producer.stderr.end(" and final tail");
|
|
producer.stdout.end();
|
|
producer.emit("close", 0, null);
|
|
await expect(retried).resolves.toEqual({ status: "verified-cleanup" });
|
|
expect(owner.snapshot().stderr.text).toBe(
|
|
"retained during retry and final tail",
|
|
);
|
|
});
|
|
|
|
it("bounds additional retention during held cleanup and across retries", async () => {
|
|
const producer = Object.assign(new EventEmitter(), {
|
|
stdout: new PassThrough(),
|
|
stderr: new PassThrough(),
|
|
exitCode: null,
|
|
signalCode: null,
|
|
kill: () => false,
|
|
});
|
|
let release: () => void = () => {};
|
|
const held = new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
let fails = true;
|
|
const owner = new ProviderProcessSupervisor({
|
|
process: producer,
|
|
ownsProcessLifetime: true,
|
|
cleanup: async () => {
|
|
if (fails) {
|
|
await held;
|
|
return { cleaned: false, reason: "held ownership inspection failed" };
|
|
}
|
|
producer.stdout.end();
|
|
producer.stderr.end();
|
|
producer.emit("exit", 0, null);
|
|
producer.emit("close", 0, null);
|
|
return { cleaned: true, signaled: false };
|
|
},
|
|
});
|
|
const stopped = owner.stop();
|
|
producer.stderr.write("x".repeat(65537));
|
|
expect(owner.snapshot().stderr).toMatchObject({
|
|
bytes: 65536,
|
|
observedBytes: 65537,
|
|
});
|
|
release();
|
|
await expect(stopped).resolves.toMatchObject({ status: "incomplete" });
|
|
producer.stderr.write("tail");
|
|
fails = false;
|
|
await expect(owner.stop()).resolves.toEqual({ status: "verified-cleanup" });
|
|
expect(owner.snapshot()).toMatchObject({
|
|
stderr: { bytes: 65536, observedBytes: 65541 },
|
|
diagnosticTruncated: true,
|
|
});
|
|
});
|
|
|
|
it("releases borrowed capture as incomplete without killing or destroying streams", async () => {
|
|
const borrowedProcess = Object.assign(new EventEmitter(), {
|
|
stdout: Object.assign(new PassThrough(), { ref: vi.fn(), unref: vi.fn() }),
|
|
stderr: Object.assign(new PassThrough(), { ref: vi.fn(), unref: vi.fn() }),
|
|
exitCode: null,
|
|
signalCode: null,
|
|
kill: vi.fn(() => false),
|
|
ref: vi.fn(),
|
|
unref: vi.fn(),
|
|
});
|
|
const borrowed = new ProviderProcessSupervisor({
|
|
process: borrowedProcess,
|
|
ownsProcessLifetime: false,
|
|
});
|
|
await expect(borrowed.stop()).resolves.toEqual({ status: "not-owned" });
|
|
expect(borrowedProcess.kill).not.toHaveBeenCalled();
|
|
for (const handle of [
|
|
borrowedProcess,
|
|
borrowedProcess.stdout,
|
|
borrowedProcess.stderr,
|
|
]) {
|
|
expect(handle.ref).not.toHaveBeenCalled();
|
|
expect(handle.unref).not.toHaveBeenCalled();
|
|
}
|
|
expect(borrowedProcess.stderr.destroyed).toBe(false);
|
|
expect(borrowed.snapshot().outputIncomplete).toBe(true);
|
|
borrowedProcess.stdout.destroy();
|
|
borrowedProcess.stderr.destroy();
|
|
});
|
|
|
|
it("retains stream failure incompleteness even after output close", async () => {
|
|
const producer = Object.assign(new EventEmitter(), {
|
|
stdout: new PassThrough(),
|
|
stderr: new PassThrough(),
|
|
exitCode: null,
|
|
signalCode: null,
|
|
kill: () => false,
|
|
});
|
|
const owner = new ProviderProcessSupervisor({
|
|
process: producer,
|
|
ownsProcessLifetime: false,
|
|
});
|
|
const checkpoint = owner.checkpointOutput();
|
|
producer.stderr.emit("error", new Error("read failure"));
|
|
producer.emit("close", 1, null);
|
|
expect(await owner.waitForOutputClose(0)).toBe(true);
|
|
expect(owner.snapshot()).toMatchObject({
|
|
outputIncomplete: true,
|
|
diagnosticTruncated: true,
|
|
});
|
|
expect(owner.snapshotOutputSince(checkpoint).complete).toBe(false);
|
|
owner.dispose();
|
|
producer.stdout.destroy();
|
|
producer.stderr.destroy();
|
|
});
|
|
|
|
it("exit does not establish complete output when a producer closes its streams later", async () => {
|
|
const producer = Object.assign(new EventEmitter(), {
|
|
stdout: new PassThrough(),
|
|
stderr: new PassThrough(),
|
|
exitCode: null,
|
|
signalCode: null,
|
|
kill: () => false,
|
|
});
|
|
const supervisor = new ProviderProcessSupervisor({
|
|
process: producer,
|
|
ownsProcessLifetime: false,
|
|
});
|
|
try {
|
|
producer.stdout.write("first");
|
|
producer.emit("exit", 0, null);
|
|
expect(await supervisor.waitForExit(0)).toBe(true);
|
|
expect(await supervisor.waitForOutputClose(0)).toBe(false);
|
|
producer.stdout.end("last");
|
|
producer.stderr.end("diagnostic");
|
|
producer.emit("close", 0, null);
|
|
expect(await supervisor.waitForOutputClose(0)).toBe(true);
|
|
expect(supervisor.snapshot()).toMatchObject({
|
|
stdout: { text: "firstlast" },
|
|
stderr: { text: "diagnostic" },
|
|
exitCode: 0,
|
|
});
|
|
} finally {
|
|
supervisor.dispose();
|
|
}
|
|
});
|
|
|
|
it("reads an observed exit without materializing stream diagnostics", () => {
|
|
const producer = Object.assign(new EventEmitter(), {
|
|
stdout: new PassThrough(),
|
|
stderr: new PassThrough(),
|
|
exitCode: null,
|
|
signalCode: null,
|
|
kill: () => false,
|
|
});
|
|
const supervisor = new ProviderProcessSupervisor({
|
|
process: producer,
|
|
ownsProcessLifetime: false,
|
|
});
|
|
try {
|
|
producer.stdout.write("retained-diagnostic");
|
|
const snapshot = vi.spyOn(supervisor, "snapshot");
|
|
expect(supervisor.observedExit()).toBeUndefined();
|
|
producer.emit("exit", null, "SIGKILL");
|
|
expect(supervisor.observedExit()).toEqual({
|
|
code: null,
|
|
signal: "SIGKILL",
|
|
});
|
|
expect(snapshot).not.toHaveBeenCalled();
|
|
expect(supervisor.snapshot()).toMatchObject({
|
|
stdout: { text: "retained-diagnostic" },
|
|
exitCode: null,
|
|
signal: "SIGKILL",
|
|
});
|
|
} finally {
|
|
supervisor.dispose();
|
|
}
|
|
});
|
|
|
|
it("scopes output checkpoints to raw bytes on one supervisor and epoch", () => {
|
|
const first = Object.assign(new EventEmitter(), {
|
|
stdout: new PassThrough(),
|
|
stderr: new PassThrough(),
|
|
exitCode: null,
|
|
signalCode: null,
|
|
kill: () => false,
|
|
});
|
|
const second = Object.assign(new EventEmitter(), {
|
|
stdout: new PassThrough(),
|
|
stderr: new PassThrough(),
|
|
exitCode: null,
|
|
signalCode: null,
|
|
kill: () => false,
|
|
});
|
|
const owner = new ProviderProcessSupervisor(
|
|
{ process: first, ownsProcessLifetime: false },
|
|
{ maxDiagnosticBytes: 128 },
|
|
);
|
|
const other = new ProviderProcessSupervisor({
|
|
process: second,
|
|
ownsProcessLifetime: false,
|
|
});
|
|
try {
|
|
first.stdout.write(Buffer.from("prior: 🧭\n", "utf8"));
|
|
const checkpoint = owner.checkpointOutput();
|
|
first.stdout.write(Buffer.from("current: é\n", "utf8"));
|
|
first.stderr.write(Buffer.from([0xff, 0x61]));
|
|
const scoped = owner.snapshotOutputSince(checkpoint);
|
|
expect(scoped.complete).toBe(true);
|
|
expect(scoped.snapshot.stdout.text).toBe("current: é\n");
|
|
expect(scoped.snapshot.stdout.bytes).toBe(
|
|
Buffer.byteLength("current: é\n"),
|
|
);
|
|
expect(scoped.snapshot.stderr).toEqual({
|
|
text: "\ufffda",
|
|
bytes: 2,
|
|
observedBytes: 2,
|
|
invalidUtf8: true,
|
|
});
|
|
expect(other.snapshotOutputSince(checkpoint).complete).toBe(false);
|
|
owner.resetOutput();
|
|
expect(owner.snapshotOutputSince(checkpoint).complete).toBe(false);
|
|
} finally {
|
|
owner.dispose();
|
|
other.dispose();
|
|
first.stdout.destroy();
|
|
first.stderr.destroy();
|
|
second.stdout.destroy();
|
|
second.stderr.destroy();
|
|
}
|
|
});
|
|
|
|
it.each(["stdout", "stderr"] as const)(
|
|
"redacts a budget-cut credential in scoped %s after prior output",
|
|
(stream) => {
|
|
const producer = Object.assign(new EventEmitter(), {
|
|
stdout: new PassThrough(),
|
|
stderr: new PassThrough(),
|
|
exitCode: null,
|
|
signalCode: null,
|
|
kill: () => false,
|
|
});
|
|
const supervisor = new ProviderProcessSupervisor(
|
|
{ process: producer, ownsProcessLifetime: false },
|
|
{ maxDiagnosticBytes: 40 },
|
|
);
|
|
try {
|
|
producer.stdout.write("p".repeat(32));
|
|
const checkpoint = supervisor.checkpointOutput();
|
|
const token = "private-transport-token";
|
|
producer[stream].write(token);
|
|
const scoped = supervisor.snapshotOutputSince(checkpoint);
|
|
expect(scoped.complete).toBe(false);
|
|
expect(scoped.snapshot.outputIncomplete).toBe(true);
|
|
expect(scoped.snapshot[stream]).toMatchObject({
|
|
bytes: 8,
|
|
observedBytes: Buffer.byteLength(token),
|
|
text: token.slice(0, 8),
|
|
});
|
|
expect(
|
|
redactCapturedTransportCredential(
|
|
scoped.snapshot[stream],
|
|
token,
|
|
"[REDACTED]",
|
|
),
|
|
).toBe("[REDACTED]");
|
|
} finally {
|
|
supervisor.dispose();
|
|
producer.stdout.destroy();
|
|
producer.stderr.destroy();
|
|
}
|
|
},
|
|
);
|
|
|
|
it("observes an exit that completed before supervision", () => {
|
|
const producer = Object.assign(new EventEmitter(), {
|
|
stdout: new PassThrough(),
|
|
stderr: new PassThrough(),
|
|
exitCode: 2,
|
|
signalCode: null,
|
|
kill: () => false,
|
|
});
|
|
const supervisor = new ProviderProcessSupervisor({
|
|
process: producer,
|
|
ownsProcessLifetime: false,
|
|
});
|
|
try {
|
|
expect(supervisor.observedExit()).toEqual({ code: 2, signal: null });
|
|
} finally {
|
|
supervisor.dispose();
|
|
}
|
|
});
|
|
|
|
it("reports a reached process memory limit only when the handle observed one", () => {
|
|
let reached = false;
|
|
const producer = Object.assign(new EventEmitter(), {
|
|
stdout: new PassThrough(),
|
|
stderr: new PassThrough(),
|
|
exitCode: null,
|
|
signalCode: null,
|
|
kill: () => false,
|
|
});
|
|
// A live accessor, as on the owned Windows process handle.
|
|
Object.defineProperty(producer, "memoryLimitReached", { get: () => reached });
|
|
const exitSnapshots: unknown[] = [];
|
|
const supervisor = new ProviderProcessSupervisor(
|
|
{ process: producer, ownsProcessLifetime: false },
|
|
{
|
|
onDiagnostic: (event) => {
|
|
if (event.type === "exit") exitSnapshots.push(event.snapshot);
|
|
},
|
|
},
|
|
);
|
|
try {
|
|
expect(supervisor.snapshot()).not.toHaveProperty("memoryLimitReached");
|
|
// Owned launchers record the job notification before emitting exit.
|
|
reached = true;
|
|
producer.emit("exit", 3, null);
|
|
expect(supervisor.snapshot()).toMatchObject({
|
|
exitCode: 3,
|
|
memoryLimitReached: true,
|
|
});
|
|
expect(exitSnapshots).toEqual([
|
|
expect.objectContaining({ memoryLimitReached: true }),
|
|
]);
|
|
} finally {
|
|
supervisor.dispose();
|
|
}
|
|
});
|
|
|
|
it("projects provider output loss as truncated diagnostics without inventing a byte count", () => {
|
|
const producer = Object.assign(new EventEmitter(), {
|
|
stdout: new PassThrough(),
|
|
stderr: new PassThrough(),
|
|
exitCode: null,
|
|
signalCode: null,
|
|
kill: () => false,
|
|
});
|
|
Object.defineProperty(producer, "outputIncomplete", { value: true });
|
|
const supervisor = new ProviderProcessSupervisor({
|
|
process: producer,
|
|
ownsProcessLifetime: false,
|
|
});
|
|
try {
|
|
expect(supervisor.snapshot()).toMatchObject({
|
|
diagnosticTruncated: true,
|
|
outputIncomplete: true,
|
|
});
|
|
expect(supervisor.snapshot()).not.toHaveProperty("discardedOutputBytes");
|
|
} finally {
|
|
supervisor.dispose();
|
|
}
|
|
});
|
|
|
|
it("unreferences process and stream handles when stop reports incomplete cleanup", async () => {
|
|
const unrefProcess = vi.fn();
|
|
const unrefStdin = vi.fn();
|
|
const unrefStdout = vi.fn();
|
|
const unrefStderr = vi.fn();
|
|
const producer = Object.assign(new EventEmitter(), {
|
|
stdout: Object.assign(new PassThrough(), { unref: unrefStdout }),
|
|
stdin: Object.assign(new PassThrough(), { unref: unrefStdin }),
|
|
stderr: Object.assign(new PassThrough(), { unref: unrefStderr }),
|
|
exitCode: null,
|
|
signalCode: null,
|
|
kill: () => false,
|
|
unref: unrefProcess,
|
|
});
|
|
const supervisor = new ProviderProcessSupervisor({
|
|
process: producer,
|
|
ownsProcessLifetime: true,
|
|
cleanup: async () => ({
|
|
cleaned: false,
|
|
reason: "process table could not be inspected: spawn EPERM",
|
|
}),
|
|
});
|
|
try {
|
|
const result = await supervisor.stop();
|
|
expect(result).toMatchObject({
|
|
status: "incomplete",
|
|
reason: "process table could not be inspected: spawn EPERM",
|
|
});
|
|
expect(unrefProcess).toHaveBeenCalledTimes(1);
|
|
expect(unrefStdin).toHaveBeenCalledTimes(1);
|
|
expect(unrefStdout).toHaveBeenCalledTimes(1);
|
|
expect(unrefStderr).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
supervisor.dispose();
|
|
}
|
|
});
|