mirror of
https://github.com/morluto/rea
synced 2026-10-26 06:16:17 -04:00
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 <cursoragent@cursor.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<Record<string, JsonValue>> {
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -78,6 +78,15 @@ export type ProviderProcessLaunch =
|
||||
readonly cleanup?: () => Promise<ProcessCleanupResult>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<ProviderProcessExitObservation>();
|
||||
readonly #outputClose = deferred<void>();
|
||||
#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<ProviderProcessStopResult> | 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 {
|
||||
|
||||
@@ -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(), {
|
||||
|
||||
@@ -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<void>((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();
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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({
|
||||
|
||||
Vendored
+8
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user