diff --git a/tests/boundary/providers/RizinDebugSessionCoverage.fixtures.ts b/tests/boundary/providers/RizinDebugSessionCoverage.fixtures.ts index 093ab8c68..4d11c6050 100644 --- a/tests/boundary/providers/RizinDebugSessionCoverage.fixtures.ts +++ b/tests/boundary/providers/RizinDebugSessionCoverage.fixtures.ts @@ -10,6 +10,10 @@ import { rizinTestInferiorTty, } from "./RizinTestInferiorTty.js"; import { stopOwnedDebuggerProcess } from "../../../src/process/ownedDebuggerSession.js"; +import { + ProviderProcessSupervisor, + spawnOwnedProviderProcess, +} from "../../../src/process/ProviderProcess.js"; import type { ProviderProcessHandle, SpawnedOwnedProviderProcess, @@ -24,10 +28,11 @@ interface InjectedProcessControl { exit(signal?: NodeJS.Signals): void; } +type RizinTestManagerOptions = NonNullable< + ConstructorParameters[1] +>; type InjectedSpawnProcess = NonNullable< - NonNullable< - ConstructorParameters[1] - >["spawnProcess"] + RizinTestManagerOptions["spawnProcess"] >; export const incompleteStop: typeof stopOwnedDebuggerProcess = async ( @@ -176,17 +181,47 @@ export const createInjectedProcess = ( return { spawnProcess, control }; }; -export const createRizinCoverageHarness = () => { - let directory: string | undefined; +export const createRizinCoverageHarness = ( + controls: { + readonly stopOwnedProcess?: ( + supervisor: ProviderProcessSupervisor, + ) => ReturnType; + } = {}, +) => { + let latestDirectory: string | undefined; + const directories = new Set(); const managers: RizinDebugSessionManager[] = []; - const track = (manager: RizinDebugSessionManager): void => { + const ownedProcesses = new Set(); + const spawnOwnedProcess: InjectedSpawnProcess = async (options) => { + const launched = await spawnOwnedProviderProcess(options); + // The fixture owns these fake children independently of the manager's + // intentionally uncertain debugger/inferior lifecycle. + ownedProcesses.add( + new ProviderProcessSupervisor( + { ...launched, ownsProcessLifetime: true }, + { captureStdout: false, maxDiagnosticBytes: 0 }, + ), + ); + return launched; + }; + const createManager: typeof createRizinTestManager = ( + environment = process.env, + options = {}, + ) => { + const manager = createRizinTestManager(environment, { + ...options, + spawnProcess: options.spawnProcess ?? spawnOwnedProcess, + }); managers.push(manager); + return manager; }; const makeProvider = async ( body: string, shebang = "#!/usr/bin/env node", ): Promise => { - directory = await mkdtemp(join(tmpdir(), "rea-rizin-coverage-")); + const directory = await mkdtemp(join(tmpdir(), "rea-rizin-coverage-")); + directories.add(directory); + latestDirectory = directory; const provider = join(directory, "fake-rizin.mjs"); await writeFile(provider, `${shebang}\n${body}\n`); await chmod(provider, 0o700); @@ -194,13 +229,9 @@ export const createRizinCoverageHarness = () => { }; const start = async ( provider: string, - options: ConstructorParameters[1] = {}, + options: RizinTestManagerOptions = {}, ): Promise<{ manager: RizinDebugSessionManager; sessionId: string }> => { - const manager = createRizinTestManager( - { REA_RIZIN_COMMAND: provider }, - options, - ); - track(manager); + const manager = createManager({ REA_RIZIN_COMMAND: provider }, options); const result = await manager.start({ path: "/tmp/coverage-target", inferior_tty: await rizinTestInferiorTty(), @@ -210,13 +241,12 @@ export const createRizinCoverageHarness = () => { }; const startInjected = async ( spawnProcess: InjectedSpawnProcess, - options: ConstructorParameters[1] = {}, + options: RizinTestManagerOptions = {}, ) => { - const manager = createRizinTestManager( + const manager = createManager( { REA_RIZIN_COMMAND: "injected-rizin" }, { ...options, spawnProcess }, ); - track(manager); const started = await manager.start({ path: "/tmp/injected", inferior_tty: await rizinTestInferiorTty(), @@ -229,17 +259,42 @@ export const createRizinCoverageHarness = () => { .splice(0) .map((manager) => manager.closeAll().catch(() => undefined)), ); - if (directory !== undefined) + const failures: Error[] = []; + for (const supervisor of ownedProcesses) { + const stopped = + controls.stopOwnedProcess === undefined + ? await supervisor.stop() + : await controls.stopOwnedProcess(supervisor); + if (stopped.status === "incomplete") { + failures.push( + new Error(`Fake Rizin cleanup incomplete: ${stopped.reason}`), + ); + } else if (supervisor.observedExit() === undefined) { + failures.push( + new Error("Fake Rizin cleanup did not observe child exit"), + ); + } else { + ownedProcesses.delete(supervisor); + } + } + // Keep ownership and fixture files available when cleanup needs a retry. + if (failures.length > 0) + throw new AggregateError(failures, "Fake Rizin fixture cleanup failed"); + for (const directory of directories) { await rm(directory, { recursive: true, force: true }); - directory = undefined; + directories.delete(directory); + } + latestDirectory = undefined; }; - const missingProviderPath = (): string => join(directory!, "does-not-exist"); + const missingProviderPath = (): string => + join(latestDirectory!, "does-not-exist"); return { cleanup, makeProvider, missingProviderPath, start, startInjected, - track, + createManager, + spawnOwnedProcess, }; }; diff --git a/tests/boundary/providers/RizinDebugSessionCoverage.part1.test.ts b/tests/boundary/providers/RizinDebugSessionCoverage.part1.test.ts index 1f5c8e49c..ad6441420 100644 --- a/tests/boundary/providers/RizinDebugSessionCoverage.part1.test.ts +++ b/tests/boundary/providers/RizinDebugSessionCoverage.part1.test.ts @@ -1,12 +1,13 @@ +import { randomUUID } from "node:crypto"; +import { access } from "node:fs/promises"; import { afterEach, expect, it, vi } from "vitest"; -import { RizinDebugSessionManager } from "../../../src/rizin/RizinDebugSessionManager.js"; import { - createRizinTestManager, stopFakeNoInferior, rizinTestInferiorTty, } from "./RizinTestInferiorTty.js"; import { rizinCommand } from "../../../src/rizin/RizinCommand.js"; -import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js"; +import * as providerProcess from "../../../src/process/ProviderProcess.js"; +import type { SpawnedOwnedProviderProcess } from "../../../src/process/ProviderProcess.js"; import { createRizinCoverageHarness, createInjectedProcess, @@ -20,7 +21,8 @@ const { missingProviderPath, start, startInjected, - track, + createManager, + spawnOwnedProcess, } = createRizinCoverageHarness(); afterEach(cleanup); @@ -40,7 +42,7 @@ it.skipIf(process.platform === "win32")( it.skipIf(process.platform === "win32")( "rejects unsupported, pre-cancelled, and failed startup requests", async () => { - const windows = createRizinTestManager({}, { platform: "win32" }); + const windows = createManager({}, { platform: "win32" }); expect( ( await windows.start({ @@ -61,20 +63,20 @@ it.skipIf(process.platform === "win32")( ).toBe(false); const failed = await makeProvider("process.exit(9);"); - const manager = createRizinTestManager({ + const manager = createManager({ REA_RIZIN_COMMAND: failed, }); - track(manager); + const result = await manager.start({ path: "/tmp/a", inferior_tty: await rizinTestInferiorTty(), }); expect(result.ok).toBe(false); - const missing = createRizinTestManager({ + const missing = createManager({ REA_RIZIN_COMMAND: missingProviderPath(), }); - track(missing); + const spawnFailure = await missing.start({ path: "/tmp/a", inferior_tty: await rizinTestInferiorTty(), @@ -87,10 +89,10 @@ it.skipIf(process.platform === "win32")( "returns a startup timeout when Rizin exits before its initial frame", async () => { const provider = await makeProvider("process.exit(0);"); - const manager = createRizinTestManager({ + const manager = createManager({ REA_RIZIN_COMMAND: provider, }); - track(manager); + const result = await manager.start({ path: "/tmp/early-exit", inferior_tty: await rizinTestInferiorTty(), @@ -110,14 +112,14 @@ it.skipIf(process.platform === "win32")( startup: () => undefined, beforeSpawnReturns: resolveSpawned, }); - const manager = createRizinTestManager( + const manager = createManager( { REA_RIZIN_COMMAND: "injected-rizin" }, { spawnProcess: injected.spawnProcess, stopProcess: async () => ({ status: "stopped" }), }, ); - track(manager); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); try { const starting = manager.start({ @@ -139,7 +141,7 @@ it.skipIf(process.platform === "win32")( "does not spawn Rizin when cancellation arrives during TTY validation", async () => { let spawned = false; - const manager = createRizinTestManager( + const manager = createManager( { REA_RIZIN_COMMAND: "injected-rizin" }, { spawnProcess: async () => { @@ -164,7 +166,7 @@ it.skipIf(process.platform === "win32")( "rejects a non-TTY path before spawning Rizin", async () => { let spawned = false; - const manager = createRizinTestManager( + const manager = createManager( { REA_RIZIN_COMMAND: "injected-rizin" }, { spawnProcess: async () => { @@ -195,7 +197,7 @@ it.skipIf(process.platform === "win32")( releaseSpawns = resolve; }); const injected = createInjectedProcess(); - const manager = createRizinTestManager( + const manager = createManager( { REA_RIZIN_COMMAND: "injected-rizin" }, { spawnProcess: async (options) => { @@ -240,10 +242,9 @@ it.skipIf(process.platform === "win32")( const provider = await makeProvider( 'process.stdout.write("x".repeat(16 * 1024 * 1024 + 1) + "\\0"); process.stdin.resume();', ); - const manager = createRizinTestManager({ + const manager = createManager({ REA_RIZIN_COMMAND: provider, }); - track(manager); const result = await manager.start({ path: "/tmp/oversized-startup", @@ -264,19 +265,19 @@ it.skipIf(process.platform === "win32")( const provider = await makeProvider( 'setTimeout(() => process.stdout.write("\\0"), 500); process.stdin.resume();', ); - const manager = createRizinTestManager( + const manager = createManager( { REA_RIZIN_COMMAND: provider, }, { spawnProcess: async (options) => { - const launched = await spawnOwnedProviderProcess(options); + const launched = await spawnOwnedProcess(options); resolveSpawned(); return launched; }, }, ); - track(manager); + const abort = new AbortController(); const starting = manager.start( { path: "/tmp/cancel-start", inferior_tty: await rizinTestInferiorTty() }, @@ -288,39 +289,138 @@ it.skipIf(process.platform === "win32")( }, ); -it.skipIf(process.platform === "win32")( - "preserves startup cleanup uncertainty after cancellation", - async () => { +it + .skipIf(process.platform === "win32") + .each(["is uncertain", "reports stopped before exit"] as const)( + "cleans startup fixtures after cancellation when manager stop %s", + async (stopMode) => { + const provider = await makeProvider( + "process.stdin.resume(); setInterval(() => {}, 1000);", + ); let resolveSpawned!: () => void; const spawned = new Promise((resolve) => { resolveSpawned = resolve; }); - const provider = await makeProvider( + let ownedLaunch: SpawnedOwnedProviderProcess | undefined; + const nativeSpawn = providerProcess.spawnOwnedProviderProcess; + const spy = vi + .spyOn(providerProcess, "spawnOwnedProviderProcess") + .mockImplementation(async (options) => { + const launched = await nativeSpawn(options); + ownedLaunch = launched; + resolveSpawned(); + return launched; + }); + try { + const stopProcess: typeof incompleteStop = + stopMode === "is uncertain" + ? incompleteStop + : async () => ({ status: "stopped" }); + const manager = createManager( + { REA_RIZIN_COMMAND: provider }, + { stopProcess }, + ); + const abort = new AbortController(); + const starting = manager.start( + { + path: "/tmp/cancel-start", + inferior_tty: await rizinTestInferiorTty(), + }, + abort.signal, + ); + await spawned; + abort.abort(); + const result = await starting; + expect(result.ok).toBe(false); + if (!result.ok && stopMode === "is uncertain") + expect(result.error.message).toContain("injected stop uncertainty"); + await cleanup(); + if (ownedLaunch === undefined) + throw new Error("Fixture launch was not observed"); + expect( + ownedLaunch.process.exitCode !== null || + ownedLaunch.process.signalCode !== null, + "harness returned before fixture child exit", + ).toBe(true); + } finally { + spy.mockRestore(); + // A failing regression must also reap its observed, directly owned child. + if ( + ownedLaunch !== undefined && + ownedLaunch.process.exitCode === null && + ownedLaunch.process.signalCode === null + ) { + const safety = new providerProcess.ProviderProcessSupervisor( + { ...ownedLaunch, ownsProcessLifetime: true }, + { captureStdout: false, maxDiagnosticBytes: 0 }, + ); + expect((await safety.stop()).status).not.toBe("incomplete"); + expect(safety.observedExit()).toBeDefined(); + } + } + }, +); + +it.skipIf(process.platform === "win32")( + "retains owned fixture processes and files until a failed cleanup can complete", + async () => { + let failCleanup = true; + const fixture = createRizinCoverageHarness({ + stopOwnedProcess: async (supervisor) => + failCleanup + ? { status: "incomplete", reason: "injected fixture stop failure" } + : supervisor.stop(), + }); + const provider = await fixture.makeProvider( "process.stdin.resume(); setInterval(() => {}, 1000);", ); - const manager = createRizinTestManager( - { REA_RIZIN_COMMAND: provider }, - { - stopProcess: incompleteStop, - spawnProcess: async (options) => { - const launched = await spawnOwnedProviderProcess(options); - resolveSpawned(); - return launched; - }, - }, - ); - track(manager); - const abort = new AbortController(); - const starting = manager.start( - { path: "/tmp/cancel-start", inferior_tty: await rizinTestInferiorTty() }, - abort.signal, - ); - await spawned; - abort.abort(); - const result = await starting; - expect(result.ok).toBe(false); - if (!result.ok) - expect(result.error.message).toContain("injected stop uncertainty"); + const launched = await fixture.spawnOwnedProcess({ + command: provider, + arguments: [], + runId: randomUUID(), + stdin: "pipe", + }); + try { + await expect(fixture.cleanup()).rejects.toMatchObject({ + message: "Fake Rizin fixture cleanup failed", + errors: [ + expect.objectContaining({ + message: expect.stringContaining("injected fixture stop failure"), + }), + ], + }); + expect(launched.process.exitCode).toBeNull(); + expect(launched.process.signalCode).toBeNull(); + await access(provider); + const nextProvider = await fixture.makeProvider("process.exit(0);"); + failCleanup = false; + await fixture.cleanup(); + expect( + launched.process.exitCode !== null || + launched.process.signalCode !== null, + ).toBe(true); + await expect(access(provider)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(access(nextProvider)).rejects.toMatchObject({ + code: "ENOENT", + }); + } finally { + failCleanup = false; + try { + await fixture.cleanup(); + } finally { + if ( + launched.process.exitCode === null && + launched.process.signalCode === null + ) { + const safety = new providerProcess.ProviderProcessSupervisor( + { ...launched, ownsProcessLifetime: true }, + { captureStdout: false, maxDiagnosticBytes: 0 }, + ); + expect((await safety.stop()).status).not.toBe("incomplete"); + expect(safety.observedExit()).toBeDefined(); + } + } + } }, ); diff --git a/tests/boundary/providers/RizinDebugSessionCoverage.part2.test.ts b/tests/boundary/providers/RizinDebugSessionCoverage.part2.test.ts index ba6390184..6a899798f 100644 --- a/tests/boundary/providers/RizinDebugSessionCoverage.part2.test.ts +++ b/tests/boundary/providers/RizinDebugSessionCoverage.part2.test.ts @@ -1,9 +1,5 @@ import { afterEach, expect, it } from "vitest"; -import { RizinDebugSessionManager } from "../../../src/rizin/RizinDebugSessionManager.js"; -import { - createRizinTestManager, - rizinTestInferiorTty, -} from "./RizinTestInferiorTty.js"; +import { rizinTestInferiorTty } from "./RizinTestInferiorTty.js"; import { createRizinCoverageHarness, abortOnCheck, @@ -11,7 +7,7 @@ import { incompleteStop, } from "./RizinDebugSessionCoverage.fixtures.js"; -const { cleanup, makeProvider, start, startInjected, track } = +const { cleanup, makeProvider, start, startInjected, createManager } = createRizinCoverageHarness(); afterEach(cleanup); @@ -88,7 +84,7 @@ it.skipIf(process.platform === "win32")( async () => { for (const spawnError of [new Error("injected spawn failure"), "opaque"]) { const injected = createInjectedProcess({ spawnError }); - const manager = createRizinTestManager( + const manager = createManager( { REA_RIZIN_COMMAND: "injected-rizin" }, { spawnProcess: injected.spawnProcess }, ); @@ -102,7 +98,7 @@ it.skipIf(process.platform === "win32")( const injected = createInjectedProcess({ startup: (control) => control.closeStdin(), }); - const manager = createRizinTestManager( + const manager = createManager( { REA_RIZIN_COMMAND: "injected-rizin" }, { spawnProcess: injected.spawnProcess }, ); @@ -124,7 +120,7 @@ it.skipIf(process.platform === "win32")( beforeSpawnReturns: () => abort.abort(), spawnError: new Error("spawn interrupted"), }); - const manager = createRizinTestManager( + const manager = createManager( { REA_RIZIN_COMMAND: "injected-rizin" }, { spawnProcess: injected.spawnProcess }, ); @@ -147,7 +143,7 @@ it.skipIf(process.platform === "win32")( abort.abort(); }, }); - const manager = createRizinTestManager( + const manager = createManager( { REA_RIZIN_COMMAND: "injected-rizin" }, { spawnProcess: injected.spawnProcess }, ); @@ -166,7 +162,7 @@ it.skipIf(process.platform === "win32")( const injected = createInjectedProcess({ beforeSpawnReturns: () => abort.abort(), }); - const manager = createRizinTestManager( + const manager = createManager( { REA_RIZIN_COMMAND: "injected-rizin" }, { spawnProcess: injected.spawnProcess }, ); diff --git a/tests/boundary/providers/RizinDebugSessionCoverage.part3.test.ts b/tests/boundary/providers/RizinDebugSessionCoverage.part3.test.ts index 393c0f4a5..fe9849c2e 100644 --- a/tests/boundary/providers/RizinDebugSessionCoverage.part3.test.ts +++ b/tests/boundary/providers/RizinDebugSessionCoverage.part3.test.ts @@ -1,17 +1,13 @@ import { afterEach, expect, it, vi } from "vitest"; import { AnalysisInputError } from "../../../src/domain/analysisErrorCore.js"; -import { RizinDebugSessionManager } from "../../../src/rizin/RizinDebugSessionManager.js"; -import { - createRizinTestManager, - rizinTestInferiorTty, -} from "./RizinTestInferiorTty.js"; +import { rizinTestInferiorTty } from "./RizinTestInferiorTty.js"; import { createRizinCoverageHarness, abortOnCheck, createInjectedProcess, } from "./RizinDebugSessionCoverage.fixtures.js"; -const { cleanup, startInjected, track } = createRizinCoverageHarness(); +const { cleanup, startInjected, createManager } = createRizinCoverageHarness(); afterEach(cleanup); it.skipIf(process.platform === "win32")( diff --git a/tests/boundary/providers/RizinDebugSessionCoverage.part4.test.ts b/tests/boundary/providers/RizinDebugSessionCoverage.part4.test.ts index e063fbea5..11e68e3e6 100644 --- a/tests/boundary/providers/RizinDebugSessionCoverage.part4.test.ts +++ b/tests/boundary/providers/RizinDebugSessionCoverage.part4.test.ts @@ -1,17 +1,13 @@ import { afterEach, expect, it } from "vitest"; import { evidenceSchema } from "../../../src/domain/evidence.js"; -import { RizinDebugSessionManager } from "../../../src/rizin/RizinDebugSessionManager.js"; -import { - createRizinTestManager, - rizinTestInferiorTty, -} from "./RizinTestInferiorTty.js"; +import { rizinTestInferiorTty } from "./RizinTestInferiorTty.js"; import { createRizinCoverageHarness, createInjectedProcess, incompleteStop, } from "./RizinDebugSessionCoverage.fixtures.js"; -const { cleanup, makeProvider, start, startInjected, track } = +const { cleanup, makeProvider, start, startInjected, createManager } = createRizinCoverageHarness(); afterEach(cleanup);