mirror of
https://github.com/morluto/rea
synced 2026-10-26 14:25:49 -04:00
dd1c879580
Settle local streams even when native handle release fails, retaining handle ownership and retry semantics until release succeeds. Reviewed-PR: #3230 Co-authored-by: qorvr <330186599+qorvr@users.noreply.github.com>
470 lines
15 KiB
TypeScript
470 lines
15 KiB
TypeScript
import { once } from "node:events";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import type { WindowsNativeAuthority } from "./WindowsNativeLoader.js";
|
|
import {
|
|
quoteWindowsProcessArgument,
|
|
WindowsOwnedProcess,
|
|
} from "./WindowsOwnedProcess.js";
|
|
|
|
describe("Windows process argument representation", () => {
|
|
it.each([
|
|
["", '""'],
|
|
["plain", '"plain"'],
|
|
["two words", '"two words"'],
|
|
['embedded"quote', '"embedded\\"quote"'],
|
|
["D:\\folder\\", '"D:\\folder\\\\"'],
|
|
['slash\\"quote', '"slash\\\\\\"quote"'],
|
|
])("quotes %j without changing its CRT argument", (input, expected) => {
|
|
expect(quoteWindowsProcessArgument(input)).toBe(expected);
|
|
});
|
|
|
|
it("rejects NUL before crossing the native boundary", () => {
|
|
expect(() => quoteWindowsProcessArgument("a\0b")).toThrow("NUL");
|
|
});
|
|
});
|
|
|
|
const LIMIT = 704 * 1024 * 1024;
|
|
const settledPoll = {
|
|
stdout: Buffer.alloc(0),
|
|
stderr: Buffer.from("allocation failed"),
|
|
stdoutEnded: true,
|
|
stderrEnded: true,
|
|
exitCode: 3,
|
|
activeProcesses: 0,
|
|
};
|
|
|
|
/** Native authority double that records calls and replays exact poll observations. */
|
|
const authority = (
|
|
options: {
|
|
readonly jobMemoryLimit?: true;
|
|
readonly polls?: readonly unknown[];
|
|
readonly appliedLimit?: (requested: unknown) => unknown;
|
|
readonly failClose?: () => boolean;
|
|
} = {},
|
|
) => {
|
|
const calls: { operation: string; arguments_: readonly unknown[] }[] = [];
|
|
const polls = [...(options.polls ?? [])];
|
|
const native: WindowsNativeAuthority = {
|
|
inspection: {
|
|
abiVersion: 1,
|
|
nodeApiVersion: 8,
|
|
architecture: "x64",
|
|
filesystem: {
|
|
requestedPath: "C:\\probe",
|
|
finalPath: "C:\\probe",
|
|
filesystem: "NTFS",
|
|
volumeSerial: "0".repeat(16),
|
|
fileId: "0".repeat(32),
|
|
size: 0,
|
|
directory: true,
|
|
},
|
|
privateDacl: true,
|
|
privateBridgeDescriptorAccess: true,
|
|
atomicJobAssignment: true,
|
|
killOnOwnerClose: true,
|
|
protocolStdin: true,
|
|
...(options.jobMemoryLimit === undefined ? {} : { jobMemoryLimit: true }),
|
|
},
|
|
call: (operation, arguments_) => {
|
|
calls.push({ operation, arguments_ });
|
|
if (operation === "process_spawn")
|
|
return {
|
|
pid: 42,
|
|
handle: {},
|
|
jobAssigned: true,
|
|
killOnOwnerClose: true,
|
|
...(arguments_.length === 6
|
|
? {
|
|
jobMemoryLimitBytes: (
|
|
options.appliedLimit ?? ((value) => value)
|
|
)(arguments_[5]),
|
|
}
|
|
: {}),
|
|
};
|
|
if (operation === "process_poll") return polls.shift();
|
|
if (operation === "process_terminate") return true;
|
|
if (operation === "process_close") {
|
|
if (options.failClose?.()) throw new Error("native close failed");
|
|
return null;
|
|
}
|
|
throw new Error(`Unmodeled native operation ${operation}`);
|
|
},
|
|
};
|
|
return { native, calls };
|
|
};
|
|
|
|
const launch = (
|
|
native: WindowsNativeAuthority,
|
|
memoryLimitBytes?: number,
|
|
): WindowsOwnedProcess =>
|
|
new WindowsOwnedProcess(
|
|
"C:\\runtime\\bun.exe",
|
|
["worker.js"],
|
|
"C:\\work",
|
|
{},
|
|
false,
|
|
{
|
|
authority: native,
|
|
...(memoryLimitBytes === undefined ? {} : { memoryLimitBytes }),
|
|
},
|
|
);
|
|
|
|
describe("Windows native release retry", () => {
|
|
it("ends output streams and retains ownership for a failed close retry", async () => {
|
|
vi.useFakeTimers();
|
|
let shouldFailClose = true;
|
|
try {
|
|
const { native, calls } = authority({
|
|
polls: [{ ...settledPoll }, { ...settledPoll }],
|
|
failClose: () => shouldFailClose,
|
|
});
|
|
const child = launch(native);
|
|
const failures: string[] = [];
|
|
child.on("error", (error: Error) => failures.push(error.message));
|
|
const closed = new Promise<void>((resolve) => {
|
|
child.once("close", () => resolve());
|
|
});
|
|
|
|
await vi.advanceTimersByTimeAsync(25);
|
|
expect(child.stdout.writableEnded).toBe(true);
|
|
expect(child.stderr.writableEnded).toBe(true);
|
|
expect(failures).toContain("native close failed");
|
|
expect(calls.some(({ operation }) => operation === "process_close")).toBe(
|
|
true,
|
|
);
|
|
|
|
shouldFailClose = false;
|
|
const cleanup = child.cleanup();
|
|
await vi.advanceTimersByTimeAsync(25);
|
|
expect(await cleanup).toMatchObject({ cleaned: true });
|
|
await closed;
|
|
expect(child.stdout.writableEnded).toBe(true);
|
|
expect(child.stderr.writableEnded).toBe(true);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("Windows Job Object memory limits", () => {
|
|
it("emits close only after descendants leave the Job Object", async () => {
|
|
const { native, calls } = authority({
|
|
polls: [
|
|
{
|
|
...settledPoll,
|
|
stdoutEnded: false,
|
|
stderrEnded: false,
|
|
activeProcesses: 1,
|
|
},
|
|
settledPoll,
|
|
],
|
|
});
|
|
const child = launch(native);
|
|
const events: string[] = [];
|
|
child.once("exit", () => events.push("exit"));
|
|
child.once("close", () => events.push("close"));
|
|
|
|
await once(child, "close");
|
|
|
|
expect(events).toEqual(["exit", "close"]);
|
|
expect(
|
|
calls.filter(({ operation }) => operation === "process_poll"),
|
|
).toHaveLength(2);
|
|
expect(calls.at(-1)?.operation).toBe("process_close");
|
|
});
|
|
|
|
it("refuses a requested limit before launch when the artifact lacks the control", () => {
|
|
const { native, calls } = authority();
|
|
expect(() => launch(native, LIMIT)).toThrow(
|
|
"does not support Job Object memory limits",
|
|
);
|
|
expect(calls).toEqual([]);
|
|
});
|
|
|
|
it("keeps the unlimited launch shape and rejects limit fields it did not request", async () => {
|
|
const { native, calls } = authority({
|
|
jobMemoryLimit: true,
|
|
polls: [
|
|
{ ...settledPoll, memoryLimitReached: true, peakJobMemoryBytes: 1 },
|
|
],
|
|
});
|
|
const child = launch(native);
|
|
const failure = once(child, "error");
|
|
expect(calls[0]?.arguments_).toHaveLength(4);
|
|
const [error] = await failure;
|
|
expect(error).toBeInstanceOf(Error);
|
|
expect(child.memoryLimitReached).toBe(false);
|
|
expect(child.peakJobMemoryBytes).toBeUndefined();
|
|
});
|
|
|
|
it("passes the limit, verifies its read-back and reports a reached limit before exit", async () => {
|
|
const { native, calls } = authority({
|
|
jobMemoryLimit: true,
|
|
polls: [
|
|
{
|
|
...settledPoll,
|
|
exitCode: null,
|
|
stdoutEnded: false,
|
|
stderrEnded: false,
|
|
activeProcesses: 1,
|
|
memoryLimitReached: false,
|
|
peakJobMemoryBytes: 300 * 1024 * 1024,
|
|
},
|
|
{ ...settledPoll, memoryLimitReached: true, peakJobMemoryBytes: LIMIT },
|
|
],
|
|
});
|
|
const child = launch(native, LIMIT);
|
|
expect(calls[0]?.arguments_.slice(4)).toEqual([false, LIMIT]);
|
|
const closed = once(child, "close");
|
|
const exited = new Promise((resolve) => {
|
|
child.once("exit", (code: number) =>
|
|
resolve({ code, reached: child.memoryLimitReached }),
|
|
);
|
|
});
|
|
expect(await exited).toEqual({ code: 3, reached: true });
|
|
expect(child.peakJobMemoryBytes).toBe(LIMIT);
|
|
await closed;
|
|
expect(calls.at(-1)?.operation).toBe("process_close");
|
|
});
|
|
|
|
it("rejects a limited poll that omits the job's notification state", async () => {
|
|
const { native } = authority({
|
|
jobMemoryLimit: true,
|
|
polls: [settledPoll],
|
|
});
|
|
const child = launch(native, LIMIT);
|
|
const [error] = await once(child, "error");
|
|
expect(error).toBeInstanceOf(Error);
|
|
expect(child.memoryLimitReached).toBe(false);
|
|
});
|
|
|
|
it("refuses a launch whose applied limit differs from the request", () => {
|
|
const { native } = authority({
|
|
jobMemoryLimit: true,
|
|
appliedLimit: () => LIMIT - 4096,
|
|
});
|
|
expect(() => launch(native, LIMIT)).toThrow();
|
|
});
|
|
});
|
|
|
|
describe("Windows output capture drain", () => {
|
|
it("repolls without the 25ms idle delay while the child keeps producing output", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const chunk = Buffer.alloc(8192, 0x61);
|
|
const polls: Array<{
|
|
stdout: Buffer;
|
|
stderr: Buffer;
|
|
stdoutEnded: boolean;
|
|
stderrEnded: boolean;
|
|
exitCode: number | null;
|
|
activeProcesses: number;
|
|
}> = Array.from({ length: 10 }, () => ({
|
|
stdout: chunk,
|
|
stderr: Buffer.alloc(0),
|
|
stdoutEnded: false,
|
|
stderrEnded: false,
|
|
exitCode: null,
|
|
activeProcesses: 1,
|
|
}));
|
|
polls.push({ ...settledPoll });
|
|
const { native, calls } = authority({ polls });
|
|
const child = launch(native);
|
|
child.stdout.resume();
|
|
const closed = once(child, "close");
|
|
// 100ms of simulated time: the fixed 25ms poll cadence can complete
|
|
// at most ~4 polls, so all 10 data polls plus the settle poll must
|
|
// still be pending. Draining repolls immediately while data flows.
|
|
await vi.advanceTimersByTimeAsync(100);
|
|
expect(
|
|
calls.filter((call) => call.operation === "process_poll"),
|
|
).toHaveLength(11);
|
|
await closed;
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("keeps the 25ms idle delay after a poll with no output", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const idle = {
|
|
stdout: Buffer.alloc(0),
|
|
stderr: Buffer.alloc(0),
|
|
stdoutEnded: false,
|
|
stderrEnded: false,
|
|
exitCode: null,
|
|
activeProcesses: 1,
|
|
};
|
|
const { native, calls } = authority({
|
|
polls: [idle, { ...settledPoll }],
|
|
});
|
|
const child = launch(native);
|
|
const closed = once(child, "close");
|
|
const pollCount = () =>
|
|
calls.filter((call) => call.operation === "process_poll").length;
|
|
// The constructor schedules the first poll 25ms out.
|
|
await vi.advanceTimersByTimeAsync(25);
|
|
expect(pollCount()).toBe(1);
|
|
// An idle child must not be repolled early: 10ms of simulated time
|
|
// must not reach the next poll, the remaining 15ms must.
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
expect(pollCount()).toBe(1);
|
|
await vi.advanceTimersByTimeAsync(15);
|
|
expect(pollCount()).toBe(2);
|
|
await closed;
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("repolls without the 25ms idle delay while only stderr carries output", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const chunk = Buffer.alloc(4096, 0x62);
|
|
const { native, calls } = authority({
|
|
polls: [
|
|
{
|
|
stdout: Buffer.alloc(0),
|
|
stderr: chunk,
|
|
stdoutEnded: false,
|
|
stderrEnded: false,
|
|
exitCode: null,
|
|
activeProcesses: 1,
|
|
},
|
|
{ ...settledPoll },
|
|
],
|
|
});
|
|
const child = launch(native);
|
|
const closed = once(child, "close");
|
|
const pollCount = () =>
|
|
calls.filter((call) => call.operation === "process_poll").length;
|
|
await vi.advanceTimersByTimeAsync(25);
|
|
expect(pollCount()).toBe(1);
|
|
// stderr-only output still means the child is producing: the next
|
|
// poll must be immediate, not 25ms out.
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
expect(pollCount()).toBe(2);
|
|
await closed;
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("Windows output backpressure and cleanup", () => {
|
|
it("stops native reads until both paused output consumers drain", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const chunk = Buffer.alloc(65536, 0x63);
|
|
const { native, calls } = authority({
|
|
polls: [
|
|
{
|
|
stdout: chunk,
|
|
stderr: chunk,
|
|
stdoutEnded: false,
|
|
stderrEnded: false,
|
|
exitCode: null,
|
|
activeProcesses: 1,
|
|
},
|
|
{ ...settledPoll, stderr: Buffer.alloc(0) },
|
|
],
|
|
});
|
|
const child = launch(native);
|
|
const closed = once(child, "close");
|
|
child.stdout.pause();
|
|
child.stderr.pause();
|
|
const pollCount = () =>
|
|
calls.filter((call) => call.operation === "process_poll").length;
|
|
await vi.advanceTimersByTimeAsync(25);
|
|
expect(child.stdout.writableNeedDrain).toBe(true);
|
|
expect(child.stderr.writableNeedDrain).toBe(true);
|
|
await vi.advanceTimersByTimeAsync(5000);
|
|
expect(pollCount()).toBe(1);
|
|
const stdoutDrained = once(child.stdout, "drain");
|
|
child.stdout.resume();
|
|
await stdoutDrained;
|
|
await vi.advanceTimersByTimeAsync(25);
|
|
expect(pollCount()).toBe(1);
|
|
const stderrDrained = once(child.stderr, "drain");
|
|
child.stderr.resume();
|
|
await stderrDrained;
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
await closed;
|
|
expect(pollCount()).toBe(2);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("discards blocked output during cleanup so the owned job can settle", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const chunk = Buffer.alloc(65536, 0x64);
|
|
const { native, calls } = authority({
|
|
polls: [
|
|
{
|
|
stdout: chunk,
|
|
stderr: Buffer.alloc(0),
|
|
stdoutEnded: false,
|
|
stderrEnded: false,
|
|
exitCode: null,
|
|
activeProcesses: 1,
|
|
},
|
|
{ ...settledPoll, stderr: Buffer.alloc(0) },
|
|
],
|
|
});
|
|
const child = launch(native);
|
|
child.stdout.pause();
|
|
await vi.advanceTimersByTimeAsync(25);
|
|
expect(child.stdout.writableNeedDrain).toBe(true);
|
|
const closed = once(child, "close");
|
|
const cleanup = child.cleanup();
|
|
await vi.advanceTimersByTimeAsync(25);
|
|
expect(await cleanup).toEqual({ cleaned: true, signaled: true });
|
|
await closed;
|
|
expect(
|
|
calls.filter((call) => call.operation === "process_poll"),
|
|
).toHaveLength(2);
|
|
expect(child.stdout.destroyed).toBe(true);
|
|
expect(child.outputIncomplete).toBe(true);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("resumes polling when a blocked output stream is destroyed", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const chunk = Buffer.alloc(65536, 0x65);
|
|
const { native, calls } = authority({
|
|
polls: [
|
|
{
|
|
stdout: chunk,
|
|
stderr: Buffer.alloc(0),
|
|
stdoutEnded: false,
|
|
stderrEnded: false,
|
|
exitCode: null,
|
|
activeProcesses: 1,
|
|
},
|
|
{ ...settledPoll, stderr: Buffer.alloc(0) },
|
|
],
|
|
});
|
|
const child = launch(native);
|
|
const closed = once(child, "close");
|
|
await vi.advanceTimersByTimeAsync(25);
|
|
expect(child.stdout.writableNeedDrain).toBe(true);
|
|
child.stdout.destroy();
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
await closed;
|
|
expect(
|
|
calls.filter((call) => call.operation === "process_poll"),
|
|
).toHaveLength(2);
|
|
expect(child.outputIncomplete).toBe(true);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
});
|