diff --git a/tests/boundary/process/pidFile.test.ts b/tests/boundary/process/pidFile.test.ts new file mode 100644 index 0000000000..e30459fe2b --- /dev/null +++ b/tests/boundary/process/pidFile.test.ts @@ -0,0 +1,45 @@ +import { open, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; + +import { expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { waitForTestPidFile } from "../../support/process/pidFile.js"; + +it("waits through a missing file and an empty file before accepting its PID", async () => { + const directory = await createTestTempDirectory("rea-pid-file-"); + const path = join(directory, "pid"); + let settled = false; + const reading = waitForTestPidFile(path, 1_000).finally(() => { + settled = true; + }); + const observed = reading.catch(() => undefined); + try { + await delay(30); + expect(settled).toBe(false); + const file = await open(path, "w"); + try { + await delay(30); + expect(settled).toBe(false); + await file.writeFile(String(process.pid)); + await expect(reading).resolves.toBe(process.pid); + } finally { + await file.close(); + } + } finally { + await observed; + } +}); + +it.each(["", "0", "-1", "1e3", "12x", "9007199254740992"])( + "refuses a PID file containing %j when its deadline expires", + async (text) => { + const directory = await createTestTempDirectory("rea-invalid-pid-file-"); + const path = join(directory, "pid"); + await writeFile(path, text); + await expect(waitForTestPidFile(path, 30)).rejects.toThrow( + "did not publish a valid PID", + ); + }, +); diff --git a/tests/boundary/providers/GdbSessionReviewRegressions.test.ts b/tests/boundary/providers/GdbSessionReviewRegressions.test.ts index 0992920881..eb0b00634b 100644 --- a/tests/boundary/providers/GdbSessionReviewRegressions.test.ts +++ b/tests/boundary/providers/GdbSessionReviewRegressions.test.ts @@ -10,6 +10,7 @@ import { evidenceSchema } from "../../../src/domain/evidence.js"; import { cleanupOwnedProcessGroup } from "../../../src/process/ProcessOwnership.js"; import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js"; import { stopOwnedDebuggerProcess } from "../../../src/process/ownedDebuggerSession.js"; +import { waitForTestPidFile } from "../../support/process/pidFile.js"; // These tests launch controlled fake providers; Linux admission is injected so // lifecycle semantics remain testable on hosts that correctly reject GDB. @@ -21,9 +22,9 @@ const createGdbManagerForInjectedPlatform = ( let directory: string | undefined; const managers: Array<{ closeAll(): Promise }> = []; -const expectOwnedProcessExited = async (pidText: string): Promise => { - const pid = Number(pidText); - expect(Number.isInteger(pid)).toBe(true); +const expectOwnedProcessExited = async (pid: number): Promise => { + expect(Number.isSafeInteger(pid)).toBe(true); + expect(pid).toBeGreaterThan(0); await expect .poll(() => { try { @@ -44,6 +45,10 @@ const expectOwnedProcessExited = async (pidText: string): Promise => { .toBe(false); }; +it("rejects an empty PID before observing process exit", async () => { + await expect(expectOwnedProcessExited(Number(""))).rejects.toThrow(); +}); + it("keeps persistent GDB sessions unavailable on Windows", async () => { const gdb = await createGdbManagerForInjectedPlatform({ environment: { REA_GDB_COMMAND: "/missing/gdb" }, @@ -448,19 +453,7 @@ process.stdin.resume(); managers.push(manager); const starting = manager.start(); - let pidText: string | undefined; - for ( - let attempt = 0; - attempt < 100 && pidText === undefined; - attempt += 1 - ) { - try { - pidText = await readFile(pidPath, "utf8"); - } catch { - await new Promise((resolve) => setTimeout(resolve, 10)); - } - } - if (pidText === undefined) throw new Error("fake GDB did not start"); + const pid = await waitForTestPidFile(pidPath, 1_000); let closeSettled = false; const closing = manager.closeAll().then(() => { @@ -481,7 +474,7 @@ process.stdin.resume(); if (started.ok) throw new Error("GDB unexpectedly started"); expect(started.error._tag).toBe("AnalysisCancelledError"); expect(await readFile(signalPath, "utf8")).toBe("SIGTERM"); - await expectOwnedProcessExited(pidText); + await expectOwnedProcessExited(pid); expect(manager.status("unknown-session")).toBeUndefined(); await expect(manager.start()).resolves.toMatchObject({ ok: false, @@ -589,7 +582,7 @@ setTimeout(() => process.exit(0), 250).unref(); expect(result.error.cleanupIncomplete).toBe(true); } await expectOwnedProcessExited( - await readFile(join(directory, "pid"), "utf8"), + await waitForTestPidFile(join(directory, "pid"), 1_000), ); expect(manager.status(started.value.session_id)?.state).toBe("closed"); }, @@ -1033,7 +1026,7 @@ setInterval(() => {}, 1000); child.kill("SIGKILL"); } await ownedProcessExit; - await expectOwnedProcessExited(String(ownedLaunch.process.pid)); + await expectOwnedProcessExited(ownedLaunch.process.pid!); } const retried = await manager.retryCleanupFailures(); expect(retried.ok).toBe(false); diff --git a/tests/boundary/providers/RizinInferiorTty.test.ts b/tests/boundary/providers/RizinInferiorTty.test.ts index a79aaadea9..9fa965d9ce 100644 --- a/tests/boundary/providers/RizinInferiorTty.test.ts +++ b/tests/boundary/providers/RizinInferiorTty.test.ts @@ -25,6 +25,7 @@ import { rizinTestInferiorTty, } from "./RizinTestInferiorTty.js"; import { createInjectedProcess } from "./RizinDebugSessionCoverage.fixtures.js"; +import { waitForTestPidFile } from "../../support/process/pidFile.js"; let terminal: ReturnType | undefined; let manager: RizinDebugSessionManager | undefined; @@ -484,18 +485,7 @@ it.skipIf( ); const closed = await closing; expect(closed.ok, closed.ok ? "" : closed.error.message).toBe(true); - const deadline = Date.now() + 5_000; - let pidText = ""; - while (Date.now() < deadline) { - try { - pidText = await readFile(pidPath, "utf8"); - break; - } catch { - await new Promise((resolveDelay) => setTimeout(resolveDelay, 50)); - } - } - inferiorPid = Number(pidText); - expect(Number.isSafeInteger(inferiorPid) && inferiorPid > 0).toBe(true); + inferiorPid = await waitForTestPidFile(pidPath, 5_000); expect(() => process.kill(inferiorPid!, 0)).not.toThrow(); await writeFile(releasePath, "release after close"); const outputDeadline = Date.now() + 3_000; @@ -636,20 +626,7 @@ process.stdin.on("data", (chunk) => { ).toBe(true); if (!started.ok) throw started.error; if (launchTarget) { - let pidText: string | undefined; - const deadline = Date.now() + 2_000; - while (pidText === undefined && Date.now() < deadline) { - try { - pidText = await readFile(pidPath, "utf8"); - } catch { - await new Promise((resolveDelay) => setTimeout(resolveDelay, 10)); - } - } - const parsedInferiorPid = - pidText === undefined ? Number.NaN : Number(pidText); - if (!Number.isSafeInteger(parsedInferiorPid) || parsedInferiorPid <= 0) - throw new Error("The test inferior did not publish a valid PID."); - inferiorPid = parsedInferiorPid; + inferiorPid = await waitForTestPidFile(pidPath, 2_000); } return started.value.session_id; } diff --git a/tests/support/process/pidFile.ts b/tests/support/process/pidFile.ts new file mode 100644 index 0000000000..1f350c0d89 --- /dev/null +++ b/tests/support/process/pidFile.ts @@ -0,0 +1,25 @@ +import { readFile } from "node:fs/promises"; +import { setTimeout as delay } from "node:timers/promises"; + +/** Wait for a fixture's PID bytes, not merely the creation of its file. */ +export const waitForTestPidFile = async ( + path: string, + timeoutMs: number, +): Promise => { + const deadline = performance.now() + timeoutMs; + do { + let text = ""; + try { + text = await readFile(path, "utf8"); + } catch (cause) { + if ( + !(cause instanceof Error && "code" in cause && cause.code === "ENOENT") + ) + throw cause; + } + const pid = Number(text); + if (/^[1-9][0-9]*$/.test(text) && Number.isSafeInteger(pid)) return pid; + await delay(10); + } while (performance.now() < deadline); + throw new Error(`Test process did not publish a valid PID in ${path}`); +};