[GH-ISSUE #1190] [Bug] Failed Ghidra runtime cleanup omits the leftover path, and a later close reports success while it remains #425

Closed
opened 2026-10-15 18:24:56 +00:00 by bsakaguchi · 0 comments
Owner

Originally created by @coygeek on GitHub (Oct 9, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/1190

Before submitting

  • I searched the open and closed issues for a related report.
  • I removed credentials, tokens, private paths, and sensitive target data.

Area

MCP or agent integration

Observed behavior

When removal of Ghidra's owned runtime directory fails, close_binary reports only a generic provider-client resource and loses the directory's cleanup state. After filesystem permission is restored, a second close returns success although the owned runtime directory and its contents remain. Server shutdown also leaves that directory behind. The result does not identify the runtime directory that the caller must repair, and the successful retry does not establish the promised resource cleanup.

The first close returned the same projection on both builds:

{
  "error": {
    "code": "cleanup_incomplete",
    "category": "execution_failure",
    "details": {
      "provider_id": "ghidra",
      "operation": "close_binary",
      "cleanup": "incomplete",
      "resources": ["provider-client"],
      "diagnostics": { "reason": "provider client close rejected unexpectedly" },
      "execution_failure": "execution_failure"
    }
  }
}

No runtime directory path or filesystem permission constraint appeared in that result. After restoring mode 0700, the second close returned {"result":null}. Directory inspection after that close and after client.close() still found the same rea-ghidra-* directory. Its remaining entries included bridge.sock, cache, config, data, ghidra.log, home, ownership.json, project, script.log, the target snapshot, and tmp. No owned headless Java process remained. The control without the permission fault left no rea-ghidra-* directory.

Reproduction

Use the baselines under Runtime or environment and a non-root account. Create a disposable thin arm64 executable and an isolated temporary parent:

export GHIDRA_INSTALL_DIR=/path/to/ghidra_12.1.4_PUBLIC
export JAVA_HOME=/path/to/jdk-21
export REA_MAIN=/path/to/rea-main
export PROBE_ROOT="$(mktemp -d /tmp/rea-lifecycle.XXXXXX)"
mkdir "$PROBE_ROOT/tmp"
cat > "$PROBE_ROOT/fixture.c" <<'EOF'
int main(void) { return 0; }
EOF
clang -g -O0 "$PROBE_ROOT/fixture.c" -o "$PROBE_ROOT/fixture"

Run this minimal client with VARIANT=release, then with VARIANT=main. It uses the SDK dependencies already present in the built checkout. It changes permissions only on the probe's own REA-created directory and restores them before the second close.

// Save as cleanup-probe.mjs in the disposable directory.
import { chmod, readdir, rm } from "node:fs/promises";
const main = process.env.REA_MAIN;
const root = process.env.PROBE_ROOT;
const { Client } = await import(`${main}/node_modules/@modelcontextprotocol/client/dist/index.mjs`);
const { StdioClientTransport } = await import(`${main}/node_modules/@modelcontextprotocol/client/dist/stdio.mjs`);
const release = process.env.VARIANT === "release";
const transport = new StdioClientTransport({
  command: "nice",
  args: ["-n", "10", ...(release
    ? ["npx", "-y", "rea-agents@6.1.0", "mcp"]
    : [process.execPath, `${main}/scripts/rea.mjs`, "mcp"])],
  cwd: root,
  env: { ...process.env, TMPDIR: `${root}/tmp` },
  stderr: "pipe",
});
const client = new Client({ name: "cleanup-probe", version: "1" });
const call = async (name, args = {}) => {
  const r = await client.callTool({ name, arguments: args }, { timeout: 390000 });
  const value = r.structuredContent ?? JSON.parse(r.content[0].text);
  console.log(name, JSON.stringify(value));
  return value;
};
let runtime;
try {
  await client.connect(transport);
  await call("open_binary", { path: `${root}/fixture`, provider_id: "ghidra" });
  await call("list_documents");
  runtime = `${root}/tmp/${(await readdir(`${root}/tmp`)).find(n => n.startsWith("rea-ghidra-"))}`;
  await chmod(runtime, 0o500);
  try { await call("close_binary"); }
  finally { await chmod(runtime, 0o700); }
  console.log("remaining contents", await readdir(runtime));
  await call("close_binary");
} finally {
  if (runtime) await chmod(runtime, 0o700);
  await client.close();
}
console.log("after shutdown", await readdir(`${root}/tmp`));
await rm(runtime, { recursive: true, force: true });
VARIANT=release node "$PROBE_ROOT/cleanup-probe.mjs"
VARIANT=main node "$PROBE_ROOT/cleanup-probe.mjs"

Both server variants were executed separately with an initially clean temporary parent. Normal close without the permission change is the control and removes the Ghidra runtime directory. The probe restores permissions and removes its own leftovers after collecting evidence.

Expected behavior

If owned runtime removal fails, return cleanup_incomplete that lists the owned resource kind that actually remains (the Ghidra runtime directory, not only a generic provider-client) and the failed constraint, so the caller can address it. The error's own text tells callers to review the reported local resources, and other Ghidra diagnostics already expose the runtime location as runtime_root, so including that location would make the recovery direct. Preserve enough cleanup state for a subsequent close to retry removal. After permissions are restored, a successful close or shutdown must remove the retained owned directory; otherwise it must continue to report incomplete cleanup rather than success.

Impact and scope

A bounded cleanup failure leaves the imported snapshot, caches, socket entry, and runtime files after a reported successful retry and shutdown, and the error reports only a generic provider-client resource and omits the remaining runtime directory needed for recovery. Normal cleanup passed, and no owned Java process remained in the fault case. Impact assessment: P2 (supported).

Workaround (available): An operator controlling the isolated temporary parent can inventory rea-ghidra-* directories, verify that the owned provider exited, restore permission, and remove the leftover manually. That manual cleanup was performed after the probes; REA's close retry itself did not remove the directory.

REA version

rea-agents@6.1.0 from npm, and main at b34bdcdb28 built locally with npm ci && npm run build:cached.

Runtime and platform

Tested against npm rea-agents@6.1.0 and the current main build at b34bdcdb2877be394c59c9cbf70269dc1841ff78, with Ghidra 12.1.4, Homebrew OpenJDK 21.0.12.1, Node 24.21.0, and macOS 27 arm64. The current account was non-root. Each server received an isolated TMPDIR; the fault was an actual OS permission denial on that server's own temporary directory, not a mocked provider.

Target and artifact context

Thin arm64 Mach-O C executable (Ghidra provider)

Interface

MCP

Earliest failure stage

Cleanup, cancellation, or lifecycle

Relevant output or evidence

  • Expected source: src/contracts/sessionToolContracts.ts, close_binary description, "every provider resource started for it"; docs/mcp-contracts.md:83, cleanup_incomplete lists the owned resource kinds that remain; docs/installation.md:493, ephemeral temporary-project cleanup; AGENTS.md, Boundary Contracts, diagnostics identify the failed constraint and lifecycle request.
  • Failure source: The inline MCP close results and post-close/post-shutdown directory inspections from the client probe, observed on both builds. src/ghidra/GhidraClient.ts:537 clears the retained runtime roots before their close() promises settle; src/application/binary/BinarySession.ts:344 clears the active binding before cleanup; src/application/binary/AnalysisClientCleanup.ts replaces the rejection with a generic provider-client error.
  • Evidence provenance: observed
  • Local verification: reproduced
  • Reproduction completeness: complete

Typed outcome or recovery expected

Repeat the real permission-fault probe. The first close must identify the remaining owned runtime-directory resource and the filesystem removal constraint, without claiming that Java remains when it has exited. Restore permissions and close again. A successful result must coincide with absence of that runtime directory and project; if deletion still fails, return cleanup_incomplete with the remaining resource. The normal-close control must still remove all provider directories and leave unrelated files untouched.

Analysis or suspected cause

No response

Additional context

No response

Originally created by @coygeek on GitHub (Oct 9, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/1190 ### Before submitting - [x] I searched the open and closed issues for a related report. - [x] I removed credentials, tokens, private paths, and sensitive target data. ### Area MCP or agent integration ### Observed behavior When removal of Ghidra's owned runtime directory fails, `close_binary` reports only a generic `provider-client` resource and loses the directory's cleanup state. After filesystem permission is restored, a second close returns success although the owned runtime directory and its contents remain. Server shutdown also leaves that directory behind. The result does not identify the runtime directory that the caller must repair, and the successful retry does not establish the promised resource cleanup. The first close returned the same projection on both builds: ```json { "error": { "code": "cleanup_incomplete", "category": "execution_failure", "details": { "provider_id": "ghidra", "operation": "close_binary", "cleanup": "incomplete", "resources": ["provider-client"], "diagnostics": { "reason": "provider client close rejected unexpectedly" }, "execution_failure": "execution_failure" } } } ``` No runtime directory path or filesystem permission constraint appeared in that result. After restoring mode 0700, the second close returned `{"result":null}`. Directory inspection after that close and after `client.close()` still found the same `rea-ghidra-*` directory. Its remaining entries included `bridge.sock`, `cache`, `config`, `data`, `ghidra.log`, `home`, `ownership.json`, `project`, `script.log`, the target snapshot, and `tmp`. No owned headless Java process remained. The control without the permission fault left no `rea-ghidra-*` directory. ### Reproduction Use the baselines under Runtime or environment and a non-root account. Create a disposable thin arm64 executable and an isolated temporary parent: ```sh export GHIDRA_INSTALL_DIR=/path/to/ghidra_12.1.4_PUBLIC export JAVA_HOME=/path/to/jdk-21 export REA_MAIN=/path/to/rea-main export PROBE_ROOT="$(mktemp -d /tmp/rea-lifecycle.XXXXXX)" mkdir "$PROBE_ROOT/tmp" cat > "$PROBE_ROOT/fixture.c" <<'EOF' int main(void) { return 0; } EOF clang -g -O0 "$PROBE_ROOT/fixture.c" -o "$PROBE_ROOT/fixture" ``` Run this minimal client with `VARIANT=release`, then with `VARIANT=main`. It uses the SDK dependencies already present in the built checkout. It changes permissions only on the probe's own REA-created directory and restores them before the second close. ```js // Save as cleanup-probe.mjs in the disposable directory. import { chmod, readdir, rm } from "node:fs/promises"; const main = process.env.REA_MAIN; const root = process.env.PROBE_ROOT; const { Client } = await import(`${main}/node_modules/@modelcontextprotocol/client/dist/index.mjs`); const { StdioClientTransport } = await import(`${main}/node_modules/@modelcontextprotocol/client/dist/stdio.mjs`); const release = process.env.VARIANT === "release"; const transport = new StdioClientTransport({ command: "nice", args: ["-n", "10", ...(release ? ["npx", "-y", "rea-agents@6.1.0", "mcp"] : [process.execPath, `${main}/scripts/rea.mjs`, "mcp"])], cwd: root, env: { ...process.env, TMPDIR: `${root}/tmp` }, stderr: "pipe", }); const client = new Client({ name: "cleanup-probe", version: "1" }); const call = async (name, args = {}) => { const r = await client.callTool({ name, arguments: args }, { timeout: 390000 }); const value = r.structuredContent ?? JSON.parse(r.content[0].text); console.log(name, JSON.stringify(value)); return value; }; let runtime; try { await client.connect(transport); await call("open_binary", { path: `${root}/fixture`, provider_id: "ghidra" }); await call("list_documents"); runtime = `${root}/tmp/${(await readdir(`${root}/tmp`)).find(n => n.startsWith("rea-ghidra-"))}`; await chmod(runtime, 0o500); try { await call("close_binary"); } finally { await chmod(runtime, 0o700); } console.log("remaining contents", await readdir(runtime)); await call("close_binary"); } finally { if (runtime) await chmod(runtime, 0o700); await client.close(); } console.log("after shutdown", await readdir(`${root}/tmp`)); await rm(runtime, { recursive: true, force: true }); ``` ```sh VARIANT=release node "$PROBE_ROOT/cleanup-probe.mjs" VARIANT=main node "$PROBE_ROOT/cleanup-probe.mjs" ``` Both server variants were executed separately with an initially clean temporary parent. Normal close without the permission change is the control and removes the Ghidra runtime directory. The probe restores permissions and removes its own leftovers after collecting evidence. ### Expected behavior If owned runtime removal fails, return `cleanup_incomplete` that lists the owned resource kind that actually remains (the Ghidra runtime directory, not only a generic `provider-client`) and the failed constraint, so the caller can address it. The error's own text tells callers to review the reported local resources, and other Ghidra diagnostics already expose the runtime location as `runtime_root`, so including that location would make the recovery direct. Preserve enough cleanup state for a subsequent close to retry removal. After permissions are restored, a successful close or shutdown must remove the retained owned directory; otherwise it must continue to report incomplete cleanup rather than success. ### Impact and scope A bounded cleanup failure leaves the imported snapshot, caches, socket entry, and runtime files after a reported successful retry and shutdown, and the error reports only a generic `provider-client` resource and omits the remaining runtime directory needed for recovery. Normal cleanup passed, and no owned Java process remained in the fault case. Impact assessment: P2 (supported). Workaround (available): An operator controlling the isolated temporary parent can inventory `rea-ghidra-*` directories, verify that the owned provider exited, restore permission, and remove the leftover manually. That manual cleanup was performed after the probes; REA's close retry itself did not remove the directory. ### REA version `rea-agents@6.1.0` from npm, and `main` at b34bdcdb2877be394c59c9cbf70269dc1841ff78 built locally with `npm ci && npm run build:cached`. ### Runtime and platform Tested against npm `rea-agents@6.1.0` and the current main build at `b34bdcdb2877be394c59c9cbf70269dc1841ff78`, with Ghidra 12.1.4, Homebrew OpenJDK 21.0.12.1, Node 24.21.0, and macOS 27 arm64. The current account was non-root. Each server received an isolated `TMPDIR`; the fault was an actual OS permission denial on that server's own temporary directory, not a mocked provider. ### Target and artifact context Thin arm64 Mach-O C executable (Ghidra provider) ### Interface MCP ### Earliest failure stage Cleanup, cancellation, or lifecycle ### Relevant output or evidence - Expected source: `src/contracts/sessionToolContracts.ts`, `close_binary` description, "every provider resource started for it"; `docs/mcp-contracts.md:83`, `cleanup_incomplete` lists the owned resource kinds that remain; `docs/installation.md:493`, ephemeral temporary-project cleanup; `AGENTS.md`, Boundary Contracts, diagnostics identify the failed constraint and lifecycle request. - Failure source: The inline MCP close results and post-close/post-shutdown directory inspections from the client probe, observed on both builds. `src/ghidra/GhidraClient.ts:537` clears the retained runtime roots before their `close()` promises settle; `src/application/binary/BinarySession.ts:344` clears the active binding before cleanup; `src/application/binary/AnalysisClientCleanup.ts` replaces the rejection with a generic `provider-client` error. - Evidence provenance: observed - Local verification: reproduced - Reproduction completeness: complete ### Typed outcome or recovery expected Repeat the real permission-fault probe. The first close must identify the remaining owned runtime-directory resource and the filesystem removal constraint, without claiming that Java remains when it has exited. Restore permissions and close again. A successful result must coincide with absence of that runtime directory and project; if deletion still fails, return `cleanup_incomplete` with the remaining resource. The normal-close control must still remove all provider directories and leave unrelated files untouched. ### Analysis or suspected cause _No response_ ### Additional context _No response_
bsakaguchi 2026-10-15 18:24:56 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#425
No description provided.