mirror of
https://github.com/morluto/rea.git
synced 2026-11-11 01:13:39 +00:00
Require delivered facts, exact cancellation and malformed-input boundaries, and independent metadata expectations. Transfer unique assertions into stronger keepers before removing duplicate cases and overwritten fixture writes; preserve all consumed core fixture bytes. Validation: 1,190 focused tests passed with 39 platform-dependent skips; 134 composition and conformance tests passed. Typecheck, lint, formatting, discovery and Knip passed. Independent preservation reviews and targeted regression controls passed.
1539 lines
49 KiB
TypeScript
1539 lines
49 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 {
|
|
OwnedDebuggerCleanupError,
|
|
retainedDebuggerProcessIds,
|
|
retainedDebuggerRemediation,
|
|
} from "../../../src/process/ownedDebuggerCleanupError.js";
|
|
import {
|
|
closeOwnedDebuggerSessions,
|
|
type OwnedDebuggerStopResult,
|
|
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(),
|
|
ref: vi.fn(),
|
|
unref: vi.fn(),
|
|
});
|
|
|
|
/** A session reduced to what closing all sessions reads from it. */
|
|
const closableSession = (id: string, pid?: number) => ({
|
|
id,
|
|
launched: { process: { pid } },
|
|
supervisor: { ref: vi.fn(), unref: 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 detachInferior = vi
|
|
.fn<
|
|
() => Promise<{
|
|
readonly status: "detached";
|
|
readonly outputSink: "durable";
|
|
}>
|
|
>()
|
|
.mockResolvedValueOnce({ status: "detached", outputSink: "durable" })
|
|
.mockRejectedValue(
|
|
new Error("debugger transport closed during termination"),
|
|
);
|
|
const session = {
|
|
id: "debugger-session",
|
|
exited: false,
|
|
launched: {
|
|
process: {
|
|
kill: (signal: NodeJS.Signals): boolean => {
|
|
signals.push(signal);
|
|
return true;
|
|
},
|
|
},
|
|
},
|
|
detachInferior,
|
|
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);
|
|
expect(detachInferior).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
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: expect.stringMatching(
|
|
/Windows.*job termination.*inferior processes/i,
|
|
),
|
|
});
|
|
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: expect.stringMatching(
|
|
/leader exited.*Windows Job Object.*active descendants.*settlement/i,
|
|
),
|
|
});
|
|
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: expect.stringMatching(
|
|
/leader exited.*output streams.*open.*settlement/i,
|
|
),
|
|
});
|
|
expect(disposals).toBe(0);
|
|
expect(outputUnrefs).toBe(0);
|
|
});
|
|
|
|
it.each([
|
|
[
|
|
"POSIX Error",
|
|
"linux",
|
|
new Error("pipe state unknown"),
|
|
"Owned GDB inherited output stream settlement could not be verified: pipe state unknown",
|
|
],
|
|
[
|
|
"POSIX non-Error",
|
|
"linux",
|
|
"pipe state unknown",
|
|
"Owned GDB inherited output stream settlement could not be verified: pipe state unknown",
|
|
],
|
|
[
|
|
"Windows Error",
|
|
"win32",
|
|
new Error("job state unknown"),
|
|
"Owned GDB Windows Job Object settlement could not be verified: job state unknown",
|
|
],
|
|
[
|
|
"Windows non-Error",
|
|
"win32",
|
|
"job state unknown",
|
|
"Owned GDB 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).toEqual({
|
|
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: expect.stringMatching(
|
|
/inferior detach.*cannot be verified.*no detach capability/i,
|
|
),
|
|
});
|
|
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: expect.stringMatching(
|
|
/inferior detach.*could not be verified.*detach state unknown/i,
|
|
),
|
|
});
|
|
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: { pid: 101, kill: () => true } },
|
|
detachInferior: async () => {
|
|
throw new Error("MI detach failed");
|
|
},
|
|
supervisor: {
|
|
waitForExit: async () => true,
|
|
limitAdditionalOutputBytes: vi.fn(),
|
|
waitForOutputClose: async () => true,
|
|
dispose: () => {
|
|
disposals += 1;
|
|
},
|
|
unrefOutputStreams: () => {},
|
|
ref: () => {},
|
|
unref: () => {},
|
|
},
|
|
};
|
|
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: { pid: 101, 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: expect.stringMatching(
|
|
/did not confirm no inferior or detachment.*durable output sink/i,
|
|
),
|
|
});
|
|
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;
|
|
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: expect.stringMatching(
|
|
/inferior detach.*not verified.*1000 ms.*no runtime override/i,
|
|
),
|
|
});
|
|
const retry = stopOwnedDebuggerProcess(session, "GDB", {
|
|
platform: "linux",
|
|
});
|
|
await vi.advanceTimersByTimeAsync(1_000);
|
|
expect(await retry).toEqual({
|
|
status: "incomplete",
|
|
reason: expect.stringMatching(
|
|
/inferior detach.*not verified.*1000 ms.*no runtime override/i,
|
|
),
|
|
});
|
|
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(1);
|
|
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: { pid: 101, 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(),
|
|
ref: vi.fn(),
|
|
unref: 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.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", closableSession("stopped")],
|
|
["incomplete", closableSession("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 = closableSession("shared-key");
|
|
const replacement = closableSession("shared-key");
|
|
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, ReturnType<typeof closableSession>>();
|
|
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", closableSession("stopped")],
|
|
["rejected", closableSession("rejected")],
|
|
["string-rejected", closableSession("string-rejected")],
|
|
["incomplete", closableSession("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",
|
|
]);
|
|
});
|
|
|
|
it("stops retained sessions holding the host and names them, leaving stopped peers alone", async () => {
|
|
const stopped = closableSession("stopped", 101);
|
|
const rejected = closableSession("rejected", 102);
|
|
const incomplete = closableSession("incomplete", 103);
|
|
const unidentified = closableSession("unidentified");
|
|
const sessions = new Map(
|
|
[stopped, rejected, incomplete, unidentified].map((session) => [
|
|
session.id,
|
|
session,
|
|
]),
|
|
);
|
|
|
|
const failure: unknown = await closeOwnedDebuggerSessions(
|
|
sessions,
|
|
async (session) => {
|
|
if (session === stopped) return { status: "stopped" };
|
|
if (session === rejected) throw new Error("signal failed");
|
|
return { status: "incomplete", reason: "still alive" };
|
|
},
|
|
"Rizin",
|
|
).catch((cause: unknown) => cause);
|
|
|
|
expect(failure).toBeInstanceOf(OwnedDebuggerCleanupError);
|
|
expect(failure).toBeInstanceOf(AggregateError);
|
|
expect((failure as OwnedDebuggerCleanupError).retainedProcessIds).toEqual([
|
|
102, 103,
|
|
]);
|
|
expect(stopped.supervisor.unref).not.toHaveBeenCalled();
|
|
for (const retained of [rejected, incomplete, unidentified])
|
|
expect(retained.supervisor.unref).toHaveBeenCalledOnce();
|
|
expect([...sessions.keys()]).toEqual([
|
|
"rejected",
|
|
"incomplete",
|
|
"unidentified",
|
|
]);
|
|
});
|
|
|
|
it("holds the host again while retrying a retained session and leaves it held on success", async () => {
|
|
const session = closableSession("retained", 101);
|
|
const sessions = new Map([[session.id, session]]);
|
|
const order: string[] = [];
|
|
session.supervisor.ref.mockImplementation(() => order.push("ref"));
|
|
session.supervisor.unref.mockImplementation(() => order.push("unref"));
|
|
let stopResult: OwnedDebuggerStopResult = {
|
|
status: "incomplete",
|
|
reason: "still alive",
|
|
};
|
|
const stop = async () => {
|
|
order.push("stop");
|
|
return stopResult;
|
|
};
|
|
|
|
await expect(
|
|
closeOwnedDebuggerSessions(sessions, stop, "GDB"),
|
|
).rejects.toBeInstanceOf(OwnedDebuggerCleanupError);
|
|
expect(order).toEqual(["ref", "stop", "unref"]);
|
|
|
|
order.length = 0;
|
|
stopResult = { status: "stopped" };
|
|
await closeOwnedDebuggerSessions(sessions, stop, "GDB");
|
|
expect(order).toEqual(["ref", "stop"]);
|
|
expect(sessions.size).toBe(0);
|
|
});
|
|
|
|
it("finds retained debugger IDs through composed failures without looping on cycles", () => {
|
|
const first = new OwnedDebuggerCleanupError([], "first", [101, 102]);
|
|
const second = new OwnedDebuggerCleanupError([], "second", [102, 103]);
|
|
const cyclic = new Error("cyclic", { cause: second });
|
|
Object.assign(second, { cause: cyclic });
|
|
const composed = new AggregateError(
|
|
[first, new Error("wrapped", { cause: cyclic }), new Error("peer"), null],
|
|
"outer",
|
|
);
|
|
|
|
expect(retainedDebuggerProcessIds(composed)).toEqual([101, 102, 103]);
|
|
expect(retainedDebuggerProcessIds(new Error("peer"))).toEqual([]);
|
|
expect(retainedDebuggerProcessIds(undefined)).toEqual([]);
|
|
});
|
|
|
|
it("names every retained debugger in its remediation", () => {
|
|
const remediation = retainedDebuggerRemediation([4242, 4243]);
|
|
expect(remediation).toMatch(/no later REA command can retry this cleanup/iu);
|
|
expect(remediation).toMatch(/\b4242\b/u);
|
|
expect(remediation).toMatch(/\b4243\b/u);
|
|
});
|