test: retain independent ownership of fake GDB processes

This commit is contained in:
morluto committed 2026-11-10 16:43:39 -05:00
1 parent 0c37c22d51
commit 0ea642843a
5 files changed
+198 -32

No files matched your search

@@ -0,0 +1,85 @@
import { randomUUID } from "node:crypto";
import { access, chmod, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it } from "vitest";
import { OwnedDebuggerCleanupError } from "../../../src/process/ownedDebuggerCleanupError.js";
import { ProviderProcessSupervisor } from "../../../src/process/ProviderProcess.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { waitForProviderProcessReady } from "../../fixtures/providerProcess.js";
import { createGdbFixtureOwner } from "./gdbTestHelpers.js";
it.skipIf(process.platform === "win32")(
"retains an uncertain fake GDB child and files for independent cleanup retry",
async () => {
const root = await createTestTempDirectory("rea-gdb-fixture-owner-");
const provider = join(root, "fake-gdb.mjs");
await writeFile(
provider,
`#!${process.execPath}\nprocess.stdout.write("ready\\n"); process.stdin.resume(); setInterval(() => {}, 1000);\n`,
);
await chmod(provider, 0o700);
let refused = true;
const fixture = createGdbFixtureOwner({
stopOwnedProcess: (supervisor) =>
refused
? Promise.resolve({
status: "incomplete",
reason: "injected physical cleanup refusal",
})
: supervisor.stop(),
});
const launched = await fixture.spawnProcess({
command: provider,
arguments: [],
runId: randomUUID(),
stdin: "pipe",
});
const managers = [
{
closeAll: async () => {
throw new OwnedDebuggerCleanupError(
[new Error("inferior state intentionally unknown")],
"controlled debugger refusal",
[launched.process.pid!],
);
},
},
];
const remove = () => rm(root, { recursive: true, force: true });
try {
await waitForProviderProcessReady(launched.process);
await expect(fixture.cleanup(managers, remove)).rejects.toMatchObject({
errors: [
expect.objectContaining({
message: expect.stringContaining(
"injected physical cleanup refusal",
),
}),
],
});
expect(managers).toHaveLength(1);
expect(launched.process.exitCode).toBeNull();
expect(launched.process.signalCode).toBeNull();
await access(provider);
refused = false;
await fixture.cleanup(managers, remove);
expect(managers).toHaveLength(0);
expect(
launched.process.exitCode !== null ||
launched.process.signalCode !== null,
).toBe(true);
await expect(access(provider)).rejects.toMatchObject({ code: "ENOENT" });
} finally {
refused = false;
// Independent backstop keeps a broken ownership mutation from leaking its child.
const safety = new ProviderProcessSupervisor(
{ ...launched, ownsProcessLifetime: true },
{ captureStdout: false, maxDiagnosticBytes: 0 },
);
expect((await safety.stop()).status).not.toBe("incomplete");
await fixture.cleanup(managers, remove);
}
},
);
@@ -31,9 +31,13 @@ import { DEFAULT_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES } from "../../../src/domain/a
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import { stopOwnedDebuggerProcess } from "../../../src/process/ownedDebuggerSession.js";
import { parseLinuxProcessStat } from "../../../src/process/LinuxProcessStat.js";
import { expectGdbSessionStarted } from "./gdbTestHelpers.js";
import {
createGdbFixtureOwner,
expectGdbSessionStarted,
} from "./gdbTestHelpers.js";
let directory: string | undefined;
const fixtureOwner = createGdbFixtureOwner();
const managers: GdbSessionManager[] = [];
it.each([
@@ -208,7 +212,10 @@ it("rejects an invalid caller TTY and platforms without safe GDB ownership", asy
expect(macUnavailable.error.message).toContain("available only on Linux");
expect(macSpawned).toBe(false);
const windows = new GdbSessionManager({ platform: "win32" });
const windows = new GdbSessionManager({
spawnProcess: fixtureOwner.spawnProcess,
platform: "win32",
});
managers.push(windows);
const unavailable = await windows.start();
expect(unavailable).toMatchObject({
@@ -1625,6 +1632,7 @@ const managerFor = async (
options: ConstructorParameters<typeof GdbSessionManager>[0] = {},
): Promise<GdbSessionManager> => {
const manager = new GdbSessionManager({
spawnProcess: fixtureOwner.spawnProcess,
platform: "linux",
environment: { REA_GDB_COMMAND: await createProvider(body) },
...options,
@@ -1653,23 +1661,21 @@ process.stdin.on('data', (chunk) => {
`;
afterEach(async () => {
await Promise.all(
managers
.splice(0)
.map((manager) => manager.closeAll().catch(() => undefined)),
);
if (directory !== undefined)
await rm(directory, { recursive: true, force: true });
directory = undefined;
await fixtureOwner.cleanup(managers, async () => {
if (directory !== undefined)
await rm(directory, { recursive: true, force: true });
directory = undefined;
});
});
it("rejects a pre-cancelled GDB start and an unconfigured executable", async () => {
const cancelled = new AbortController();
const reason = new Error("caller cancelled before start");
cancelled.abort(reason);
const startResult = await new GdbSessionManager({ platform: "linux" }).start(
cancelled.signal,
);
const startResult = await new GdbSessionManager({
spawnProcess: fixtureOwner.spawnProcess,
platform: "linux",
}).start(cancelled.signal);
expect(startResult.ok).toBe(false);
if (!startResult.ok) {
expect(startResult.error._tag).toBe("AnalysisCancelledError");
@@ -1679,12 +1685,14 @@ it("rejects a pre-cancelled GDB start and an unconfigured executable", async ()
const nullReasonController = new AbortController();
nullReasonController.abort(null);
const nullReasonResult = await new GdbSessionManager({
spawnProcess: fixtureOwner.spawnProcess,
platform: "linux",
}).start(nullReasonController.signal);
expect(nullReasonResult.ok).toBe(false);
if (!nullReasonResult.ok) expect(nullReasonResult.error.cause).toBeNull();
const missing = await new GdbSessionManager({
spawnProcess: fixtureOwner.spawnProcess,
platform: "linux",
environment: { REA_GDB_COMMAND: join(tmpdir(), "does-not-exist-gdb") },
}).start();
@@ -1713,6 +1721,7 @@ if (process.argv.includes('--interpreter=mi3')) process.exit(0);
${responsiveProvider()}
`);
const manager = new GdbSessionManager({
spawnProcess: fixtureOwner.spawnProcess,
platform: "linux",
environment: { REA_GDB_COMMAND: provider },
});
@@ -1729,6 +1738,7 @@ it("retains ownership when GDB startup fails before its prompt and stop is incom
let retainedSessionId: string | undefined;
const provider = await createProvider("process.exit(0);\n");
const manager = new GdbSessionManager({
spawnProcess: fixtureOwner.spawnProcess,
platform: "linux",
environment: { REA_GDB_COMMAND: provider },
stopProcess: async (session, name) => {
@@ -1859,6 +1869,7 @@ it("returns startup timeout when the MI prompt never arrives", async () => {
"process.stdin.resume(); setInterval(() => {}, 1000);\n",
);
const manager = new GdbSessionManager({
spawnProcess: fixtureOwner.spawnProcess,
platform: "linux",
environment: { REA_GDB_COMMAND: provider },
startupTimeoutMs: 20,
@@ -1939,6 +1950,7 @@ it("retains startup timeout without partial evidence when cleanup is uncertain",
"process.stdin.resume(); setInterval(() => {}, 1000);\n",
);
const manager = new GdbSessionManager({
spawnProcess: fixtureOwner.spawnProcess,
platform: "linux",
environment: { REA_GDB_COMMAND: provider },
startupTimeoutMs: 20,
@@ -2022,6 +2034,7 @@ it("handles a start signal that becomes aborted during listener registration", a
},
});
const manager = new GdbSessionManager({
spawnProcess: fixtureOwner.spawnProcess,
platform: "linux",
environment: { REA_GDB_COMMAND: provider },
});
@@ -3167,6 +3180,7 @@ it("retains a session when closing cannot confirm the debugger process stopped",
let stopCalls = 0;
const provider = await createProvider(responsiveProvider());
const manager = new GdbSessionManager({
spawnProcess: fixtureOwner.spawnProcess,
platform: "linux",
environment: { REA_GDB_COMMAND: provider },
stopProcess: async (session, name) => {
@@ -3222,6 +3236,7 @@ it.each([new Error("simulated stop failure"), "simulated stop failure"])(
`),
);
const manager = new GdbSessionManager({
spawnProcess: fixtureOwner.spawnProcess,
platform: "linux",
environment: {
REA_GDB_COMMAND: provider,
@@ -1,3 +1,4 @@
import { createGdbFixtureOwner } from "./gdbTestHelpers.js";
import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -8,17 +9,15 @@ import { GdbSessionManager } from "../../../src/gdb/GdbSessionManager.js";
describe("persistent debugger session managers", () => {
let directory: string | undefined;
const fixtureOwner = createGdbFixtureOwner();
const managers: Array<{ closeAll(): Promise<void> }> = [];
afterEach(async () => {
await Promise.all(
managers
.splice(0)
.map((manager) => manager.closeAll().catch(() => undefined)),
);
if (directory !== undefined)
await rm(directory, { recursive: true, force: true });
directory = undefined;
await fixtureOwner.cleanup(managers, async () => {
if (directory !== undefined)
await rm(directory, { recursive: true, force: true });
directory = undefined;
});
});
it("runs a correlated GDB MI command, reports status, and closes its owned process", async () => {
@@ -47,6 +46,7 @@ process.stdin.on("data", (chunk) => {
);
await chmod(provider, 0o700);
const manager = new GdbSessionManager({
spawnProcess: fixtureOwner.spawnProcess,
platform: "linux",
environment: { REA_GDB_COMMAND: provider },
});
@@ -93,6 +93,7 @@ process.stdin.on("data", (chunk) => {
);
await chmod(provider, 0o700);
const manager = new GdbSessionManager({
spawnProcess: fixtureOwner.spawnProcess,
platform: "linux",
environment: { REA_GDB_COMMAND: provider },
});
@@ -1,3 +1,4 @@
import { createGdbFixtureOwner } from "./gdbTestHelpers.js";
import { readFileSync } from "node:fs";
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
@@ -17,9 +18,14 @@ import { waitForTestPidFile } from "../../support/process/pidFile.js";
const createGdbManagerForInjectedPlatform = (
options: ConstructorParameters<typeof GdbSessionManager>[0] = {},
): GdbSessionManager =>
new GdbSessionManager({ platform: "linux", ...options });
new GdbSessionManager({
spawnProcess: fixtureOwner.spawnProcess,
platform: "linux",
...options,
});
let directory: string | undefined;
const fixtureOwner = createGdbFixtureOwner();
const managers: Array<{ closeAll(): Promise<void> }> = [];
const expectOwnedProcessExited = async (pid: number): Promise<void> => {
@@ -381,7 +387,7 @@ process.stdin.on("data", (chunk) => {
enterSecondSpawn?.();
await secondSpawnGate;
}
return spawnOwnedProviderProcess(options);
return fixtureOwner.spawnProcess(options);
},
});
managers.push(manager);
@@ -487,14 +493,11 @@ process.stdin.resume();
);
afterEach(async () => {
await Promise.all(
managers
.splice(0)
.map((manager) => manager.closeAll().catch(() => undefined)),
);
if (directory !== undefined)
await rm(directory, { recursive: true, force: true });
directory = undefined;
await fixtureOwner.cleanup(managers, async () => {
if (directory !== undefined)
await rm(directory, { recursive: true, force: true });
directory = undefined;
});
});
it.skipIf(process.platform === "win32")(
@@ -988,7 +991,7 @@ setInterval(() => {}, 1000);
const manager = createGdbManagerForInjectedPlatform({
environment: { REA_GDB_COMMAND: provider },
spawnProcess: async (options) => {
ownedLaunch = await spawnOwnedProviderProcess(options);
ownedLaunch = await fixtureOwner.spawnProcess(options);
ownedProcessExit = new Promise<void>((resolve) => {
const child = ownedLaunch!.process;
if (child.exitCode !== null || child.signalCode !== null) resolve();
@@ -1,3 +1,8 @@
import {
ProviderProcessSupervisor,
spawnOwnedProviderProcess,
} from "../../../src/process/ProviderProcess.js";
import { OwnedDebuggerCleanupError } from "../../../src/process/ownedDebuggerCleanupError.js";
import { expect } from "vitest";
import type { GdbSessionManager } from "../../../src/gdb/GdbSessionManager.js";
@@ -14,3 +19,60 @@ export const expectGdbSessionStarted = async (
if (!started.ok) throw new Error(started.error.message);
return started;
};
/** The fixture, unlike GDB, knows these inert provider processes have no real inferior. */
export const createGdbFixtureOwner = (
controls: {
readonly stopOwnedProcess?: (
supervisor: ProviderProcessSupervisor,
) => ReturnType<ProviderProcessSupervisor["stop"]>;
} = {},
) => {
const processes = new Set<ProviderProcessSupervisor>();
const spawnProcess: typeof spawnOwnedProviderProcess = async (options) => {
const launched = await spawnOwnedProviderProcess(options);
processes.add(
new ProviderProcessSupervisor(
{ ...launched, ownsProcessLifetime: true },
{ captureStdout: false, maxDiagnosticBytes: 0 },
),
);
return launched;
};
const cleanup = async (
managers: Array<{ closeAll(): Promise<void> }>,
removeFiles: () => Promise<void>,
): Promise<void> => {
const outcomes = await Promise.allSettled(
managers.map((manager) => manager.closeAll()),
);
// The tests intentionally model unknown inferior state. Only that tagged
// refusal is discharged by the fixture's independent physical process owner.
const failures = outcomes.flatMap((outcome) =>
outcome.status === "rejected" &&
!(outcome.reason instanceof OwnedDebuggerCleanupError)
? [outcome.reason as unknown]
: [],
);
for (const supervisor of processes) {
try {
const result =
controls.stopOwnedProcess === undefined
? await supervisor.stop()
: await controls.stopOwnedProcess(supervisor);
if (result.status === "incomplete")
throw new Error(`Fake GDB cleanup incomplete: ${result.reason}`);
if (supervisor.observedExit() === undefined)
throw new Error("Fake GDB cleanup did not observe child exit");
processes.delete(supervisor);
} catch (cause: unknown) {
failures.push(cause);
}
}
if (failures.length > 0)
throw new AggregateError(failures, "Fake GDB fixture cleanup failed");
managers.length = 0;
await removeFiles();
};
return { spawnProcess, cleanup };
};