Files
rea/src/process/ProviderProcessOutput.test.ts
T
Vinícius Andrade 1e38a038c6 fix(process): release handles and close worker on incomplete cleanup (#2321)
* fix(process): unreference handles on incomplete provider process stop

Signed-off-by: Vinícius DS Andrade <95101505+viniciusdsandrade@users.noreply.github.com>

* fix(javascript): close worker and release handles on incomplete cleanup

Signed-off-by: Vinícius DS Andrade <95101505+viniciusdsandrade@users.noreply.github.com>

* fix(javascript): defer worker stdin close until incomplete cleanup

Signed-off-by: Vinícius DS Andrade <95101505+viniciusdsandrade@users.noreply.github.com>

* test(javascript): assert cleanup error instance on inspection uncertainty

Signed-off-by: Vinícius DS Andrade <95101505+viniciusdsandrade@users.noreply.github.com>

---------

Signed-off-by: Vinícius DS Andrade <95101505+viniciusdsandrade@users.noreply.github.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-13 22:34:54 +08:00

161 lines
4.9 KiB
TypeScript

import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import { expect, it, vi } from "vitest";
import { ProviderProcessSupervisor } from "./ProviderProcess.js";
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("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("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();
}
});