rea/tests/boundary/process/ownedDebuggerSession.test.ts

1453 lines
46 KiB
TypeScript

import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { once } from "node:events";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { connect } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawn as spawnPty } from "@lydell/node-pty";
import { expect, it, onTestFinished, vi } from "vitest";
import {
closeOwnedDebuggerSessions,
stopOwnedDebuggerProcess,
verifyOwnedInferiorsEnded,
} from "../../../src/process/ownedDebuggerSession.js";
import { ProviderProcessSupervisor } from "../../../src/process/ProviderProcess.js";
import { cleanupOwnedProcessGroup } from "../../../src/process/ProcessOwnership.js";
const triggerDescendantOutput = (port: number): void => {
const socket = connect(port, "127.0.0.1");
socket.on("error", () => {});
socket.on("connect", () => socket.end());
};
const createFakeSupervisor = () => ({
waitForExit: async () => true,
waitForOutputClose: async () => true,
limitAdditionalOutputBytes: vi.fn(),
dispose: vi.fn(),
unrefOutputStreams: vi.fn(),
});
const startStopProbe = (source: string, pipeInput = false) => {
const runId = randomUUID();
const child = spawn(process.execPath, ["--input-type=module", "-e", source], {
detached: process.platform !== "win32",
env: { ...process.env, REA_PROCESS_RUN_ID: runId },
stdio: [pipeInput ? "pipe" : "ignore", "pipe", "pipe"],
});
const leaderPid = child.pid;
if (leaderPid === undefined) throw new Error("Fixture probe did not spawn");
onTestFinished(async () => {
const cleanup = await cleanupOwnedProcessGroup({
runId,
leaderPid,
processGroupId: leaderPid,
});
expect(cleanup.cleaned, JSON.stringify(cleanup)).toBe(true);
});
let output = "";
const collect = (chunk: Buffer | string): void => {
output += chunk.toString();
};
child.stdout?.on("data", collect);
child.stderr?.on("data", collect);
return {
child,
get output() {
return output;
},
};
};
const waitForProbeLine = (
child: ReturnType<typeof spawn>,
initialOutput: string,
pattern: RegExp,
): Promise<string> =>
new Promise((resolve, reject) => {
let output = initialOutput;
const match = (): string | undefined => pattern.exec(output)?.[0];
const existing = match();
if (existing !== undefined) {
resolve(existing);
return;
}
const timeout = setTimeout(
() =>
reject(new Error("probe did not report the expected cleanup state")),
3_000,
);
child.stdout?.on("data", (chunk: Buffer | string) => {
output += chunk.toString();
const found = match();
if (found !== undefined) {
clearTimeout(timeout);
resolve(found);
}
});
child.once("error", (cause) => {
clearTimeout(timeout);
reject(cause);
});
});
const startDebuggerWithInferior = (inferiorSource: string) => {
const debuggerSource = [
'const { spawn } = require("node:child_process");',
`spawn(process.execPath, ["-e", ${JSON.stringify(inferiorSource)}], { stdio: "inherit" });`,
"process.exit(0);",
].join("\n");
const child = spawn(process.execPath, ["-e", debuggerSource], {
stdio: ["ignore", "pipe", "pipe"],
});
const supervisor = new ProviderProcessSupervisor({
process: child,
ownsProcessLifetime: true,
});
let readyOutput = "";
const inferiorPort = new Promise<number>((resolvePort) => {
child.stdout?.on("data", (chunk: Buffer | string) => {
readyOutput += chunk.toString();
const match = /^ready:(\d+)$/mu.exec(readyOutput);
if (match !== null) resolvePort(Number(match[1]));
});
});
return { child, supervisor, inferiorPort };
};
it("preserves the session receiver when invoking the provider detach callback", async () => {
const session = {
id: "receiver-bound-detach",
exited: false,
transport: { verified: true },
launched: { process: { kill: vi.fn(() => true) } },
async detachInferior(this: { transport: { verified: boolean } }) {
expect(this.transport.verified).toBe(true);
return { status: "no-inferior" } as const;
},
supervisor: createFakeSupervisor(),
};
await expect(
stopOwnedDebuggerProcess(session, "GDB", { platform: "linux" }),
).resolves.toEqual({ status: "stopped" });
expect(session.supervisor.limitAdditionalOutputBytes).toHaveBeenCalledWith(
64 * 1024,
);
});
it("clears the detach timeout when provider detachment settles immediately", async () => {
vi.useFakeTimers();
try {
const session = {
id: "immediate-detach",
exited: false,
launched: { process: { kill: vi.fn(() => true) } },
detachInferior: async () => ({ status: "no-inferior" }) as const,
supervisor: createFakeSupervisor(),
};
await expect(
stopOwnedDebuggerProcess(session, "GDB", { platform: "linux" }),
).resolves.toEqual({ status: "stopped" });
expect(vi.getTimerCount()).toBe(0);
} finally {
vi.useRealTimers();
}
});
it("retries provider detachment after a rejected attempt", async () => {
const detachInferior = vi
.fn<() => Promise<{ readonly status: "no-inferior" }>>()
.mockRejectedValueOnce(new Error("detach transport failed"))
.mockResolvedValueOnce({ status: "no-inferior" });
const session = {
id: "retry-rejected-detach",
exited: false,
launched: { process: { kill: vi.fn(() => true) } },
detachInferior,
supervisor: createFakeSupervisor(),
};
await expect(
stopOwnedDebuggerProcess(session, "GDB", { platform: "linux" }),
).resolves.toMatchObject({ status: "incomplete" });
await expect(
stopOwnedDebuggerProcess(session, "GDB", { platform: "linux" }),
).resolves.toEqual({ status: "stopped" });
expect(detachInferior).toHaveBeenCalledTimes(2);
});
it("retains ownership after stop until inherited output writers close", async () => {
let exited = false;
let outputClosed = false;
const supervisor = {
...createFakeSupervisor(),
waitForOutputClose: async () => outputClosed,
};
const session = {
id: "stopped-with-open-output",
get exited() {
return exited;
},
launched: {
process: {
kill: vi.fn(() => {
exited = true;
return true;
}),
},
},
detachInferior: async () => ({ status: "no-inferior" }) as const,
supervisor,
};
await expect(
stopOwnedDebuggerProcess(session, "GDB", { platform: "linux" }),
).resolves.toMatchObject({ status: "incomplete", reason: /output streams/i });
expect(supervisor.unrefOutputStreams).not.toHaveBeenCalled();
expect(supervisor.dispose).not.toHaveBeenCalled();
outputClosed = true;
await expect(
stopOwnedDebuggerProcess(session, "GDB", { platform: "linux" }),
).resolves.toEqual({ status: "already_exited" });
expect(supervisor.unrefOutputStreams).toHaveBeenCalledOnce();
expect(supervisor.dispose).toHaveBeenCalledOnce();
});
it("retains process supervision after an incomplete stop and disposes after a retry observes exit", async () => {
let waits = 0;
let disposals = 0;
let outputUnrefs = 0;
const signals: NodeJS.Signals[] = [];
const session = {
id: "debugger-session",
exited: false,
launched: {
process: {
kill: (signal: NodeJS.Signals): boolean => {
signals.push(signal);
return true;
},
},
},
detachInferior: async () =>
({
status: "detached",
outputSink: "durable",
}) as const,
supervisor: {
waitForExit: async (): Promise<boolean> => {
waits += 1;
return waits === 3;
},
limitAdditionalOutputBytes: vi.fn(),
waitForOutputClose: async (): Promise<boolean> => true,
dispose: (): void => {
disposals += 1;
},
unrefOutputStreams: (): void => {
outputUnrefs += 1;
},
},
};
const first = await stopOwnedDebuggerProcess(session, "GDB", {
platform: "linux",
});
expect(first).toEqual({
status: "incomplete",
reason: "Owned GDB process did not exit after direct termination.",
});
expect(disposals).toBe(0);
expect(outputUnrefs).toBe(0);
expect(signals).toEqual(["SIGTERM", "SIGKILL"]);
const retry = await stopOwnedDebuggerProcess(session, "GDB", {
platform: "linux",
});
expect(retry).toEqual({ status: "stopped" });
expect(disposals).toBe(1);
expect(waits).toBe(3);
});
it("refuses Windows job-backed debugger termination before signaling", async () => {
const signals: NodeJS.Signals[] = [];
let outputUnrefs = 0;
const session = {
id: "windows-debugger",
exited: false,
launched: {
process: {
kill: (signal: NodeJS.Signals): boolean => {
signals.push(signal);
return true;
},
},
},
supervisor: {
waitForExit: async (): Promise<boolean> => false,
limitAdditionalOutputBytes: vi.fn(),
waitForOutputClose: async (): Promise<boolean> => false,
dispose: (): void => {},
unrefOutputStreams: (): void => {
outputUnrefs += 1;
},
},
};
await expect(
stopOwnedDebuggerProcess(session, "GDB", { platform: "win32" }),
).resolves.toEqual({
status: "incomplete",
reason:
"Owned GDB process cannot be stopped safely on Windows because job termination can also terminate its inferior processes.",
});
expect(signals).toEqual([]);
expect(outputUnrefs).toBe(0);
});
it("retains an already-exited Windows session while its job has descendants", async () => {
let outputUnrefs = 0;
let disposals = 0;
const result = await stopOwnedDebuggerProcess(
{
id: "windows-already-exited",
exited: true,
launched: { process: { kill: () => true } },
supervisor: {
waitForExit: async () => false,
limitAdditionalOutputBytes: vi.fn(),
waitForOutputClose: async () => false,
dispose: () => {
disposals += 1;
},
unrefOutputStreams: () => {
outputUnrefs += 1;
},
},
},
"GDB",
{ platform: "win32" },
);
expect(result).toEqual({
status: "incomplete",
reason:
"Owned GDB process leader exited, but its Windows Job Object still has active descendants or has not reported settlement.",
});
expect(disposals).toBe(0);
expect(outputUnrefs).toBe(0);
});
it("retains an exited POSIX session while descendants still hold its output streams", async () => {
let outputUnrefs = 0;
let disposals = 0;
const result = await stopOwnedDebuggerProcess(
{
id: "posix-already-exited-with-descendant",
exited: true,
launched: { process: { kill: () => true } },
supervisor: {
waitForExit: async () => true,
limitAdditionalOutputBytes: vi.fn(),
waitForOutputClose: async () => false,
dispose: () => {
disposals += 1;
},
unrefOutputStreams: () => {
outputUnrefs += 1;
},
},
},
"GDB",
{ platform: "linux" },
);
expect(result).toEqual({
status: "incomplete",
reason:
"Owned GDB process leader exited, but inherited output streams are still open or have not reported settlement.",
});
expect(disposals).toBe(0);
expect(outputUnrefs).toBe(0);
});
it.each([
[
"POSIX Error",
"linux",
new Error("pipe state unknown"),
/inherited output stream settlement could not be verified: pipe state unknown/,
],
[
"POSIX non-Error",
"linux",
"pipe state unknown",
/inherited output stream settlement could not be verified: pipe state unknown/,
],
[
"Windows Error",
"win32",
new Error("job state unknown"),
/Windows Job Object settlement could not be verified: job state unknown/,
],
[
"Windows non-Error",
"win32",
"job state unknown",
/Windows Job Object settlement could not be verified: job state unknown/,
],
] as const)(
"retains an exited debugger when output settlement throws (%s)",
async (_label, platform, cause, expectedReason) => {
let disposals = 0;
const result = await stopOwnedDebuggerProcess(
{
id: "output-settlement-error",
exited: true,
launched: { process: { kill: () => true } },
supervisor: {
waitForExit: async () => true,
waitForOutputClose: async () => Promise.reject(cause),
limitAdditionalOutputBytes: () => {},
dispose: () => {
disposals += 1;
},
unrefOutputStreams: () => {},
},
},
"GDB",
{ platform },
);
expect(result).toMatchObject({
status: "incomplete",
reason: expectedReason,
});
expect(disposals).toBe(0);
},
);
it("does not signal an active debugger when its provider cannot detach the inferior", async () => {
const signals: NodeJS.Signals[] = [];
const result = await stopOwnedDebuggerProcess(
{
id: "missing-detach-capability",
exited: false,
launched: { process: { kill: (signal) => (signals.push(signal), true) } },
supervisor: {
waitForExit: async () => false,
waitForOutputClose: async () => false,
limitAdditionalOutputBytes: () => {},
dispose: () => {},
unrefOutputStreams: () => {},
},
},
"GDB",
{ platform: "linux" },
);
expect(result).toEqual({
status: "incomplete",
reason:
"Owned GDB inferior detach cannot be verified because the provider has no detach capability.",
});
expect(signals).toEqual([]);
});
it("preserves a non-Error provider detach rejection and does not signal the debugger", async () => {
const signals: NodeJS.Signals[] = [];
const result = await stopOwnedDebuggerProcess(
{
id: "non-error-detach-failure",
exited: false,
launched: { process: { kill: (signal) => (signals.push(signal), true) } },
detachInferior: async () => Promise.reject("detach state unknown"),
supervisor: {
waitForExit: async () => false,
waitForOutputClose: async () => false,
limitAdditionalOutputBytes: () => {},
dispose: () => {},
unrefOutputStreams: () => {},
},
},
"GDB",
{ platform: "linux" },
);
expect(result).toEqual({
status: "incomplete",
reason:
"Owned GDB inferior detach could not be verified: detach state unknown",
});
expect(signals).toEqual([]);
});
it("does not signal a POSIX debugger when provider detach cannot be verified", async () => {
const signals: NodeJS.Signals[] = [];
const result = await stopOwnedDebuggerProcess(
{
id: "unverified-detach",
exited: false,
launched: { process: { kill: (signal) => (signals.push(signal), true) } },
detachInferior: async () => {
throw new Error("MI detach failed");
},
supervisor: {
waitForExit: async () => false,
limitAdditionalOutputBytes: vi.fn(),
waitForOutputClose: async () => false,
dispose: () => {},
unrefOutputStreams: () => {},
},
},
"GDB",
{ platform: "linux" },
);
expect(result).toEqual({
status: "incomplete",
reason: "Owned GDB inferior detach could not be verified: MI detach failed",
});
expect(signals).toEqual([]);
});
it("retains an exited debugger after provider detach failed", async () => {
let exited = false;
let disposals = 0;
const session = {
id: "detach-failed-then-exited",
get exited() {
return exited;
},
launched: { process: { kill: () => true } },
detachInferior: async () => {
throw new Error("MI detach failed");
},
supervisor: {
waitForExit: async () => true,
limitAdditionalOutputBytes: vi.fn(),
waitForOutputClose: async () => true,
dispose: () => {
disposals += 1;
},
unrefOutputStreams: () => {},
},
};
const sessions = new Map([[session.id, session]]);
expect(
await stopOwnedDebuggerProcess(session, "GDB", { platform: "linux" }),
).toEqual({
status: "incomplete",
reason: "Owned GDB inferior detach could not be verified: MI detach failed",
});
exited = true;
await expect(
closeOwnedDebuggerSessions(
sessions,
(owned) => stopOwnedDebuggerProcess(owned, "GDB", { platform: "linux" }),
"GDB",
),
).rejects.toThrow("One or more owned GDB processes did not stop cleanly");
expect(sessions.get(session.id)).toBe(session);
expect(disposals).toBe(0);
});
it("recovers failed detach only after the identified inferior ends and output settles", async () => {
let exited = false;
let inferiorEnded = false;
const supervisor = {
...createFakeSupervisor(),
waitForOutputClose: vi.fn(async () => true),
};
const session = {
id: "failed-detach-recovery",
get exited() {
return exited;
},
launched: { process: { kill: vi.fn(() => true) } },
detachInferior: async () => {
throw new Error("detach failed");
},
verifyInferiorsEndedAfterExit: async () => inferiorEnded,
supervisor,
};
const sessions = new Map([[session.id, session]]);
const stop = (owned: typeof session) =>
stopOwnedDebuggerProcess(owned, "GDB", { platform: "linux" });
expect(await stop(session)).toMatchObject({ status: "incomplete" });
exited = true;
expect(await stop(session)).toMatchObject({ status: "incomplete" });
expect(supervisor.waitForOutputClose).not.toHaveBeenCalled();
expect(session.launched.process.kill).not.toHaveBeenCalled();
inferiorEnded = true;
await closeOwnedDebuggerSessions(sessions, stop, "GDB");
expect(supervisor.waitForOutputClose).toHaveBeenCalledOnce();
expect(supervisor.dispose).toHaveBeenCalledOnce();
expect(sessions.size).toBe(0);
});
it.each([
{ observed: "100", ended: false },
{ observed: "200", ended: true },
{ observed: null, ended: true },
])(
"compares captured inferior identity with $observed",
async ({ observed, ended }) => {
expect(
await verifyOwnedInferiorsEnded(
[{ pid: "123", startTime: "100" }],
async () => observed,
),
).toBe(ended);
},
);
it("preserves unknown identity reads and rejects empty proof", async () => {
const read = vi.fn(async () => {
throw new Error("identity unreadable");
});
await expect(
verifyOwnedInferiorsEnded([{ pid: "123", startTime: "100" }], read),
).rejects.toThrow("identity unreadable");
read.mockClear();
expect(await verifyOwnedInferiorsEnded([], read)).toBe(false);
expect(read).not.toHaveBeenCalled();
});
it("allows closing when the provider confirms no inferior is attached", async () => {
const signals: NodeJS.Signals[] = [];
const result = await stopOwnedDebuggerProcess(
{
id: "no-inferior",
exited: false,
launched: { process: { kill: (signal) => (signals.push(signal), true) } },
detachInferior: async () => ({ status: "no-inferior" }),
supervisor: {
waitForExit: async () => true,
limitAdditionalOutputBytes: vi.fn(),
waitForOutputClose: async () => true,
dispose: () => {},
unrefOutputStreams: () => {},
},
},
"GDB",
{ platform: "linux" },
);
expect(result).toEqual({ status: "stopped" });
expect(signals).toEqual(["SIGTERM"]);
});
it("fails closed when detach lacks durable output sink confirmation", async () => {
const signals: NodeJS.Signals[] = [];
const result = await stopOwnedDebuggerProcess(
{
id: "non-durable-sink",
exited: false,
launched: { process: { kill: (signal) => (signals.push(signal), true) } },
detachInferior: async () => ({ status: "detached" }) as never,
supervisor: {
waitForExit: async () => true,
limitAdditionalOutputBytes: vi.fn(),
waitForOutputClose: async () => false,
dispose: () => {},
unrefOutputStreams: () => {},
},
},
"GDB",
{ platform: "linux" },
);
expect(result).toEqual({
status: "incomplete",
reason:
"Owned GDB provider did not confirm no inferior or detachment with a durable output sink.",
});
expect(signals).toEqual([]);
});
it("retains an owned debugger when inferior detach does not settle", async () => {
vi.useFakeTimers();
const signals: NodeJS.Signals[] = [];
let detachCalls = 0;
let resolveDetach!: (value: {
readonly status: "detached";
readonly outputSink: "durable";
}) => void;
let disposals = 0;
try {
const session = {
id: "detach-timeout",
exited: false,
launched: {
process: {
kill: (signal: NodeJS.Signals) => (signals.push(signal), true),
},
},
detachInferior: () => {
detachCalls += 1;
if (detachCalls > 1)
return Promise.resolve({
status: "detached",
outputSink: "durable",
} as const);
return new Promise<{
readonly status: "detached";
readonly outputSink: "durable";
}>((resolve) => {
resolveDetach = resolve;
});
},
supervisor: {
waitForExit: async () => true,
limitAdditionalOutputBytes: vi.fn(),
waitForOutputClose: async () => true,
dispose: () => {
disposals += 1;
},
unrefOutputStreams: () => {},
},
};
const attempt = stopOwnedDebuggerProcess(session, "GDB", {
platform: "linux",
});
await vi.advanceTimersByTimeAsync(1_000);
expect(await attempt).toEqual({
status: "incomplete",
reason: "Owned GDB inferior detach was not verified within 1000 ms.",
});
const retry = stopOwnedDebuggerProcess(session, "GDB", {
platform: "linux",
});
await vi.advanceTimersByTimeAsync(1_000);
expect(await retry).toEqual({
status: "incomplete",
reason: "Owned GDB inferior detach was not verified within 1000 ms.",
});
expect(detachCalls).toBe(1);
expect(signals).toEqual([]);
expect(disposals).toBe(0);
resolveDetach({ status: "detached", outputSink: "durable" });
await vi.advanceTimersByTimeAsync(0);
expect(
await stopOwnedDebuggerProcess(session, "GDB", { platform: "linux" }),
).toEqual({ status: "stopped" });
expect(detachCalls).toBe(2);
expect(signals).toEqual(["SIGTERM"]);
expect(disposals).toBe(1);
} finally {
vi.useRealTimers();
}
});
it("retains an exited session while a timed-out detach attempt is still pending", async () => {
vi.useFakeTimers();
let exited = false;
let resolveDetach!: (value: { readonly status: "no-inferior" }) => void;
let detachCalls = 0;
const session = {
id: "exited-with-pending-detach",
get exited() {
return exited;
},
launched: { process: { kill: () => true } },
detachInferior: () => {
detachCalls += 1;
return new Promise<{ readonly status: "no-inferior" }>((resolve) => {
resolveDetach = resolve;
});
},
supervisor: {
waitForExit: async () => true,
limitAdditionalOutputBytes: vi.fn(),
waitForOutputClose: async () => true,
dispose: vi.fn(),
unrefOutputStreams: vi.fn(),
},
};
const sessions = new Map([[session.id, session]]);
try {
const attempt = stopOwnedDebuggerProcess(session, "GDB", {
platform: "linux",
});
await vi.advanceTimersByTimeAsync(1_000);
expect(await attempt).toMatchObject({ status: "incomplete" });
exited = true;
await expect(
closeOwnedDebuggerSessions(
sessions,
(owned) =>
stopOwnedDebuggerProcess(owned, "GDB", { platform: "linux" }),
"GDB",
),
).rejects.toThrow("One or more owned GDB processes did not stop cleanly");
expect(sessions.get(session.id)).toBe(session);
expect(session.supervisor.dispose).not.toHaveBeenCalled();
expect(detachCalls).toBe(1);
resolveDetach({ status: "no-inferior" });
await vi.advanceTimersByTimeAsync(0);
await Promise.resolve();
await expect(
closeOwnedDebuggerSessions(
sessions,
(owned) =>
stopOwnedDebuggerProcess(owned, "GDB", { platform: "linux" }),
"GDB",
),
).resolves.toBeUndefined();
expect(sessions.size).toBe(0);
expect(session.supervisor.dispose).toHaveBeenCalledTimes(1);
expect(detachCalls).toBe(1);
} finally {
vi.useRealTimers();
}
});
it("requires the Windows Job Object to close before releasing an exited session", async () => {
let closeWaits = 0;
let disposals = 0;
const session = {
id: "windows-job-settlement",
exited: true,
launched: { process: { kill: () => true } },
supervisor: {
waitForExit: async () => true,
limitAdditionalOutputBytes: vi.fn(),
waitForOutputClose: async () => ++closeWaits > 1,
dispose: () => {
disposals += 1;
},
unrefOutputStreams: () => {},
},
};
expect(
await stopOwnedDebuggerProcess(session, "GDB", { platform: "win32" }),
).toMatchObject({ status: "incomplete" });
expect(disposals).toBe(0);
expect(
await stopOwnedDebuggerProcess(session, "GDB", { platform: "win32" }),
).toEqual({ status: "already_exited" });
expect(closeWaits).toBe(2);
expect(disposals).toBe(1);
});
it("disposes supervision when a process had already exited", async () => {
let disposals = 0;
const lifecycle: string[] = [];
let outputUnrefs = 0;
const result = await stopOwnedDebuggerProcess(
{
id: "already-exited",
exited: true,
launched: { process: { kill: () => true } },
supervisor: {
waitForExit: async () => false,
limitAdditionalOutputBytes: vi.fn(),
waitForOutputClose: async () => true,
dispose: () => {
lifecycle.push("dispose");
disposals += 1;
},
unrefOutputStreams: () => {
lifecycle.push("unref");
outputUnrefs += 1;
},
},
},
"GDB",
{ platform: "linux" },
);
expect(result).toEqual({ status: "already_exited" });
expect(disposals).toBe(1);
expect(outputUnrefs).toBe(1);
expect(lifecycle).toEqual(["unref", "dispose"]);
});
it("uses the host platform when an exited debugger is stopped without options", async () => {
let outputCloseWaits = 0;
let outputUnrefs = 0;
const result = await stopOwnedDebuggerProcess(
{
id: "already-exited-default-platform",
exited: true,
launched: { process: { kill: () => true } },
supervisor: {
waitForExit: async () => true,
waitForOutputClose: async (timeoutMs) => {
expect(timeoutMs).toBe(1_000);
outputCloseWaits += 1;
return true;
},
limitAdditionalOutputBytes: () => {},
dispose: () => {},
unrefOutputStreams: () => {
outputUnrefs += 1;
},
},
},
"GDB",
);
expect(result).toEqual({ status: "already_exited" });
expect(outputCloseWaits).toBe(1);
expect(outputUnrefs).toBe(process.platform === "win32" ? 0 : 1);
});
it.each([
["linux", "inherited output stream"],
["win32", "Windows Job Object"],
] as const)(
"retains a non-Error output-settlement failure on %s",
async (platform, resource) => {
const result = await stopOwnedDebuggerProcess(
{
id: `output-settlement-${platform}`,
exited: true,
launched: { process: { kill: () => true } },
supervisor: {
waitForExit: async () => true,
waitForOutputClose: async () => {
throw "output status unavailable";
},
limitAdditionalOutputBytes: () => {},
dispose: () => {},
unrefOutputStreams: () => {},
},
},
"GDB",
{ platform },
);
expect(result).toEqual({
status: "incomplete",
reason: `Owned GDB ${resource} settlement could not be verified: output status unavailable`,
});
},
);
it.skipIf(process.platform === "win32")(
"keeps supervision until a late-writing descendant closes inherited output after leader exit",
async () => {
const inferiorSource = [
'const { createServer } = require("node:net");',
"const server = createServer((socket) => {",
' process.stdout.write("late-stdout-sentinel\\n");',
' process.stderr.write("late-stderr-sentinel\\n");',
" socket.end();",
" setImmediate(() => process.exit(0));",
"});",
'server.listen(0, "127.0.0.1", () => process.stdout.write(`ready:${server.address().port}\\n`));',
].join("\n");
const { child, supervisor, inferiorPort } =
startDebuggerWithInferior(inferiorSource);
let port: number | undefined;
try {
await once(child, "exit");
const readyPort = await inferiorPort;
port = readyPort;
const limitAdditionalOutputBytes =
supervisor.limitAdditionalOutputBytes.bind(supervisor);
vi.spyOn(supervisor, "limitAdditionalOutputBytes").mockImplementation(
(maxBytes) => {
limitAdditionalOutputBytes(maxBytes);
triggerDescendantOutput(readyPort);
},
);
const result = await stopOwnedDebuggerProcess(
{
id: "leader-exited-with-inferior",
exited: true,
launched: { process: child },
supervisor,
},
"GDB",
{ platform: "linux" },
);
expect(result).toEqual({ status: "already_exited" });
expect(supervisor.snapshot().stdout.text).toContain(
"late-stdout-sentinel",
);
expect(supervisor.snapshot().stderr.text).toContain(
"late-stderr-sentinel",
);
} finally {
if (port !== undefined) triggerDescendantOutput(port);
supervisor.dispose();
}
},
);
it.skipIf(process.platform === "win32")(
"keeps late debugger output bounded and drained across cleanup retries",
async () => {
const inferiorSource = [
'const { createServer } = require("node:net");',
"const writePair = (stdout, stderr) => Promise.all([",
" new Promise((resolve) => process.stdout.write(stdout, resolve)),",
" new Promise((resolve) => process.stderr.write(stderr, resolve)),",
"]);",
"let outputCount = 0;",
"const server = createServer((socket) => {",
" outputCount += 1;",
' const output = outputCount === 1 ? writePair("a".repeat(40 * 1024), "b".repeat(40 * 1024)) : writePair("c".repeat(40 * 1024), "d".repeat(40 * 1024));',
" socket.end();",
" if (outputCount === 2) void output.then(() => process.exit(0));",
"});",
'server.listen(0, "127.0.0.1", () => process.stdout.write(`ready:${server.address().port}\\n`));',
].join("\n");
const { child, supervisor, inferiorPort } =
startDebuggerWithInferior(inferiorSource);
let port: number | undefined;
const session = {
id: "leader-exited-retrying-inferior",
exited: true,
launched: { process: child },
supervisor,
};
try {
await once(child, "exit");
const readyPort = await inferiorPort;
port = readyPort;
const initialStdoutBytes = supervisor.snapshot().stdout.observedBytes;
const limitAdditionalOutputBytes =
supervisor.limitAdditionalOutputBytes.bind(supervisor);
vi.spyOn(supervisor, "limitAdditionalOutputBytes").mockImplementation(
(maxBytes) => {
limitAdditionalOutputBytes(maxBytes);
triggerDescendantOutput(readyPort);
},
);
const first = await stopOwnedDebuggerProcess(session, "GDB", {
platform: "linux",
});
expect(first.status).toBe("incomplete");
expect(supervisor.snapshot().stdout.observedBytes).toBe(
initialStdoutBytes + 40 * 1024,
);
expect(supervisor.snapshot().stderr.observedBytes).toBe(40 * 1024);
const second = await stopOwnedDebuggerProcess(session, "GDB", {
platform: "linux",
});
expect(second).toEqual({ status: "already_exited" });
const snapshot = supervisor.snapshot();
expect(snapshot.stdout.observedBytes).toBe(
initialStdoutBytes + 80 * 1024,
);
expect(snapshot.stderr.observedBytes).toBe(80 * 1024);
expect(snapshot.stdout.bytes + snapshot.stderr.bytes).toBe(
initialStdoutBytes + 64 * 1024,
);
expect(snapshot.diagnosticTruncated).toBe(true);
} finally {
if (port !== undefined) {
triggerDescendantOutput(port);
triggerDescendantOutput(port);
}
if (child.exitCode === null && child.signalCode === null)
child.kill("SIGKILL");
supervisor.dispose();
}
},
);
it("does not escalate when the debugger exits after SIGTERM", async () => {
const signals: NodeJS.Signals[] = [];
const lifecycle: string[] = [];
let disposals = 0;
const result = await stopOwnedDebuggerProcess(
{
id: "graceful-exit",
exited: false,
launched: {
process: {
kill: (signal: NodeJS.Signals): boolean => {
signals.push(signal);
return true;
},
},
},
detachInferior: async () =>
({
status: "detached",
outputSink: "durable",
}) as const,
supervisor: {
waitForExit: async () => true,
limitAdditionalOutputBytes: vi.fn(),
waitForOutputClose: async () => true,
dispose: () => {
lifecycle.push("dispose");
disposals += 1;
},
unrefOutputStreams: () => lifecycle.push("unref"),
},
},
"Rizin",
{ platform: "linux" },
);
expect(result).toEqual({ status: "stopped" });
expect(signals).toEqual(["SIGTERM"]);
expect(disposals).toBe(1);
expect(lifecycle).toEqual(["unref", "dispose"]);
});
it("disposes supervision when the debugger exits after SIGKILL", async () => {
let waits = 0;
let disposals = 0;
const lifecycle: string[] = [];
const signals: NodeJS.Signals[] = [];
const result = await stopOwnedDebuggerProcess(
{
id: "forced-exit",
exited: false,
launched: {
process: {
kill: (signal: NodeJS.Signals): boolean => {
signals.push(signal);
return true;
},
},
},
detachInferior: async () =>
({
status: "detached",
outputSink: "durable",
}) as const,
supervisor: {
waitForExit: async () => {
waits += 1;
return waits === 2;
},
limitAdditionalOutputBytes: vi.fn(),
waitForOutputClose: async () => true,
dispose: () => {
lifecycle.push("dispose");
disposals += 1;
},
unrefOutputStreams: () => lifecycle.push("unref"),
},
},
"Rizin",
{ platform: "linux" },
);
expect(result).toEqual({ status: "stopped" });
expect(signals).toEqual(["SIGTERM", "SIGKILL"]);
expect(disposals).toBe(1);
expect(lifecycle).toEqual(["unref", "dispose"]);
});
it.skipIf(process.platform === "win32")(
"retains the debugger owner while an unknown descendant holds output pipes open",
async () => {
const releaseDirectory = await mkdtemp(
join(tmpdir(), "rea-debugger-output-writer-"),
);
const releaseFile = join(releaseDirectory, "release-writer");
const writerSource = `const{watch}=require("node:fs");const watcher=watch(${JSON.stringify(releaseDirectory)},(_event,name)=>{if(name==="release-writer"){watcher.close();process.exit(0)}});process.stdout.write("writer-ready\\n");`;
const providerProcessUrl = new URL(
"../../../dist/process/ProviderProcess.js",
import.meta.url,
).href;
const debuggerSessionUrl = new URL(
"../../../dist/process/ownedDebuggerSession.js",
import.meta.url,
).href;
const debuggerSource = [
'const { spawn } = require("node:child_process");',
`spawn(process.execPath, ["-e", ${JSON.stringify(writerSource)}], { stdio: "inherit" });`,
"process.stdout.write(`debugger=${process.pid}\\n`);",
"setInterval(() => {}, 1000);",
].join("\n");
const probeSource = [
'import { spawn } from "node:child_process";',
`import { ProviderProcessSupervisor } from ${JSON.stringify(providerProcessUrl)};`,
`import { stopOwnedDebuggerProcess } from ${JSON.stringify(debuggerSessionUrl)};`,
`const child = spawn(process.execPath, ["-e", ${JSON.stringify(debuggerSource)}], { stdio: ["ignore", "pipe", "pipe"] });`,
"const supervisor = new ProviderProcessSupervisor({ process: child, ownsProcessLifetime: true });",
"let output = '';",
"await new Promise((resolve, reject) => {",
" const timer = setTimeout(() => reject(new Error('output writer did not become ready')), 2000);",
" child.stdout.on('data', (chunk) => {",
" output += chunk.toString();",
" const debuggerMatch = /^debugger=(\\d+)/mu.exec(output);",
" if (debuggerMatch) process.stderr.write(`debugger-pid=${debuggerMatch[1]}\\n`);",
" if (output.includes('writer-ready\\n')) { clearTimeout(timer); resolve(); }",
" });",
" child.once('error', reject);",
"});",
"const session = { id: 'debugger', get exited() { return child.exitCode !== null || child.signalCode !== null; }, launched: { process: child }, supervisor };",
"session.detachInferior = async () => ({ status: 'no-inferior' });",
"const result = await stopOwnedDebuggerProcess(session, 'GDB', { platform: 'linux' });",
"process.stdout.write(JSON.stringify({ result }) + '\\n');",
"await new Promise((resolve) => process.stdin.once('data', () => { process.stdin.destroy(); resolve(); }));",
"const retried = await stopOwnedDebuggerProcess(session, 'GDB', { platform: 'linux' });",
"process.stdout.write(JSON.stringify({ retried }) + '\\n');",
].join("\n");
const stopProbe = startStopProbe(probeSource, true);
const probe = stopProbe.child;
const probeExit = once(probe, "exit");
try {
const incompleteLine = await waitForProbeLine(
probe,
stopProbe.output,
/\{"result":\{"status":"incomplete"/u,
);
expect(incompleteLine).toContain('"status":"incomplete"');
expect(probe.exitCode).toBeNull();
expect(probe.signalCode).toBeNull();
expect(stopProbe.output).toContain('"status":"incomplete"');
await writeFile(releaseFile, "release");
probe.stdin?.write("retry\n");
const settledLine = await waitForProbeLine(
probe,
stopProbe.output,
/\{"retried":\{"status":"already_exited"\}\}/u,
);
expect(settledLine).toContain('"status":"already_exited"');
await probeExit;
} finally {
await writeFile(releaseFile, "release").catch(() => undefined);
probe.stdin?.write("retry\n");
if (probe.exitCode === null && probe.signalCode === null)
await Promise.race([
probeExit,
new Promise<void>((resolve) => setTimeout(resolve, 3_000)),
]);
await rm(releaseDirectory, { recursive: true, force: true });
}
},
);
it.skipIf(process.platform === "win32")(
"preserves inferior output written to its selected PTY after the REA probe exits",
async () => {
const inferiorSource = [
"process.stdout.write(`inferior-ready=${process.pid}\\n`);",
'process.stdin.setEncoding("utf8");',
'process.stdin.on("data", (input) => { if (input.includes("write-after-probe")) { process.stdout.write("stdout-after-probe-sentinel\\n"); process.stderr.write("stderr-after-probe-sentinel\\n"); } });',
"setTimeout(() => process.exit(0), 5000);",
].join("\n");
const terminal = spawnPty(process.execPath, ["-e", inferiorSource], {
name: "xterm",
cols: 100,
rows: 20,
});
const terminalExit = new Promise<void>((resolve) => {
terminal.onExit(() => resolve());
});
let terminalOutput = "";
terminal.onData((data) => {
terminalOutput += data;
});
let inferiorPid: number | undefined;
const ready = new Promise<number>((resolve, reject) => {
let readySubscription: { dispose: () => void } | undefined;
const timeout = setTimeout(() => {
readySubscription?.dispose();
reject(new Error("PTY inferior did not start"));
}, 2_000);
const checkReady = (): void => {
const match = /inferior-ready=(\d+)/u.exec(terminalOutput);
if (match?.[1] !== undefined) {
clearTimeout(timeout);
resolve(Number(match[1]));
}
};
readySubscription = terminal.onData(() => {
checkReady();
if (/inferior-ready=\d+/u.test(terminalOutput))
readySubscription?.dispose();
});
});
const providerProcessUrl = new URL(
"../../../dist/process/ProviderProcess.js",
import.meta.url,
).href;
const debuggerSessionUrl = new URL(
"../../../dist/process/ownedDebuggerSession.js",
import.meta.url,
).href;
const probeSource = [
'import { spawn } from "node:child_process";',
`import { ProviderProcessSupervisor } from ${JSON.stringify(providerProcessUrl)};`,
`import { stopOwnedDebuggerProcess } from ${JSON.stringify(debuggerSessionUrl)};`,
'const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: ["ignore", "pipe", "pipe"] });',
"process.stdout.write(`debugger-pid=${child.pid}\\n`);",
"const supervisor = new ProviderProcessSupervisor({ process: child, ownsProcessLifetime: true });",
"const session = { id: 'debugger-with-selected-pty-inferior', get exited() { return child.exitCode !== null || child.signalCode !== null; }, launched: { process: child }, supervisor, detachInferior: async () => ({ status: 'detached', outputSink: 'durable' }) };",
"const result = await stopOwnedDebuggerProcess(session, 'GDB', { platform: 'linux' });",
"process.stdout.write(`probe-result=${JSON.stringify(result)}\\n`);",
].join("\n");
const stopProbe = startStopProbe(probeSource);
const probe = stopProbe.child;
const probeExit = once(probe, "exit");
try {
inferiorPid = await ready;
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error("REA probe did not exit")),
3_000,
);
void probeExit.then(() => {
clearTimeout(timeout);
resolve();
}, reject);
});
expect(stopProbe.output).toContain('probe-result={"status":"stopped"}');
expect(() => process.kill(inferiorPid!, 0)).not.toThrow();
terminal.write("write-after-probe\n");
await new Promise<void>((resolve, reject) => {
let subscription: { dispose: () => void } | undefined;
const timeout = setTimeout(() => {
subscription?.dispose();
reject(
new Error(
"PTY inferior sentinels were not observed after probe exit",
),
);
}, 2_000);
const finish = (): void => {
subscription?.dispose();
clearTimeout(timeout);
resolve();
};
const checkOutput = (): void => {
if (
terminalOutput.includes("stdout-after-probe-sentinel") &&
terminalOutput.includes("stderr-after-probe-sentinel")
)
finish();
};
subscription = terminal.onData(checkOutput);
checkOutput();
});
expect(terminalOutput).toContain("stdout-after-probe-sentinel");
expect(terminalOutput).toContain("stderr-after-probe-sentinel");
expect(() => process.kill(inferiorPid!, 0)).not.toThrow();
} finally {
terminal.kill("SIGKILL");
await Promise.race([
terminalExit,
new Promise<void>((resolve) => setTimeout(resolve, 1_000)),
]);
}
},
);
it("removes stopped sessions and retains incomplete sessions when closing all", async () => {
const sessions = new Map([
["stopped", { id: "stopped" }],
["incomplete", { id: "incomplete" }],
]);
await expect(
closeOwnedDebuggerSessions(
sessions,
async (session) =>
session.id === "incomplete"
? { status: "incomplete", reason: "process remains alive" }
: { status: "stopped" },
"GDB",
),
).rejects.toMatchObject({
errors: [expect.objectContaining({ message: "process remains alive" })],
});
expect([...sessions.keys()]).toEqual(["incomplete"]);
});
it("does not remove a replacement session installed under the same key while stopping", async () => {
const original = { id: "shared-key", generation: "old" };
const replacement = { id: "shared-key", generation: "new" };
const sessions = new Map([["shared-key", original]]);
let finishStop!: (result: { readonly status: "stopped" }) => void;
const stopping = new Promise<{ readonly status: "stopped" }>((resolve) => {
finishStop = resolve;
});
const closing = closeOwnedDebuggerSessions(
sessions,
async () => stopping,
"GDB",
);
await Promise.resolve();
sessions.set("shared-key", replacement);
finishStop({ status: "stopped" });
await closing;
expect(sessions.get("shared-key")).toBe(replacement);
});
it("closes an empty owned-session collection", async () => {
const sessions = new Map<string, { id: string }>();
await expect(
closeOwnedDebuggerSessions(
sessions,
async () => ({ status: "stopped" }),
"GDB",
),
).resolves.toBeUndefined();
});
it("settles every stop and retains rejected sessions while removing successful ones", async () => {
const sessions = new Map([
["stopped", { id: "stopped" }],
["rejected", { id: "rejected" }],
["string-rejected", { id: "string-rejected" }],
["incomplete", { id: "incomplete" }],
]);
const calls: string[] = [];
await expect(
closeOwnedDebuggerSessions(
sessions,
async (session) => {
calls.push(session.id);
if (session.id === "rejected") throw new Error("signal failed");
if (session.id === "string-rejected") throw "string rejection";
if (session.id === "incomplete")
return { status: "incomplete", reason: "still alive" };
return { status: "stopped" };
},
"GDB",
),
).rejects.toMatchObject({
message: "One or more owned GDB processes did not stop cleanly",
errors: [
expect.objectContaining({ message: "signal failed" }),
expect.objectContaining({ message: "string rejection" }),
expect.objectContaining({ message: "still alive" }),
],
});
expect(calls).toHaveLength(4);
expect([...sessions.keys()]).toEqual([
"rejected",
"string-rejected",
"incomplete",
]);
});