mirror of
https://github.com/morluto/rea.git
synced 2026-11-12 01:41:21 +00:00
* test: focus configuration diagnostics on their owners Preserve shape errors, selected paths, recovery guidance, and unchanged-file behavior across parser, setup, and uninstall boundaries. * test: focus artifact and evidence checks on behavior Retain format, identity, malformed-input, and cleanup coverage while removing fixed prose and closing the admitted test source. * refactor(debugger): use configured session admission limits * test: focus evidence and diagnostics on behavior * fix(test): restore verifier export and formatting * test(config): verify debugger limit ranges * test(rizin): keep fixture executable authoritative * test(rizin): isolate debugger fixture environment
491 lines
15 KiB
TypeScript
491 lines
15 KiB
TypeScript
import { EventEmitter } from "node:events";
|
|
import { PassThrough, Writable } from "node:stream";
|
|
import { expect, it, vi } from "vitest";
|
|
|
|
import { createDeferred } from "../../tests/fixtures/binarySession.js";
|
|
import { OwnedCommandFailure, runOwnedCommand } from "./OwnedCommand.js";
|
|
import type { ProcessCleanupResult } from "./ProcessOwnership.js";
|
|
|
|
class CommandChild extends EventEmitter {
|
|
readonly stdout = new PassThrough();
|
|
readonly stderr = new PassThrough();
|
|
readonly kill = vi.fn(() => false);
|
|
exitCode: number | null = null;
|
|
readonly signalCode = null;
|
|
|
|
constructor(readonly stdin: Writable | null) {
|
|
super();
|
|
}
|
|
|
|
complete(): void {
|
|
if (this.exitCode !== null) return;
|
|
this.exitCode = 0;
|
|
this.stdout.end();
|
|
this.stderr.end();
|
|
this.emit("exit", 0, null);
|
|
this.emit("close", 0, null);
|
|
}
|
|
}
|
|
|
|
const ownedInput = (input: Writable | null) => {
|
|
const child = new CommandChild(input);
|
|
const ownership = {
|
|
runId: "authored-command-input",
|
|
leaderPid: 41234,
|
|
processGroupId: 41234,
|
|
};
|
|
const cleanup = vi.fn(async (): Promise<ProcessCleanupResult> => {
|
|
input?.destroy();
|
|
child.complete();
|
|
return { cleaned: true, signaled: false };
|
|
});
|
|
return { child, ownership, cleanup };
|
|
};
|
|
|
|
const spawn = {
|
|
command: "authored-command",
|
|
arguments: [],
|
|
runId: "authored-command-input",
|
|
};
|
|
|
|
it("captures synchronous EOF output after supervision and interprets it before cleanup", async () => {
|
|
const stages: string[] = [];
|
|
const input = new Writable({
|
|
final(callback) {
|
|
stages.push("input-final");
|
|
fixture.child.stdout.write("EOF output");
|
|
fixture.child.stderr.write("EOF diagnostic");
|
|
fixture.child.complete();
|
|
callback();
|
|
},
|
|
});
|
|
const fixture = ownedInput(input);
|
|
const cleanup = fixture.cleanup.getMockImplementation()!;
|
|
fixture.cleanup.mockImplementation(async () => {
|
|
stages.push("cleanup");
|
|
return cleanup();
|
|
});
|
|
const snapshot = await runOwnedCommand(
|
|
spawn,
|
|
{
|
|
timeoutMs: null,
|
|
diagnosticBytes: 1024,
|
|
stdin: "closed-pipe",
|
|
observeCompleted: () => stages.push("observed"),
|
|
},
|
|
{
|
|
launcher: async (request) => {
|
|
expect(request.stdin).toBe("pipe");
|
|
expect(input.writableEnded).toBe(false);
|
|
return {
|
|
process: fixture.child,
|
|
ownership: fixture.ownership,
|
|
cleanup: fixture.cleanup,
|
|
};
|
|
},
|
|
},
|
|
);
|
|
expect(snapshot).toMatchObject({
|
|
exitCode: 0,
|
|
stdout: { text: "EOF output" },
|
|
stderr: { text: "EOF diagnostic" },
|
|
});
|
|
expect(stages).toEqual(["input-final", "observed", "cleanup"]);
|
|
expect(fixture.child.kill).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("waits for delayed input finalization after output closes", async () => {
|
|
const finalizing = createDeferred<void>();
|
|
let finishInput: () => void = () => {};
|
|
const input = new Writable({
|
|
final(callback) {
|
|
finishInput = callback;
|
|
fixture.child.complete();
|
|
finalizing.resolve();
|
|
},
|
|
});
|
|
const fixture = ownedInput(input);
|
|
const observeCompleted = vi.fn();
|
|
const pending = runOwnedCommand(
|
|
spawn,
|
|
{
|
|
timeoutMs: null,
|
|
diagnosticBytes: 1024,
|
|
stdin: "closed-pipe",
|
|
observeCompleted,
|
|
},
|
|
{
|
|
launcher: async () => ({
|
|
process: fixture.child,
|
|
ownership: fixture.ownership,
|
|
cleanup: fixture.cleanup,
|
|
}),
|
|
},
|
|
);
|
|
await finalizing.promise;
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
expect(observeCompleted).not.toHaveBeenCalled();
|
|
expect(fixture.cleanup).not.toHaveBeenCalled();
|
|
finishInput();
|
|
await expect(pending).resolves.toMatchObject({ exitCode: 0 });
|
|
expect(observeCompleted).toHaveBeenCalledOnce();
|
|
expect(fixture.cleanup).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it.each([2_000, null])(
|
|
"cancels delayed input finalization with the opaque caller reason (timeout %s)",
|
|
async (timeoutMs) => {
|
|
const controller = new AbortController();
|
|
const reason = new OwnedCommandFailure("timeout", "opaque caller reason");
|
|
const finalizing = createDeferred<void>();
|
|
const input = new Writable({
|
|
final(_callback) {
|
|
fixture.child.complete();
|
|
finalizing.resolve();
|
|
},
|
|
});
|
|
const fixture = ownedInput(input);
|
|
const observeCompleted = vi.fn();
|
|
const pending = runOwnedCommand(
|
|
spawn,
|
|
{
|
|
timeoutMs,
|
|
diagnosticBytes: 1024,
|
|
stdin: "closed-pipe",
|
|
observeCompleted,
|
|
},
|
|
{
|
|
signal: controller.signal,
|
|
launcher: async () => ({
|
|
process: fixture.child,
|
|
ownership: fixture.ownership,
|
|
cleanup: fixture.cleanup,
|
|
}),
|
|
},
|
|
);
|
|
const rejected = expect(pending).rejects.toMatchObject({
|
|
reason: "cancelled",
|
|
cancellation: { reason },
|
|
cleanupFailure: null,
|
|
});
|
|
await finalizing.promise;
|
|
controller.abort(reason);
|
|
await rejected;
|
|
expect(observeCompleted).not.toHaveBeenCalled();
|
|
expect(fixture.cleanup).toHaveBeenCalledOnce();
|
|
expect(input.destroyed).toBe(true);
|
|
expect(fixture.child.kill).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it.each([undefined, null, new Error("synchronous end failure")])(
|
|
"preserves a synchronous input end rejection as a physical collection failure: %j",
|
|
async (reason) => {
|
|
const input = new Writable();
|
|
vi.spyOn(input, "end").mockImplementation(() => {
|
|
throw reason;
|
|
});
|
|
const fixture = ownedInput(input);
|
|
const failure: unknown = await runOwnedCommand(
|
|
spawn,
|
|
{ timeoutMs: null, diagnosticBytes: 1024, stdin: "closed-pipe" },
|
|
{
|
|
launcher: async () => ({
|
|
process: fixture.child,
|
|
ownership: fixture.ownership,
|
|
cleanup: fixture.cleanup,
|
|
}),
|
|
},
|
|
).catch((cause: unknown) => cause);
|
|
if (!(failure instanceof OwnedCommandFailure)) throw failure;
|
|
expect(failure.reason).toBe("process");
|
|
expect(failure.collectionFailure).toBe(true);
|
|
expect(failure.cause).toBeInstanceOf(OwnedCommandFailure);
|
|
expect(failure.cause).toHaveProperty("cause", reason);
|
|
expect(fixture.cleanup).toHaveBeenCalledOnce();
|
|
},
|
|
);
|
|
|
|
it("keeps an asynchronous input error primary before same-turn cancellation", async () => {
|
|
const controller = new AbortController();
|
|
const inputError = new Error("authored input failure");
|
|
const reason = { request: "abort after physical input error" };
|
|
const input = new Writable({
|
|
final(_callback) {
|
|
setImmediate(() => {
|
|
input.emit("error", inputError);
|
|
controller.abort(reason);
|
|
});
|
|
},
|
|
});
|
|
const fixture = ownedInput(input);
|
|
const failure: unknown = await runOwnedCommand(
|
|
spawn,
|
|
{ timeoutMs: null, diagnosticBytes: 1024, stdin: "closed-pipe" },
|
|
{
|
|
signal: controller.signal,
|
|
launcher: async () => ({
|
|
process: fixture.child,
|
|
ownership: fixture.ownership,
|
|
cleanup: fixture.cleanup,
|
|
}),
|
|
},
|
|
).catch((cause: unknown) => cause);
|
|
if (!(failure instanceof OwnedCommandFailure)) throw failure;
|
|
expect(failure).toMatchObject({
|
|
reason: "process",
|
|
collectionFailure: true,
|
|
processError: inputError,
|
|
});
|
|
expect(failure.cancellation?.reason).toBe(reason);
|
|
expect(failure.cause).toHaveProperty("cause", inputError);
|
|
expect(fixture.cleanup).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("collects an asynchronous Writable final callback error before process cleanup", async () => {
|
|
const inputError = new Error("asynchronous final callback failure");
|
|
const input = new Writable({
|
|
final(callback) {
|
|
setImmediate(() => callback(inputError));
|
|
},
|
|
});
|
|
const fixture = ownedInput(input);
|
|
const failure: unknown = await runOwnedCommand(
|
|
spawn,
|
|
{ timeoutMs: null, diagnosticBytes: 1024, stdin: "closed-pipe" },
|
|
{
|
|
launcher: async () => ({
|
|
process: fixture.child,
|
|
ownership: fixture.ownership,
|
|
cleanup: fixture.cleanup,
|
|
}),
|
|
},
|
|
).catch((cause: unknown) => cause);
|
|
if (!(failure instanceof OwnedCommandFailure)) throw failure;
|
|
expect(failure.reason).toBe("process");
|
|
expect(failure.collectionFailure).toBe(true);
|
|
expect(failure.processError).toBe(inputError);
|
|
expect(failure.cause).toHaveProperty("cause", inputError);
|
|
expect(fixture.cleanup).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("reports a premature input close as a collection failure", async () => {
|
|
const input = new Writable({
|
|
final(_callback) {
|
|
input.destroy();
|
|
},
|
|
});
|
|
const fixture = ownedInput(input);
|
|
await expect(
|
|
runOwnedCommand(
|
|
spawn,
|
|
{ timeoutMs: null, diagnosticBytes: 1024, stdin: "closed-pipe" },
|
|
{
|
|
launcher: async () => ({
|
|
process: fixture.child,
|
|
ownership: fixture.ownership,
|
|
cleanup: fixture.cleanup,
|
|
}),
|
|
},
|
|
),
|
|
).rejects.toMatchObject({
|
|
reason: "process",
|
|
collectionFailure: true,
|
|
message: expect.stringMatching(
|
|
/stdin stream failed.*closed before writable finalization completed/i,
|
|
),
|
|
processError: {
|
|
message: "closed before writable finalization completed",
|
|
},
|
|
});
|
|
expect(fixture.cleanup).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("keeps late input errors observed through refused cleanup and the exact retry owner", async () => {
|
|
const controller = new AbortController();
|
|
const reason = { request: "abort during final cleanup" };
|
|
const inputError = new Error("late input failure");
|
|
const input = new Writable({
|
|
autoDestroy: false,
|
|
final(callback) {
|
|
callback();
|
|
fixture.child.complete();
|
|
},
|
|
});
|
|
const fixture = ownedInput(input);
|
|
let denied = true;
|
|
fixture.cleanup.mockImplementation(async () => {
|
|
if (denied) {
|
|
input.emit("error", inputError);
|
|
controller.abort(reason);
|
|
return { cleaned: false, reason: "authored input owner still held" };
|
|
}
|
|
input.emit("error", new Error("input error during retry"));
|
|
input.destroy();
|
|
return { cleaned: true, signaled: false };
|
|
});
|
|
let failure: OwnedCommandFailure | undefined;
|
|
try {
|
|
const cause: unknown = await runOwnedCommand(
|
|
spawn,
|
|
{ timeoutMs: null, diagnosticBytes: 1024, stdin: "closed-pipe" },
|
|
{
|
|
signal: controller.signal,
|
|
launcher: async () => ({
|
|
process: fixture.child,
|
|
ownership: fixture.ownership,
|
|
cleanup: fixture.cleanup,
|
|
}),
|
|
},
|
|
).catch((cause: unknown) => cause);
|
|
if (!(cause instanceof OwnedCommandFailure)) throw cause;
|
|
failure = cause;
|
|
expect(failure).toMatchObject({
|
|
reason: "process",
|
|
collectionFailure: true,
|
|
processError: inputError,
|
|
cleanupFailure: "authored input owner still held",
|
|
});
|
|
expect(failure.cancellation?.reason).toBe(reason);
|
|
expect(failure.cleanupOwner?.launch.ownership).toBe(fixture.ownership);
|
|
expect(failure.cleanupOwner?.launch.process.stdin).toBe(input);
|
|
expect(failure.cause).toHaveProperty("cause", inputError);
|
|
denied = false;
|
|
await expect(failure.cleanupOwner?.stop()).resolves.toEqual({
|
|
status: "verified-cleanup",
|
|
});
|
|
expect(fixture.cleanup).toHaveBeenCalledTimes(2);
|
|
expect(input.destroyed).toBe(true);
|
|
expect(fixture.child.kill).not.toHaveBeenCalled();
|
|
} finally {
|
|
denied = false;
|
|
await failure?.cleanupOwner?.stop();
|
|
input.destroy();
|
|
}
|
|
});
|
|
|
|
it("keeps an explicitly selected input pipe open until process cleanup", async () => {
|
|
const stages: string[] = [];
|
|
const input = new Writable();
|
|
const ended = vi.spyOn(input, "end");
|
|
const fixture = ownedInput(input);
|
|
const cleanup = fixture.cleanup.getMockImplementation()!;
|
|
fixture.cleanup.mockImplementation(async () => {
|
|
stages.push("cleanup");
|
|
expect(input.writableEnded).toBe(false);
|
|
expect(input.writableFinished).toBe(false);
|
|
expect(input.destroyed).toBe(false);
|
|
return cleanup();
|
|
});
|
|
const snapshot = await runOwnedCommand(
|
|
spawn,
|
|
{
|
|
timeoutMs: 2_000,
|
|
diagnosticBytes: 1024,
|
|
stdin: "pipe",
|
|
observeCompleted: () => stages.push("observed"),
|
|
},
|
|
{
|
|
launcher: async (request) => {
|
|
expect(request.stdin).toBe("pipe");
|
|
setImmediate(() => {
|
|
stages.push("output");
|
|
fixture.child.stdout.write("open input output");
|
|
fixture.child.stderr.write("open input diagnostic");
|
|
fixture.child.complete();
|
|
});
|
|
return {
|
|
process: fixture.child,
|
|
ownership: fixture.ownership,
|
|
cleanup: fixture.cleanup,
|
|
};
|
|
},
|
|
},
|
|
);
|
|
expect(snapshot).toMatchObject({
|
|
exitCode: 0,
|
|
stdout: { text: "open input output" },
|
|
stderr: { text: "open input diagnostic" },
|
|
});
|
|
expect(stages).toEqual(["output", "observed", "cleanup"]);
|
|
expect(ended).not.toHaveBeenCalled();
|
|
expect(fixture.cleanup).toHaveBeenCalledOnce();
|
|
expect(input.destroyed).toBe(true);
|
|
expect(fixture.child.kill).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([undefined, "pipe"] as const)(
|
|
"leaves stdin untouched without the closed-pipe contract (spawn stdin %s)",
|
|
async (stdin) => {
|
|
const input = new Writable();
|
|
const ended = vi.spyOn(input, "end");
|
|
const fixture = ownedInput(input);
|
|
await runOwnedCommand(
|
|
{ ...spawn, ...(stdin === undefined ? {} : { stdin }) },
|
|
{ timeoutMs: null, diagnosticBytes: 1024 },
|
|
{
|
|
launcher: async (request) => {
|
|
expect(request.stdin).toBe(stdin);
|
|
setImmediate(() => fixture.child.complete());
|
|
return {
|
|
process: fixture.child,
|
|
ownership: fixture.ownership,
|
|
cleanup: fixture.cleanup,
|
|
};
|
|
},
|
|
},
|
|
);
|
|
expect(ended).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it("reports a deadline that elapses during final cleanup as a timeout", async () => {
|
|
// Controlled time: collection never races the deadline, which elapses only
|
|
// after cleanup has demonstrably started.
|
|
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "performance"] });
|
|
try {
|
|
const fixture = ownedInput(null);
|
|
const observeCompleted = vi.fn();
|
|
const cleanupStarted = createDeferred<void>();
|
|
const releaseCleanup = createDeferred<void>();
|
|
fixture.cleanup.mockImplementation(async () => {
|
|
cleanupStarted.resolve();
|
|
await releaseCleanup.promise;
|
|
return { cleaned: true, signaled: false };
|
|
});
|
|
const pending = runOwnedCommand(
|
|
spawn,
|
|
{ timeoutMs: 1_000, diagnosticBytes: 1024, observeCompleted },
|
|
{
|
|
launcher: async () => {
|
|
setImmediate(() => {
|
|
fixture.child.stdout.write("completed output");
|
|
fixture.child.complete();
|
|
});
|
|
return {
|
|
process: fixture.child,
|
|
ownership: fixture.ownership,
|
|
cleanup: fixture.cleanup,
|
|
};
|
|
},
|
|
},
|
|
);
|
|
const rejected = expect(pending).rejects.toMatchObject({
|
|
reason: "timeout",
|
|
cleanupFailure: null,
|
|
snapshot: { exitCode: 0, stdout: { text: "completed output" } },
|
|
});
|
|
let started = false;
|
|
void cleanupStarted.promise.then(() => (started = true));
|
|
while (!started) await vi.advanceTimersByTimeAsync(10);
|
|
expect(performance.now()).toBeLessThan(1_000);
|
|
expect(observeCompleted).toHaveBeenCalledOnce();
|
|
await vi.advanceTimersByTimeAsync(1_000);
|
|
releaseCleanup.resolve();
|
|
await rejected;
|
|
expect(fixture.cleanup).toHaveBeenCalledOnce();
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|