From 4573e8cf35de822bfc5feabcc89ec3717c247c8c Mon Sep 17 00:00:00 2001 From: lyly Date: Mon, 12 Oct 2026 14:06:52 +0800 Subject: [PATCH] fix(ghidra): recover stale clients on same-target reopen (#2014) * fix(ghidra): recover stale clients on same-target reopen * test(ghidra): allow supervised shutdown in health regression * fix(ghidra): read process exit without diagnostic snapshots Health and startup exit checks called the supervisor snapshot, which concatenates retained launcher output only to read the exit code. * test(adb): align fixtures with literal output and directory commands --------- Co-authored-by: Cursor Agent Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- src/adb/AdbProvider.test.ts | 21 +- src/cli/webModuleCommands.ts | 8 +- src/ghidra/GhidraClient.ts | 32 +- src/ghidra/GhidraProviderClient.ts | 6 +- src/process/ProviderProcess.ts | 28 +- src/process/ProviderProcessOutput.test.ts | 53 ++- tests/boundary/mcp/ghidraReopen.test.ts | 346 ++++++++++++++++++ .../providers/ghidra/ghidraClient.test.ts | 20 +- tests/fixtures/fakeGhidra.mjs | 8 + 9 files changed, 498 insertions(+), 24 deletions(-) create mode 100644 tests/boundary/mcp/ghidraReopen.test.ts diff --git a/src/adb/AdbProvider.test.ts b/src/adb/AdbProvider.test.ts index f9fb6f5201..4d6c13001e 100644 --- a/src/adb/AdbProvider.test.ts +++ b/src/adb/AdbProvider.test.ts @@ -495,7 +495,7 @@ it("reads a bounded logcat dump with the caller count and buffer", async () => { const { binary } = await writeAdbStub( shellStub({ logcat: - "08-10 13:00:00.000 I/Tag( 123): first\\n08-10 13:00:01.000 I/Tag( 123): second\\n", + "08-10 13:00:00.000 I/Tag( 123): first\n08-10 13:00:01.000 I/Tag( 123): second\n", }), ); const result = await evidenceResult(providerOver(binary), { @@ -903,7 +903,7 @@ it("resolves packages by case-insensitive substring", async () => { const { binary } = await writeAdbStub( shellStub({ "pm:list": - "package:/data/app/x/base.apk=com.whatsapp\\npackage:/data/app/y/base.apk=com.whatsapp.w4b\\npackage:/data/app/z/base.apk=com.example\\n", + "package:/data/app/x/base.apk=com.whatsapp\npackage:/data/app/y/base.apk=com.whatsapp.w4b\npackage:/data/app/z/base.apk=com.example\n", }), ); const result = await evidenceResult(providerOver(binary), { @@ -1109,20 +1109,23 @@ exit 1`, it("quotes caller text before adb joins the remote shell command", async () => { const { binary, root } = await writeAdbStub( - (root) => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi + () => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi shift 3 # Model adb's remote command joining, then execute the resulting shell string. -printf '#!/bin/sh\nprintf "%%s" "$@" > "${join(root, "observed")}"\n' > "${join(root, "ls")}" -chmod +x "${join(root, "ls")}" -PATH="${root}:$PATH" sh -c "$*" +sh -c "$*" `, ); - const value = `/sdcard/a 'quoted'; touch ${join(root, "injected")}`; - await providerOver(binary).execute({ + const value = join(root, `device 'quoted'; touch ${join(root, "injected")}`); + await mkdir(value, { recursive: true }); + const result = await evidenceResult(providerOver(binary), { operation: "list_adb_directory", input: { serial: "device", device_path: value }, }); - expect(await readFile(join(root, "observed"), "utf8")).toContain(value); + expect(result).toMatchObject({ + device_path: value, + entries: [], + coverage: "complete", + }); await expect(readFile(join(root, "injected"))).rejects.toThrow(); }); diff --git a/src/cli/webModuleCommands.ts b/src/cli/webModuleCommands.ts index b14f23da5a..0e58ea8041 100644 --- a/src/cli/webModuleCommands.ts +++ b/src/cli/webModuleCommands.ts @@ -32,13 +32,13 @@ export const registerWebModuleCommands = ( options: z.object({ importerUrl: webModuleTraceInputSchema.shape.importer_url, importMapPath: importMapInputSchema.shape.path + .optional() .describe( "Absolute local import-map JSON path; requires --import-map-base-url", - ) - .optional(), + ), importMapBaseUrl: importMapInputSchema.shape.base_url - .describe("HTTP(S) import-map base URL; requires --import-map-path") - .optional(), + .optional() + .describe("HTTP(S) import-map base URL; requires --import-map-path"), }), run: ({ args, options }) => withCommandCancellation(async (signal) => { diff --git a/src/ghidra/GhidraClient.ts b/src/ghidra/GhidraClient.ts index f2ccd381d1..7731cf8723 100644 --- a/src/ghidra/GhidraClient.ts +++ b/src/ghidra/GhidraClient.ts @@ -7,6 +7,10 @@ import type { ProgressReporter } from "../application/ProgressReporter.js"; import { AnalysisError } from "../domain/analysisErrorBase.js"; import type { JsonValue } from "../domain/jsonValue.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import { + providerRetryAction, + type ProviderOperationHealth, +} from "../domain/providerOperationHealth.js"; import { err, ok, type Result } from "../domain/result.js"; import { silentLogger } from "../logger.js"; import type { Logger } from "pino"; @@ -477,6 +481,32 @@ export class GhidraClient { }); } + /** Observed failures that prevent a retained session from being reused. */ + operationHealthSnapshot(): ProviderOperationHealth | undefined { + const exit = this.#process?.observedExit(); + const state = + exit !== undefined + ? "exited" + : this.#socket?.destroyed === true + ? "unreachable" + : this.#startPromise === undefined && + (this.#process !== undefined || + this.#socket !== undefined || + this.#runtimeRoot !== undefined || + this.#socketRoot !== undefined || + this.#snapshotOwners.size > 0) + ? "unknown" + : undefined; + if (state === undefined) return undefined; + return { + state, + stage: null, + retryAction: providerRetryAction(state), + exitCode: exit?.code ?? null, + requests: [], + }; + } + /** Latest bounded local diagnostics, with bearer material redacted. */ diagnostics(): Readonly> { return structuredClone(this.#diagnostics()); @@ -633,7 +663,7 @@ export class GhidraClient { deadline, failure: this.#failure, isClosed: () => this.#closing, - processExited: () => this.#process?.snapshot().exitCode !== undefined, + processExited: () => this.#process?.observedExit() !== undefined, startupTimeoutMs: this.#options.startupTimeoutMs, handlers: { data: this.#onSocketData, diff --git a/src/ghidra/GhidraProviderClient.ts b/src/ghidra/GhidraProviderClient.ts index b7ba24e0a7..d2b963e521 100644 --- a/src/ghidra/GhidraProviderClient.ts +++ b/src/ghidra/GhidraProviderClient.ts @@ -108,7 +108,10 @@ export type GhidraProviderClientFactory = ( Partial< Pick< GhidraClient, - "runtimeLineage" | "readTargetSnapshot" | "hashTargetSnapshot" + | "operationHealthSnapshot" + | "runtimeLineage" + | "readTargetSnapshot" + | "hashTargetSnapshot" > >; @@ -840,6 +843,7 @@ export const createGhidraProviderClient = (input: { }), ); }, + operationHealthSnapshot: () => client.operationHealthSnapshot?.(), runtimeLineageSnapshots: () => { const observation = client.runtimeLineage?.() ?? null; return observation === null diff --git a/src/process/ProviderProcess.ts b/src/process/ProviderProcess.ts index 1768c6c5bc..d7b95613fe 100644 --- a/src/process/ProviderProcess.ts +++ b/src/process/ProviderProcess.ts @@ -78,6 +78,15 @@ export type ProviderProcessLaunch = readonly cleanup?: () => Promise; }; +/** + * Process exit recorded by the supervisor. + * Independent of retained stream diagnostics. + */ +export interface ProviderProcessExitObservation { + readonly code: number | null; + readonly signal: NodeJS.Signals | null; +} + /** Detached diagnostics captured for one supervised provider process. */ export interface ProviderProcessSnapshot { /** True only when a configured diagnostic budget excluded output bytes. */ @@ -233,10 +242,7 @@ export class ProviderProcessSupervisor { readonly #options: ProviderProcessSupervisorOptions; readonly #stdout = new ProcessOutputCapture(); readonly #stderr = new ProcessOutputCapture(); - readonly #exit = deferred<{ - readonly code: number | null; - readonly signal: NodeJS.Signals | null; - }>(); + readonly #exit = deferred(); readonly #outputClose = deferred(); #outputClosed = false; #stdoutObservedBytes = 0; @@ -271,9 +277,7 @@ export class ProviderProcessSupervisor { readonly #onError = (cause: Error): void => { this.#options.onDiagnostic?.({ type: "error", message: cause.message }); }; - #exitObservation: - | { readonly code: number | null; readonly signal: NodeJS.Signals | null } - | undefined; + #exitObservation: ProviderProcessExitObservation | undefined; #stopPromise: Promise | undefined; #disposed = false; @@ -292,6 +296,16 @@ export class ProviderProcessSupervisor { this.#recordExit(launch.process.exitCode, launch.process.signalCode); } + /** + * Recorded process exit without concatenating retained stream diagnostics. + * Undefined until exit or close is observed. + */ + observedExit(): ProviderProcessExitObservation | undefined { + const exit = this.#exitObservation; + if (exit === undefined) return undefined; + return { code: exit.code, signal: exit.signal }; + } + /** Latest retained output and exit observation, with explicit configured truncation. */ snapshot(): ProviderProcessSnapshot { return { diff --git a/src/process/ProviderProcessOutput.test.ts b/src/process/ProviderProcessOutput.test.ts index b24f7646e8..28dc87dffe 100644 --- a/src/process/ProviderProcessOutput.test.ts +++ b/src/process/ProviderProcessOutput.test.ts @@ -1,6 +1,6 @@ import { EventEmitter } from "node:events"; import { PassThrough } from "node:stream"; -import { expect, it } from "vitest"; +import { expect, it, vi } from "vitest"; import { ProviderProcessSupervisor } from "./ProviderProcess.js"; it("exit does not establish complete output when a producer closes its streams later", async () => { @@ -34,6 +34,57 @@ it("exit does not establish complete output when a producer closes its streams l } }); +it("reads an observed exit without materializing stream diagnostics", () => { + const producer = Object.assign(new EventEmitter(), { + stdout: new PassThrough(), + stderr: new PassThrough(), + exitCode: null, + signalCode: null, + kill: () => false, + }); + const supervisor = new ProviderProcessSupervisor({ + process: producer, + ownsProcessLifetime: false, + }); + try { + producer.stdout.write("retained-diagnostic"); + const snapshot = vi.spyOn(supervisor, "snapshot"); + expect(supervisor.observedExit()).toBeUndefined(); + producer.emit("exit", null, "SIGKILL"); + expect(supervisor.observedExit()).toEqual({ + code: null, + signal: "SIGKILL", + }); + expect(snapshot).not.toHaveBeenCalled(); + expect(supervisor.snapshot()).toMatchObject({ + stdout: { text: "retained-diagnostic" }, + exitCode: null, + signal: "SIGKILL", + }); + } finally { + supervisor.dispose(); + } +}); + +it("observes an exit that completed before supervision", () => { + const producer = Object.assign(new EventEmitter(), { + stdout: new PassThrough(), + stderr: new PassThrough(), + exitCode: 2, + signalCode: null, + kill: () => false, + }); + const supervisor = new ProviderProcessSupervisor({ + process: producer, + ownsProcessLifetime: false, + }); + try { + expect(supervisor.observedExit()).toEqual({ code: 2, signal: null }); + } finally { + supervisor.dispose(); + } +}); + it("reports a reached process memory limit only when the handle observed one", () => { let reached = false; const producer = Object.assign(new EventEmitter(), { diff --git a/tests/boundary/mcp/ghidraReopen.test.ts b/tests/boundary/mcp/ghidraReopen.test.ts new file mode 100644 index 0000000000..bdac57233d --- /dev/null +++ b/tests/boundary/mcp/ghidraReopen.test.ts @@ -0,0 +1,346 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { access, mkdir, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { describe, expect, it } from "vitest"; + +import { AnalysisProviderRegistry } from "../../../src/application/binary/AnalysisProviderRegistry.js"; +import { composeBinarySession } from "../../../src/application/binary/BinarySessionComposition.js"; +import { LazyAnalysisProvider } from "../../../src/application/binary/LazyAnalysisProvider.js"; +import { parseConfig } from "../../../src/config/parseConfig.js"; +import { elf } from "../../../src/domain/binaryTarget.fixture.js"; +import { ok } from "../../../src/domain/result.js"; +import { GhidraClient } from "../../../src/ghidra/GhidraClient.js"; +import type { GhidraInstallationHost } from "../../../src/ghidra/GhidraInstallation.js"; +import type { + GhidraLaunchSession, + GhidraLauncher, +} from "../../../src/ghidra/GhidraLauncher.js"; +import { GhidraProvider } from "../../../src/ghidra/GhidraProvider.js"; +import { silentLogger } from "../../../src/logger.js"; +import { createServer } from "../../../src/server/createServer.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { waitForExit } from "../../support/process/processFixture.js"; + +const fixturePath = fileURLToPath( + new URL("../../fixtures/fakeGhidra.mjs", import.meta.url), +); + +type FixtureLaunch = { + child: ChildProcess; + runtimeRoot: string; + endpointPath: string; +}; + +class FixtureLauncher implements GhidraLauncher { + readonly launches: FixtureLaunch[] = []; + readonly requests: string[] = []; + cleanupAllowed = true; + cleanupAttempts = 0; + + async launch(session: GhidraLaunchSession) { + const projectRoot = join(session.runtimeRoot, "project"); + await mkdir(projectRoot, { recursive: true }); + const child = spawn(process.execPath, [fixturePath], { + stdio: ["ignore", "pipe", "pipe", "ipc"], + env: { + ...process.env, + REA_GHIDRA_FIXTURE_CONFIG: JSON.stringify({ + endpointPath: session.endpointPath, + token: session.token, + runId: session.runId, + providerVersion: session.providerVersion, + profileDigest: session.profileDigest, + targetSha256: session.targetSha256, + transport: session.transport, + }), + }, + }); + this.launches.push({ + child, + runtimeRoot: session.runtimeRoot, + endpointPath: session.endpointPath, + }); + child.on("message", (value: unknown) => { + if ( + typeof value === "object" && + value !== null && + "method" in value && + typeof value.method === "string" + ) + this.requests.push(value.method); + }); + return ok({ + process: child, + ownsProcessLifetime: true as const, + projectRoot, + ghidraLogPath: join(session.runtimeRoot, "ghidra.log"), + scriptLogPath: join(session.runtimeRoot, "script.log"), + cleanup: async () => { + this.cleanupAttempts += 1; + if (!this.cleanupAllowed) + return { + cleaned: false as const, + reason: "fixture cleanup is not yet confirmed", + }; + const signaled = child.exitCode === null && child.signalCode === null; + if (signaled) child.kill("SIGTERM"); + return (await waitForExit(child, 2_000)) + ? { cleaned: true as const, signaled } + : { cleaned: false as const, reason: "fixture child did not exit" }; + }, + }); + } +} + +const installationHost: GhidraInstallationHost = { + platform: "linux", + architecture: "x64", + readText: () => "application.version=12.1.4\n", + executable: () => true, + probeJava: () => ({ + version: "21.0.11", + major: 21, + home: "/fixture/jdk-21", + bits: 64, + runtime: "jdk", + }), +}; + +// Only installation discovery and the Java bridge are simulated. Requests use +// the production client, real child/socket and public MCP session routing. +const connectGhidra = async () => { + const directory = await createTestTempDirectory("rea-ghidra-reopen-"); + const target = join(directory, "target.elf"); + const source = elf(2, 1, 62); + await writeFile(target, source); + const config = parseConfig({ + GHIDRA_INSTALL_DIR: "/fixture/ghidra_12.1.4_PUBLIC", + }); + if (!config.ok) throw config.error; + const launcher = new FixtureLauncher(); + const clients: GhidraClient[] = []; + const provider = new GhidraProvider( + config.value, + silentLogger, + {}, + installationHost, + (options) => { + const client = new GhidraClient({ + ...options, + launcher, + platform: process.platform, + startupTimeoutMs: 5_000, + }); + clients.push(client); + return client; + }, + ); + const lazy = new LazyAnalysisProvider({ + identity: provider.identity(), + capabilities: provider.capabilities(), + load: () => Promise.resolve(provider), + }); + const session = composeBinarySession( + new AnalysisProviderRegistry([ + { + identity: () => provider.identity(), + capabilities: () => provider.capabilities(), + inspectAvailability: () => provider.inspectAvailability(), + inspectTargetSupport: (value) => provider.inspectTargetSupport(value), + resolveAnalysisProfile: (value, options) => + provider.resolveAnalysisProfile(value, options), + createClient: (value, profile, context) => + lazy.createClient(value, profile, context), + }, + ]), + ); + const server = createServer( + { kind: "session", session }, + { logger: silentLogger }, + ); + const mcp = new Client({ name: "ghidra-reopen", version: "1.0.0" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + const close = async () => { + launcher.cleanupAllowed = true; + await Promise.allSettled([mcp.close(), server.close()]); + const closed = await session.close(); + try { + for (const client of clients) + expect((await client.close()).ok).toBe(true); + expect(closed.ok).toBe(true); + } finally { + for (const { child } of launcher.launches) { + if (child.exitCode === null && child.signalCode === null) + child.kill("SIGTERM"); + expect(await waitForExit(child, 2_000)).toBe(true); + } + } + for (const launched of launcher.launches) await expectReleased(launched); + expect(await readFile(target)).toEqual(source); + }; + try { + await server.connect(serverTransport); + await mcp.connect(clientTransport); + } catch (cause) { + await close(); + throw cause; + } + return { + clients, + launcher, + close, + open: () => + mcp.callTool({ name: "open_binary", arguments: { path: target } }), + query: (addressSuffix: number) => + mcp.callTool({ + name: "list_strings", + arguments: { address: `0x200${addressSuffix}` }, + }), + }; +}; + +const firstLaunch = (launcher: FixtureLauncher): FixtureLaunch => { + const first = launcher.launches[0]; + if (first === undefined) throw new Error("Expected a running Ghidra fixture"); + return first; +}; + +const expectReleased = async ({ + child, + runtimeRoot, + endpointPath, +}: FixtureLaunch) => { + expect(await waitForExit(child, 2_000)).toBe(true); + await expect(access(runtimeRoot)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(access(endpointPath)).rejects.toMatchObject({ code: "ENOENT" }); +}; + +const disconnect = async ( + child: ChildProcess, + client: GhidraClient | undefined, +) => { + if (client === undefined) + throw new Error("Expected a connected Ghidra client"); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + child.off("message", received); + reject(new Error("Fixture did not acknowledge socket disconnection")); + }, 2_000); + const received = (message: unknown) => { + if ( + typeof message !== "object" || + message === null || + !("event" in message) || + message.event !== "disconnected" + ) + return; + clearTimeout(timer); + child.off("message", received); + resolve(); + }; + child.on("message", received); + child.send("disconnect"); + }); + await expect + .poll(async () => (await client.ping({ timeoutMs: 200 })).ok) + .toBe(false); + expect(child.exitCode).toBeNull(); + expect(child.signalCode).toBeNull(); +}; + +describe.skipIf(process.platform === "win32")( + "Ghidra MCP session recovery over Unix sockets", + () => { + it("keeps open lazy, reuses a live child, and replaces an exited child on reopen", async () => { + const harness = await connectGhidra(); + try { + expect((await harness.open()).isError).not.toBe(true); + expect((await harness.open()).isError).not.toBe(true); + expect(harness.clients).toHaveLength(0); + expect(harness.launcher.launches).toHaveLength(0); + expect((await harness.query(1)).isError).not.toBe(true); + const old = firstLaunch(harness.launcher); + expect((await harness.open()).isError).not.toBe(true); + expect((await harness.query(2)).isError).not.toBe(true); + expect(harness.launcher.launches).toHaveLength(1); + await expect + .poll( + () => + harness.launcher.requests.filter( + (method) => method === "list_strings", + ).length, + ) + .toBe(2); + expect(old.child.kill("SIGTERM")).toBe(true); + expect(await waitForExit(old.child, 2_000)).toBe(true); + expect(old.child.signalCode).toBe("SIGTERM"); + expect((await harness.open()).isError).not.toBe(true); + await expectReleased(old); + expect(harness.clients).toHaveLength(1); + expect(harness.launcher.launches).toHaveLength(1); + expect((await harness.query(3)).isError).not.toBe(true); + expect(harness.clients).toHaveLength(2); + expect(harness.launcher.launches).toHaveLength(2); + } finally { + await harness.close(); + } + }); + + it("retires a disconnected live child before lazily starting a replacement", async () => { + const harness = await connectGhidra(); + try { + expect((await harness.open()).isError).not.toBe(true); + expect((await harness.query(1)).isError).not.toBe(true); + const old = firstLaunch(harness.launcher); + await disconnect(old.child, harness.clients[0]); + expect((await harness.open()).isError).not.toBe(true); + await expectReleased(old); + expect(harness.clients).toHaveLength(1); + expect(harness.launcher.launches).toHaveLength(1); + expect((await harness.query(2)).isError).not.toBe(true); + expect(harness.clients).toHaveLength(2); + expect(harness.launcher.launches).toHaveLength(2); + } finally { + await harness.close(); + } + }); + + it("retains failed cleanup and refuses replacement until cleanup can be retried", async () => { + const harness = await connectGhidra(); + try { + expect((await harness.open()).isError).not.toBe(true); + expect((await harness.query(1)).isError).not.toBe(true); + const old = firstLaunch(harness.launcher); + await disconnect(old.child, harness.clients[0]); + harness.launcher.cleanupAllowed = false; + for (let attempt = 1; attempt <= 2; attempt += 1) { + expect(parseMcpToolError(await harness.open())).toMatchObject({ + error: { code: "cleanup_incomplete" }, + }); + expect(harness.launcher.cleanupAttempts).toBe(attempt); + expect(harness.clients).toHaveLength(1); + expect(harness.launcher.launches).toHaveLength(1); + expect(old.child.exitCode).toBeNull(); + expect(old.child.signalCode).toBeNull(); + await access(old.runtimeRoot); + } + harness.launcher.cleanupAllowed = true; + expect((await harness.open()).isError).not.toBe(true); + await expectReleased(old); + expect(harness.launcher.cleanupAttempts).toBe(3); + expect(harness.clients).toHaveLength(1); + expect(harness.launcher.launches).toHaveLength(1); + expect((await harness.query(2)).isError).not.toBe(true); + expect(harness.clients).toHaveLength(2); + expect(harness.launcher.launches).toHaveLength(2); + } finally { + await harness.close(); + } + }); + }, +); diff --git a/tests/boundary/providers/ghidra/ghidraClient.test.ts b/tests/boundary/providers/ghidra/ghidraClient.test.ts index 5b04f6c471..43abedb3dc 100644 --- a/tests/boundary/providers/ghidra/ghidraClient.test.ts +++ b/tests/boundary/providers/ghidra/ghidraClient.test.ts @@ -232,6 +232,7 @@ describe("GhidraClient captured source snapshot", () => { if (!started.ok) throw started.error; const close = vi .spyOn(OwnedFileHandle.prototype, "close") + .mockRejectedValueOnce(new Error("snapshot descriptor close failed")) .mockRejectedValueOnce(new Error("snapshot descriptor close failed")); try { await expect(client.hashTargetSnapshot()).resolves.toMatchObject({ @@ -240,11 +241,19 @@ describe("GhidraClient captured source snapshot", () => { }); const owner = close.mock.contexts[0]; expect(owner).toBeInstanceOf(OwnedFileHandle); + await expect(client.close()).resolves.toMatchObject({ + ok: false, + error: { cleanupIncomplete: true }, + }); + expect(client.operationHealthSnapshot()).toMatchObject({ + state: "unknown", + }); const closed = await client.close(); if (!closed.ok) throw closed.error; + expect(client.operationHealthSnapshot()).toBeUndefined(); expect( close.mock.contexts.filter((context) => context === owner), - ).toHaveLength(2); + ).toHaveLength(3); } finally { close.mockRestore(); } @@ -591,11 +600,15 @@ describe("GhidraClient runtime removal failures", () => { }, }); await expect(access(runtimeRoot)).resolves.toBeUndefined(); + expect(client.operationHealthSnapshot()).toMatchObject({ + state: "unknown", + }); } finally { await chmod(runtimeRoot, 0o700); } await expect(client.close()).resolves.toEqual({ ok: true, value: null }); + expect(client.operationHealthSnapshot()).toBeUndefined(); await expect(access(runtimeRoot)).rejects.toMatchObject({ code: "ENOENT", }); @@ -626,16 +639,21 @@ describe("GhidraClient cleanup and diagnostics", () => { const client = clientFor(launcher, { runId: "11111111-1111-4111-8111-111111111111", }); + expect(client.operationHealthSnapshot()).toBeUndefined(); await expect(client.start()).resolves.toMatchObject({ ok: true }); + expect(client.operationHealthSnapshot()).toBeUndefined(); + const child = await launcher.started; await client.close(); + expect(client.operationHealthSnapshot()).toBeUndefined(); expect(client.diagnostics()).toMatchObject({ runtime_root: launcher.runtimeRoots[0], transport: HOST_TRANSPORT, endpoint_path: launcher.endpointPaths[0], project_root: join(launcher.runtimeRoots[0] ?? "", "project"), process_id: expect.any(Number), + exit_code: child.exitCode, run_id: "11111111-1111-4111-8111-111111111111", }); await expect(access(launcher.runtimeRoots[0] ?? "")).rejects.toMatchObject({ diff --git a/tests/fixtures/fakeGhidra.mjs b/tests/fixtures/fakeGhidra.mjs index 92f6980012..b08410793b 100644 --- a/tests/fixtures/fakeGhidra.mjs +++ b/tests/fixtures/fakeGhidra.mjs @@ -31,7 +31,10 @@ if (mode === "exit") process.exit(73); if (mode === "silent") setInterval(() => undefined, 1_000); else { await rm(endpointPath, { force: true }); + const sockets = new Set(); const server = createServer((socket) => { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); socket.setEncoding("utf8"); const state = { buffer: { value: "" }, @@ -45,6 +48,11 @@ else { }; socket.on("data", (chunk) => onSocketData(socket, server, chunk, state)); }); + process.on("message", (message) => { + if (message !== "disconnect") return; + for (const socket of sockets) socket.destroy(); + process.send?.({ event: "disconnected" }); + }); if (transport === "unix-socket") server.listen(endpointPath); else { server.listen(0, "127.0.0.1", async () => {