From 0ea642843a4497e4eccb6ea4253e5ed044a6eb85 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:42:59 -0500 Subject: [PATCH] test: retain independent ownership of fake GDB processes --- .../providers/GdbFixtureOwnership.test.ts | 85 +++++++++++++++++++ .../GdbSessionManagerCoverage.test.ts | 41 ++++++--- .../providers/GdbSessionManagers.test.ts | 17 ++-- .../GdbSessionReviewRegressions.test.ts | 25 +++--- tests/boundary/providers/gdbTestHelpers.ts | 62 ++++++++++++++ 5 files changed, 198 insertions(+), 32 deletions(-) create mode 100644 tests/boundary/providers/GdbFixtureOwnership.test.ts diff --git a/tests/boundary/providers/GdbFixtureOwnership.test.ts b/tests/boundary/providers/GdbFixtureOwnership.test.ts new file mode 100644 index 0000000000..5a785e6dfc --- /dev/null +++ b/tests/boundary/providers/GdbFixtureOwnership.test.ts @@ -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); + } + }, +); diff --git a/tests/boundary/providers/GdbSessionManagerCoverage.test.ts b/tests/boundary/providers/GdbSessionManagerCoverage.test.ts index 9afbee3b27..72a289f624 100644 --- a/tests/boundary/providers/GdbSessionManagerCoverage.test.ts +++ b/tests/boundary/providers/GdbSessionManagerCoverage.test.ts @@ -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[0] = {}, ): Promise => { 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, diff --git a/tests/boundary/providers/GdbSessionManagers.test.ts b/tests/boundary/providers/GdbSessionManagers.test.ts index c70c4c4a2e..bcb0d58b44 100644 --- a/tests/boundary/providers/GdbSessionManagers.test.ts +++ b/tests/boundary/providers/GdbSessionManagers.test.ts @@ -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 }> = []; 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 }, }); diff --git a/tests/boundary/providers/GdbSessionReviewRegressions.test.ts b/tests/boundary/providers/GdbSessionReviewRegressions.test.ts index 7709350d97..84ea5e682a 100644 --- a/tests/boundary/providers/GdbSessionReviewRegressions.test.ts +++ b/tests/boundary/providers/GdbSessionReviewRegressions.test.ts @@ -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[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 }> = []; const expectOwnedProcessExited = async (pid: number): Promise => { @@ -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((resolve) => { const child = ownedLaunch!.process; if (child.exitCode !== null || child.signalCode !== null) resolve(); diff --git a/tests/boundary/providers/gdbTestHelpers.ts b/tests/boundary/providers/gdbTestHelpers.ts index 7597cba500..2be4758556 100644 --- a/tests/boundary/providers/gdbTestHelpers.ts +++ b/tests/boundary/providers/gdbTestHelpers.ts @@ -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; + } = {}, +) => { + const processes = new Set(); + 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 }>, + removeFiles: () => Promise, + ): Promise => { + 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 }; +};