[GH-ISSUE #1719] [Bug] open_binary silently short-circuits without starting a real provider client #575

Closed
opened 2026-10-15 18:26:20 +00:00 by bsakaguchi · 3 comments
Owner

Originally created by @paulhothersall on GitHub (Oct 11, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/1719

Area

MCP or agent integration

Observed behavior

Calling open_binary a second time for the same target path+profile within one MCP session returns success in <1s without ever invoking the real Ghidra provider startup. This happens whether or not snapshot_path is passed on the second call, and was observed even after an explicit close_binary in between. Every subsequent query tool (search_strings, list_documents, search_procedures) then fails against the un-started client.

Reproduction

  1. Start the MCP server, call open_binary with snapshot_path set to an existing snapshot for a large target (triggers the sameTarget/sameProfile fast path in BinarySession#open, importing from the snapshot).
  2. Call open_binary again on the same path, with or without snapshot_path.
  3. Observe it returns in <1s (too fast for a real cold Ghidra import on a 25MB target — confirmed independently taking ~21 minutes via plain CLI on the same target/machine).
  4. Call any query tool (e.g. search_strings) — it hangs and fails.

Expected behavior

open_binary should only short-circuit when a real, currently-live provider client actually backs the active session — not merely because the recorded target+profile match. If the previous client was never truly started (or has since died/been torn down), a new open_binary call should start a real one.

Impact and scope

Affects any MCP client workflow that re-opens the same target more than once in a session (including after close_binary), or that uses snapshot_path. Makes multi-round interactive sessions against one binary unreliable — the documented advantage of MCP mode over CLI (one import, many queries) silently breaks. Does not appear to affect a single open+query-once-then-exit session.

REA version

6.3.0 (npm); also reproduced against main at 603d04e

Runtime and platform

Node (spawned via a thin stdio proxy in front of rea mcp), Linux x86_64 (Ubuntu), Ghidra 12.1.4, JDK 21

Target and artifact context

PE/x86, ~25MB Borland Delphi executable

Interface

MCP

Earliest failure stage

Setup or provider admission

Relevant output or evidence

Second open_binary call (with snapshot_path), captured via the calling MCP client's own request log: completed in 0.049s and 0.201s on two separate attempts, returning a normal success payload (path, format, kind, sha256, architecture), with no corresponding Ghidra/Java process ever appearing in ps. Every subsequent query tool call in the same session failed at a client-side timeout (see companion issue re: that timeout's length), and ps/application.log confirmed no Ghidra process had started.

Analysis or suspected cause

Hypothesis, not confirmed beyond reading the function: in src/application/binary/BinarySession.ts, #open()'s sameTarget && sameProfile branch returns ok(target) immediately -- including importing a supplied snapshot via importAnalysisSnapshot -- without ever calling client.execute("health") -> client.start(). This looks correct for a genuinely still-live session, but nothing in that branch appears to verify the existing client is actually alive/connected before taking it.

Additional context

Suggested regression test: open_binary with snapshot_path, then kill/close the underlying provider process out from under the session, then open_binary again on the same target -- assert it starts a real client rather than short-circuiting.

Originally created by @paulhothersall on GitHub (Oct 11, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/1719 ### Area MCP or agent integration ### Observed behavior Calling `open_binary` a second time for the same target path+profile within one MCP session returns success in <1s without ever invoking the real Ghidra provider startup. This happens whether or not `snapshot_path` is passed on the second call, and was observed even after an explicit `close_binary` in between. Every subsequent query tool (`search_strings`, `list_documents`, `search_procedures`) then fails against the un-started client. ### Reproduction 1. Start the MCP server, call `open_binary` with `snapshot_path` set to an existing snapshot for a large target (triggers the `sameTarget`/`sameProfile` fast path in `BinarySession#open`, importing from the snapshot). 2. Call `open_binary` again on the same path, with or without `snapshot_path`. 3. Observe it returns in <1s (too fast for a real cold Ghidra import on a 25MB target — confirmed independently taking ~21 minutes via plain CLI on the same target/machine). 4. Call any query tool (e.g. `search_strings`) — it hangs and fails. ### Expected behavior `open_binary` should only short-circuit when a real, currently-live provider client actually backs the active session — not merely because the recorded target+profile match. If the previous client was never truly started (or has since died/been torn down), a new `open_binary` call should start a real one. ### Impact and scope Affects any MCP client workflow that re-opens the same target more than once in a session (including after `close_binary`), or that uses `snapshot_path`. Makes multi-round interactive sessions against one binary unreliable — the documented advantage of MCP mode over CLI (one import, many queries) silently breaks. Does not appear to affect a single open+query-once-then-exit session. ### REA version 6.3.0 (npm); also reproduced against `main` at 603d04e ### Runtime and platform Node (spawned via a thin stdio proxy in front of `rea mcp`), Linux x86_64 (Ubuntu), Ghidra 12.1.4, JDK 21 ### Target and artifact context PE/x86, ~25MB Borland Delphi executable ### Interface MCP ### Earliest failure stage Setup or provider admission ### Relevant output or evidence Second `open_binary` call (with `snapshot_path`), captured via the calling MCP client's own request log: completed in 0.049s and 0.201s on two separate attempts, returning a normal success payload (`path`, `format`, `kind`, `sha256`, `architecture`), with no corresponding Ghidra/Java process ever appearing in `ps`. Every subsequent query tool call in the same session failed at a client-side timeout (see companion issue re: that timeout's length), and `ps`/`application.log` confirmed no Ghidra process had started. ### Analysis or suspected cause Hypothesis, not confirmed beyond reading the function: in `src/application/binary/BinarySession.ts`, `#open()`'s `sameTarget && sameProfile` branch returns `ok(target)` immediately -- including importing a supplied snapshot via `importAnalysisSnapshot` -- without ever calling `client.execute("health")` -> `client.start()`. This looks correct for a genuinely still-live session, but nothing in that branch appears to verify the existing client is actually alive/connected before taking it. ### Additional context Suggested regression test: `open_binary` with `snapshot_path`, then kill/close the underlying provider process out from under the session, then `open_binary` again on the same target -- assert it starts a real client rather than short-circuiting.
Author
Owner

@CassieFox23 commented on GitHub (Oct 11, 2026):

TL;DR: On Linux main, no open_binary starts Ghidra, first call or second. The first query does, and queries work when the client waits. The 60 seconds in #1720 came from my test client's own timer, not from REA.

  • Second open_binary: 0.00 to 0.02 s, with or without snapshot_path, and also right after close_binary. The very first open_binary took 0.08 s and started no Java.
  • First query after an open: about 81 s, which is the real Ghidra import. Later queries took about 0.04 s. With a 2 minute client timer, none hung or failed.
  • #1720: with a 60 s client timer, list_documents failed at 60.00 s with -32001, twice. With a 30 minute timer, list_documents finished in 76 s.

Not run

REA 6.3.0 from npm, main at 603d04e, and the reporter's 25 MB PE file. My target was jq 1.7.1 (MIT), a 2.3 MB ELF.

Thanks, paulhothersall, for the two reports.

Reading of the code (not a test)
  • The composite provider answers health with ok at once. It never calls the Ghidra client.
  • BinarySession#open only calls health (line 279). So no open_binary starts Ghidra on this main. The first query does.
  • The fast path is at line 250 of BinarySession. It checks sameTarget && sameProfile.
  • sameTarget compares the open target's path and snapshot identity.
  • sameProfile compares the open analysis profile.
  • Nothing checks that a provider is alive.
  • close_binary clears the active target, so a later open_binary takes the slow path. It still returns fast, for the health reason above.
  • No 60000 or 60_000 sits in the session or Ghidra code. The hits are adb, native, browser and Android code.
  • The MCP client SDK sets DEFAULT_REQUEST_TIMEOUT_MSEC = 60000. REA's package.json pins that client package at 2.3.1. I cannot see which client the reporter used.
  • On the one call where I asked for progress, REA sent two messages, "started" and "completed", none in between.
  • GhidraClient.callTool and the health path both call the same start(). It uses the configured startup timeout whichever tool calls it.
  • From 603d04e to e36c82bec, CompositeProvider.ts and LazyAnalysisProvider.ts did not change. BinarySession.ts, BinarySessionOpen.ts and SessionProviderRouter.ts changed only in import type lines. So the open and health code is the same on 603d04e, by reading.
Results by call (one server session per row group)

Paths are what the code does, by reading. The client cannot tell them apart.

Call Path Client time
A1 first open slow path 0.08 s
A2 open again, no snapshot fast path 0.00 s
list_documents starts Ghidra 81.64 s
search_strings, search_procedures Ghidra running 0.04 s each
close_binary with snapshot_path 0.47 s
B1 open with snapshot_path, after close slow path 0.02 s
B2 open again with snapshot_path fast path 0.01 s
B3 open again, no snapshot fast path 0.01 s
list_documents starts Ghidra again 80.59 s
C1 open after plain close slow path 0.02 s
C2 open again fast path 0.00 s
list_documents starts Ghidra again 80.84 s
D1, D2 open then close, no query slow path 0.01 s, 0.00 s

Ghidra did not start for D1 or D2. The server log shows a Ghidra ping only before a query.

For #1720, in one fresh session:

Client timer Result Java after
60 s -32001 at 60.00 s gone 5 s later
60 s -32001 at 60.00 s gone after the next call
30 min ok at 76.18 s running

The server logged the two failed calls as errors at 60.58 s, after my client cancelled them. The Java process was gone 5 s after the first cancel. A new one started for the next query, so a 60 s retry likely starts the import over.

Commands
git -C ~/code/rea-mirror/rea.git fetch origin
git -C ~/code/rea-mirror/rea.git archive e36c82becdd613787bcc59903f8b5fe14eb2f105 | tar -x -C /tmp/i1719/src
cd /tmp/i1719/src && npm ci && npm run build
gh release download jq-1.7.1 --repo jqlang/jq -p jq-linux-amd64
sha256sum jq-linux-amd64   # 5942c9b0934e510ee61eb3e30273f1b3fe2590df93933a93d7c58b81d19c8ff5
export GHIDRA_INSTALL_DIR=~/.local/opt/ghidra_12.1.4_PUBLIC JAVA_HOME=~/.local/opt/jdk-21.0.12.1+1
export REA_GHIDRA_STARTUP_TIMEOUT_MS=2700000 REA_ANALYSIS_PROVIDER=ghidra
node scripts/rea.mjs mcp   # driven over stdio by a small JSON-RPC script

The script sends initialize, then tools/call requests, each with its own client timer. On expiry it sends notifications/cancelled, as MCP clients do. Calls: open_binary (path, optional snapshot_path), list_documents, search_strings with usage, search_procedures with main, and close_binary (optional snapshot_path).

Test setup
  • Box: Nobara 44 (Fedora 44 base), Linux 7.2.9
  • CPU: AMD Ryzen 9 5900X, 12 cores / 24 threads
  • RAM: 32 GB DDR4-3600
  • Node: 24.18.0
  • Head / base: e36c82bec (REA 6.4.0) / none; main only
  • Other: Ghidra 12.1.4, JDK 21.0.12.1, jq 1.7.1 (MIT)
  • The box was shared with other test agents. The load average (busy tasks, out of 24 threads) was 3.3 to 4.7.

🤖 Generated with Claude Code (Sonnet 5.5, verified by Opus 5.5) 🦊

<!-- gh-comment-id:6106496600 --> @CassieFox23 commented on GitHub (Oct 11, 2026): **TL;DR:** On Linux main, no `open_binary` starts Ghidra, first call or second. The first query does, and queries work when the client waits. The 60 seconds in #1720 came from my test client's own timer, not from REA. - **Second `open_binary`:** 0.00 to 0.02 s, with or without `snapshot_path`, and also right after `close_binary`. The very first `open_binary` took 0.08 s and started no Java. - **First query after an open:** about 81 s, which is the real Ghidra import. Later queries took about 0.04 s. With a 2 minute client timer, none hung or failed. - **#1720:** with a 60 s client timer, `list_documents` failed at 60.00 s with `-32001`, twice. With a 30 minute timer, `list_documents` finished in 76 s. ### Not run REA 6.3.0 from npm, main at 603d04e, and the reporter's 25 MB PE file. My target was jq 1.7.1 (MIT), a 2.3 MB ELF. Thanks, paulhothersall, for the two reports. <details> <summary>Reading of the code (not a test)</summary> - The composite provider answers `health` with `ok` at once. It never calls the Ghidra client. - `BinarySession#open` only calls `health` (line 279). So no `open_binary` starts Ghidra on this main. The first query does. - The fast path is at line 250 of `BinarySession`. It checks `sameTarget && sameProfile`. - `sameTarget` compares the open target's path and snapshot identity. - `sameProfile` compares the open analysis profile. - Nothing checks that a provider is alive. - `close_binary` clears the active target, so a later `open_binary` takes the slow path. It still returns fast, for the `health` reason above. - No `60000` or `60_000` sits in the session or Ghidra code. The hits are adb, native, browser and Android code. - The MCP client SDK sets `DEFAULT_REQUEST_TIMEOUT_MSEC = 60000`. REA's `package.json` pins that client package at 2.3.1. I cannot see which client the reporter used. - On the one call where I asked for progress, REA sent two messages, "started" and "completed", none in between. - `GhidraClient.callTool` and the `health` path both call the same `start()`. It uses the configured startup timeout whichever tool calls it. - From 603d04e to e36c82bec, `CompositeProvider.ts` and `LazyAnalysisProvider.ts` did not change. `BinarySession.ts`, `BinarySessionOpen.ts` and `SessionProviderRouter.ts` changed only in `import type` lines. So the open and `health` code is the same on 603d04e, by reading. </details> <details> <summary>Results by call (one server session per row group)</summary> Paths are what the code does, by reading. The client cannot tell them apart. | Call | Path | Client time | |---|---|---| | A1 first open | slow path | 0.08 s | | A2 open again, no snapshot | fast path | 0.00 s | | `list_documents` | starts Ghidra | 81.64 s | | `search_strings`, `search_procedures` | Ghidra running | 0.04 s each | | `close_binary` with `snapshot_path` | | 0.47 s | | B1 open with `snapshot_path`, after close | slow path | 0.02 s | | B2 open again with `snapshot_path` | fast path | 0.01 s | | B3 open again, no snapshot | fast path | 0.01 s | | `list_documents` | starts Ghidra again | 80.59 s | | C1 open after plain close | slow path | 0.02 s | | C2 open again | fast path | 0.00 s | | `list_documents` | starts Ghidra again | 80.84 s | | D1, D2 open then close, no query | slow path | 0.01 s, 0.00 s | Ghidra did not start for D1 or D2. The server log shows a Ghidra `ping` only before a query. For #1720, in one fresh session: | Client timer | Result | Java after | |---|---|---| | 60 s | `-32001` at 60.00 s | gone 5 s later | | 60 s | `-32001` at 60.00 s | gone after the next call | | 30 min | ok at 76.18 s | running | The server logged the two failed calls as errors at 60.58 s, after my client cancelled them. The Java process was gone 5 s after the first cancel. A new one started for the next query, so a 60 s retry likely starts the import over. </details> <details> <summary>Commands</summary> ```bash git -C ~/code/rea-mirror/rea.git fetch origin git -C ~/code/rea-mirror/rea.git archive e36c82becdd613787bcc59903f8b5fe14eb2f105 | tar -x -C /tmp/i1719/src cd /tmp/i1719/src && npm ci && npm run build gh release download jq-1.7.1 --repo jqlang/jq -p jq-linux-amd64 sha256sum jq-linux-amd64 # 5942c9b0934e510ee61eb3e30273f1b3fe2590df93933a93d7c58b81d19c8ff5 export GHIDRA_INSTALL_DIR=~/.local/opt/ghidra_12.1.4_PUBLIC JAVA_HOME=~/.local/opt/jdk-21.0.12.1+1 export REA_GHIDRA_STARTUP_TIMEOUT_MS=2700000 REA_ANALYSIS_PROVIDER=ghidra node scripts/rea.mjs mcp # driven over stdio by a small JSON-RPC script ``` The script sends `initialize`, then `tools/call` requests, each with its own client timer. On expiry it sends `notifications/cancelled`, as MCP clients do. Calls: `open_binary` (path, optional `snapshot_path`), `list_documents`, `search_strings` with `usage`, `search_procedures` with `main`, and `close_binary` (optional `snapshot_path`). </details> <details> <summary>Test setup</summary> - Box: Nobara 44 (Fedora 44 base), Linux 7.2.9 - CPU: AMD Ryzen 9 5900X, 12 cores / 24 threads - RAM: 32 GB DDR4-3600 - Node: 24.18.0 - Head / base: e36c82bec (REA 6.4.0) / none; main only - Other: Ghidra 12.1.4, JDK 21.0.12.1, jq 1.7.1 (MIT) - The box was shared with other test agents. The load average (busy tasks, out of 24 threads) was 3.3 to 4.7. </details> 🤖 Generated with Claude Code (Sonnet 5.5, verified by Opus 5.5) 🦊
Author
Owner

@RishTheFish21 commented on GitHub (Oct 13, 2026):

Re-tested on current main (ebaaa79ca) with real Ghidra 12.1.4, JDK 21, macOS arm64 (Apple Silicon), driving rea mcp over stdio with a small cc -O0 -g Mach-O. I killed the Ghidra JVM (SIGKILL) at set points:

Scenario Result
open → first query → second query ok. Ghidra starts on the first query (~5 s), not on open_binary
kill Ghidra → open_binary same target → query ok. Fresh JVM started (#2014 path)
close_binary with snapshot_path → open_binary with snapshot_path → query → second open_binary → query ok
kill Ghidra → open_binary with snapshot_path → query ok. Fresh JVM started
kill Ghidra → query without reopening fails

So the reopen and snapshot_path paths from the original report look fixed by e59db2f and #2014 on real Ghidra.

The remaining case: after Ghidra dies, a query without a reopen returns

execution_failure: "Analysis could not complete. Run rea doctor and review the reported details; repeating the unchanged request is not expected to succeed."

That advice is misleading. Nothing is wrong with the installation, and a plain open_binary on the same target recovers immediately; no close_binary was needed.

From reading the code (not tested beyond the run above): GhidraClient.operationHealthSnapshot() already reports state: "exited" with retryAction: "restart_provider", but the query failure is mapped to a generic ProviderAdapterError. Hopper has a dedicated path for this (HopperProcessError → "Restart the owned provider…" in analysisErrorPresentation.ts), and Ghidra has none.

I can send a small PR so a query on an exited or unreachable Ghidra session says the provider exited and to call open_binary again, instead of pointing at rea doctor.

<!-- gh-comment-id:6151272701 --> @RishTheFish21 commented on GitHub (Oct 13, 2026): Re-tested on current `main` (`ebaaa79ca`) with real Ghidra 12.1.4, JDK 21, macOS arm64 (Apple Silicon), driving `rea mcp` over stdio with a small `cc -O0 -g` Mach-O. I killed the Ghidra JVM (SIGKILL) at set points: | Scenario | Result | |---|---| | open → first query → second query | ok. Ghidra starts on the first query (~5 s), not on `open_binary` | | kill Ghidra → `open_binary` same target → query | ok. Fresh JVM started (#2014 path) | | `close_binary` with `snapshot_path` → `open_binary` with `snapshot_path` → query → second `open_binary` → query | ok | | kill Ghidra → `open_binary` with `snapshot_path` → query | ok. Fresh JVM started | | kill Ghidra → query **without** reopening | **fails** | So the reopen and `snapshot_path` paths from the original report look fixed by `e59db2f` and #2014 on real Ghidra. The remaining case: after Ghidra dies, a query without a reopen returns > `execution_failure`: "Analysis could not complete. Run `rea doctor` and review the reported details; repeating the unchanged request is not expected to succeed." That advice is misleading. Nothing is wrong with the installation, and a plain `open_binary` on the same target recovers immediately; no `close_binary` was needed. From reading the code (not tested beyond the run above): `GhidraClient.operationHealthSnapshot()` already reports `state: "exited"` with `retryAction: "restart_provider"`, but the query failure is mapped to a generic `ProviderAdapterError`. Hopper has a dedicated path for this (`HopperProcessError` → "Restart the owned provider…" in `analysisErrorPresentation.ts`), and Ghidra has none. I can send a small PR so a query on an exited or unreachable Ghidra session says the provider exited and to call `open_binary` again, instead of pointing at `rea doctor`.
Author
Owner

@morluto commented on GitHub (Oct 14, 2026):

Addressed on main in e59db2f5d.

Same-target session reopen verifies retained client liveness before the fast path.

Validation: Package verifier targetLifecycle=true; global gates pass.

Verification scope: No additional provider-specific external verification.

<!-- gh-comment-id:6158645670 --> @morluto commented on GitHub (Oct 14, 2026): Addressed on main in [e59db2f5d](https://github.com/morluto/rea/commit/e59db2f5d). Same-target session reopen verifies retained client liveness before the fast path. Validation: Package verifier targetLifecycle=true; global gates pass. Verification scope: No additional provider-specific external verification.
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#575
No description provided.