[GH-ISSUE #6133] [Bug] Ghidra catalog close failures permanently block registry admission #3761

Closed
opened 2026-10-31 00:43:46 +00:00 by bsakaguchi · 3 comments
Owner

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

Originally assigned to: @kaoru0822-kitauji on GitHub.

Before submitting

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

Area

Provider or capability discovery

Observed behavior

A retryable close failure for a Ghidra .ldefs file during profile selection leaves the registry unable to admit subsequent open_binary requests. After the injected close failure is disabled, open_binary, close_binary, and another open_binary still return cleanup_incomplete; none retries the retained handle. A direct catalog read releases that handle, but the original registry continues reporting incomplete cleanup. A fresh provider/registry selects the same target successfully in the same process.

This was executed through registered MCP requests with production configuration, target parsing, provider selection, session routing and SDK in-memory transport. Only installation readiness and the file-close fault are simulated; no Java or Ghidra engine process runs. Requests settle with errors; this is a persistent admission lockout, not a pending-promise hang.

Reproduction

With the pinned toolchain and locked dependencies, I ran npm ci, npm run build:cached, and the single focused test below. Save it as tests/boundary/mcp/catalogCloseRecovery.test.ts and run:

mkdir -p repro-data
CATALOG_REPRO_ROOT="$PWD/repro-data" node node_modules/vitest/vitest.mjs run tests/boundary/mcp/catalogCloseRecovery.test.ts --maxWorkers=1

The test first confirms healthy MCP open/close. It then rejects the exact language-file handle's close with synthetic EIO while its real descriptor remains open, removes the rejection, and observes failed recovery. The direct provider/catalog calls isolate ownership and distinguish release from recovery. Assertions characterize the current defect. One focused execution passed all assertions; the full suite was not run.

import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { expect, it, vi } from "vitest";
import { AnalysisProviderRegistry } from "../../../src/application/binary/AnalysisProviderRegistry.js";
import { composeBinarySession } from "../../../src/application/binary/BinarySessionComposition.js";
import { parseConfig } from "../../../src/config/parseConfig.js";
import { ghidraProfileInstallationHost } from "../../../src/ghidra/GhidraInstallation.fixture.js";
import { readGhidraLanguageCatalog } from "../../../src/ghidra/GhidraLanguageCatalog.js";
import { GhidraProvider } from "../../../src/ghidra/GhidraProvider.js";
import { silentLogger } from "../../../src/logger.js";
import { createServer } from "../../../src/server/createServer.js";
import { nativeAotPeFixture } from "../../fixtures/nativeaotPe.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";

const fault = vi.hoisted(() => ({ deny: false, calls: 0, enabled: false }));
vi.mock("node:fs/promises", async (importOriginal) => {
  const actual = await importOriginal<typeof import("node:fs/promises")>();
  return {
    ...actual,
    open: async (...args: Parameters<typeof actual.open>) => {
      const handle = await actual.open(...args);
      if (fault.enabled && String(args[0]).endsWith("/x86.ldefs")) {
        const close = handle.close.bind(handle);
        handle.close = async () => {
          fault.calls++;
          if (fault.deny) throw Object.assign(new Error("injected close EIO"), { code: "EIO" });
          return close();
        };
      }
      return handle;
    },
  };
});

it("cannot recover public admission after a retryable catalog close failure", async () => {
  const root = await mkdtemp(join(process.env.CATALOG_REPRO_ROOT!, "case-"));
  const languages = join(root, "Ghidra/Processors/x86/data/languages");
  const target = join(root, "fixture.exe");
  await mkdir(languages, { recursive: true });
  await writeFile(join(languages, "x86.ldefs"), '<language_definitions><language id="x86:LE:64:default"><compiler id="windows"/></language></language_definitions>');
  await writeFile(target, nativeAotPeFixture());
  const config = parseConfig({ GHIDRA_INSTALL_DIR: root, REA_GHIDRA_LANGUAGE_ID: "x86:LE:64:default" });
  if (!config.ok) throw config.error;
  let clients = 0;
  const provider = new GhidraProvider(config.value, silentLogger, {}, ghidraProfileInstallationHost, () => {
    clients++; throw new Error("unexpected engine startup");
  });
  const registry = new AnalysisProviderRegistry([provider]);
  const session = composeBinarySession(registry);
  const server = createServer({ kind: "session", session }, { logger: silentLogger });
  const client = new Client({ name: "catalog-close-reproduction", version: "1.0.0" });
  const [ct, st] = InMemoryTransport.createLinkedPair();
  const call = (name: string) => client.callTool({ name, arguments: name === "open_binary" ? { path: target, provider_id: "ghidra" } : {} });
  const report = (step: string, value: unknown) => console.log(step, JSON.stringify(value).split(root).join("<fixture>"));
  try {
    await server.connect(st); await client.connect(ct);
    expect((await call("open_binary")).isError).not.toBe(true);
    expect((await call("close_binary")).isError).not.toBe(true);
    fault.enabled = true; fault.deny = true;
    const first = parseMcpToolError(await call("open_binary"));
    expect(first.error).toMatchObject({ code: "cleanup_incomplete", details: { cleanup: "incomplete" } });
    expect(registry.hasPendingOwnedResources()).toBe(true);
    expect(fault.calls).toBe(1);
    report("failed-open", first);
    expect(provider.hasPendingOwnedResources()).toBe(false);
    const providerClosed = await provider.closeOwnedResources();
    expect(providerClosed.ok).toBe(true); expect(fault.calls).toBe(1);
    report("provider-close", { result: providerClosed, closeCalls: fault.calls, providerPending: provider.hasPendingOwnedResources(), registryPending: registry.hasPendingOwnedResources() });
    fault.deny = false;
    for (const name of ["open_binary", "close_binary", "open_binary"]) {
      const error = parseMcpToolError(await call(name));
      expect(error.error).toMatchObject({ code: "cleanup_incomplete", details: { cleanup: "incomplete" } });
      expect(registry.hasPendingOwnedResources()).toBe(true);
      expect(fault.calls).toBe(1);
      report(name, { error, closeCalls: fault.calls });
    }
    fault.enabled = false;
    expect(await readGhidraLanguageCatalog(root)).toEqual([{ id: "x86:LE:64:default", compilerSpecIds: ["windows"] }]);
    expect(fault.calls).toBe(2);
    const stale = parseMcpToolError(await call("open_binary"));
    expect(stale.error).toMatchObject({ code: "cleanup_incomplete", details: { cleanup: "incomplete" } });
    expect(registry.hasPendingOwnedResources()).toBe(true);
    const stillBlocked = await registry.closeOwnedResources();
    expect(stillBlocked.ok).toBe(false);
    if (!stillBlocked.ok) expect(stillBlocked.error.cleanupIncomplete).toBe(true);
    report("after-exact-owner-release", { error: stale, closeCalls: fault.calls, pending: registry.hasPendingOwnedResources() });
    const freshProvider = new GhidraProvider(config.value, silentLogger, {}, ghidraProfileInstallationHost);
    const freshRegistry = new AnalysisProviderRegistry([freshProvider]);
    const selected = await freshRegistry.select({ path: target, sha256: "a".repeat(64), kind: "executable", format: "pe", architecture: "x86_64", availableArchitectures: ["x86_64"], executableRole: "application", managed: false }, "ghidra");
    expect(selected.ok).toBe(true);
    expect((await freshRegistry.closeOwnedResources()).ok).toBe(true);
    report("fresh-registry", { selected: selected.ok });
    expect(clients).toBe(0);
  } finally {
    fault.deny = false; fault.enabled = false;
    await readGhidraLanguageCatalog(root);
    await client.close(); await server.close(); await session.close();
    await rm(root, { recursive: true, force: true });
  }
});

Remove the test and empty repro-data directory afterward.

Expected behavior

Provider close and subsequent admission should retry the retained language-file owner. Once that exact owner confirms release, its original cleanup marker should resolve and the same registry should admit new work, while preserving the earlier diagnostic.

The existing descriptor ownership and provider-selection lifecycle contract requires retrying retained cleanup before new work, binding the originating error to its exact owner, and reopening admission after confirmed retry. Language override documentation establishes that configured language validation reads the installed .ldefs catalog.

Impact and scope

A close fault during configured language validation blocks later opens in the affected long-lived registry even after the fault clears. Provider close also reports success and no pending resources while the registry retains the catalog failure. Reconstruction is a demonstrated recovery at the provider/registry boundary; a complete server-process restart was not exercised. Affected requests returned typed errors promptly.

The fault was deliberately injected. Natural close-failure frequency, real Ghidra analysis, public status output, descriptor exhaustion and data loss were not tested. This is a bounded recovery failure with reconstruction available, assessed as P2.

REA version

01788bac86 (rea-agents 6.7.0)

Runtime and platform

Node.js 24.18.0; npm 11.16.0; macOS 27.0.1 arm64; locked Vitest 5.0.3. The repository's ghidraProfileInstallationHost simulates Linux x64 installation readiness for Ghidra 12.1.4 and Java 21.0.11. No actual engine was launched.

Target and artifact context

Synthetic native x86_64 PE from the repository's nativeAotPeFixture; real catalog XML defining x86:LE:64:default with compiler spec windows; configuration sets GHIDRA_INSTALL_DIR to the fixture root and REA_GHIDRA_LANGUAGE_ID=x86:LE:64:default.

Interface

MCP

Earliest failure stage

Cleanup, cancellation, or lifecycle

Relevant output or evidence

The focused execution produced these observations (fixture paths abbreviated):

healthy open_binary / close_binary: success
failed-open: code=cleanup_incomplete, category=execution_failure,
  details.cleanup=incomplete,
  resources=[<fixture>/Ghidra/Processors/x86/data/languages/x86.ldefs]
provider-close: result={"ok":true,"value":null}, closeCalls=1,
  providerPending=false, registryPending=true
fault disabled:
  open_binary: cleanup_incomplete, closeCalls=1
  close_binary: cleanup_incomplete, closeCalls=1
  open_binary: cleanup_incomplete, closeCalls=1
direct catalog read: expected language returned, closeCalls=2
after-exact-owner-release: open_binary still cleanup_incomplete, pending=true
fresh-registry: selected=true
Test Files  1 passed (1)
     Tests  1 passed (1)

The helper-call results and call counts come from the executed assertions and log; the healthy control is asserted before injection. The source explanation below is derived from the pinned implementation.

Typed outcome or recovery expected

Keep cleanup_incomplete while the exact catalog owner remains unresolved. Retry it on close/recovery, and clear the original error's live cleanup marker after confirmed release so the same registry can select again. A provider must not report clean resource release while its catalog handle remains retained.

Analysis or suspected cause

Source-derived ownership gap:

  • Catalog retention and retry keeps failed handles in a module-global set. Retry is reached through catalog reads.
  • Provider shutdown observes and retries only inspection-scope resources. Catalog error translation constructs a cleanup error without binding it to the retained file owner.
  • Registry close retains that still-incomplete error. Selection admission retries provider close before resolving another profile, so it cannot reach the catalog read that would retry the retained handle. The unbound error remains incomplete even after a separate catalog read confirms release.

Giving catalog reads a caller-owned cleanup capability and binding returned errors to it would let shutdown retry the owner and let confirmed release reopen admission.

Additional context

Closed, unmerged PR #2867 already discussed the same missing provider-close retry in this review comment; the focused test above confirms the recovery gap remains on the reported revision.

Related but distinct: #5972 concerns swallowed directory-read errors, #6031 concerns override-policy error classification, and #6104 concerns a session-observation cleanup aggregate after engine-client startup. This report concerns retained language-file ownership during profile selection, before engine startup. Open and closed issues/PRs were searched; no existing issue or open PR fixing this behavior was found. The first affected revision and a last known good version are NOT_ENOUGH_INFO; they were not established.

Originally created by @coygeek on GitHub (Oct 21, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/6133 Originally assigned to: @kaoru0822-kitauji on GitHub. ### 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 Provider or capability discovery ### Observed behavior A retryable close failure for a Ghidra `.ldefs` file during profile selection leaves the registry unable to admit subsequent `open_binary` requests. After the injected close failure is disabled, `open_binary`, `close_binary`, and another `open_binary` still return `cleanup_incomplete`; none retries the retained handle. A direct catalog read releases that handle, but the original registry continues reporting incomplete cleanup. A fresh provider/registry selects the same target successfully in the same process. This was executed through registered MCP requests with production configuration, target parsing, provider selection, session routing and SDK in-memory transport. Only installation readiness and the file-close fault are simulated; no Java or Ghidra engine process runs. Requests settle with errors; this is a persistent admission lockout, not a pending-promise hang. ### Reproduction With the pinned toolchain and locked dependencies, I ran `npm ci`, `npm run build:cached`, and the single focused test below. Save it as `tests/boundary/mcp/catalogCloseRecovery.test.ts` and run: ```sh mkdir -p repro-data CATALOG_REPRO_ROOT="$PWD/repro-data" node node_modules/vitest/vitest.mjs run tests/boundary/mcp/catalogCloseRecovery.test.ts --maxWorkers=1 ``` The test first confirms healthy MCP open/close. It then rejects the exact language-file handle's close with synthetic `EIO` while its real descriptor remains open, removes the rejection, and observes failed recovery. The direct provider/catalog calls isolate ownership and distinguish release from recovery. Assertions characterize the current defect. One focused execution passed all assertions; the full suite was not run. ```ts import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { expect, it, vi } from "vitest"; import { AnalysisProviderRegistry } from "../../../src/application/binary/AnalysisProviderRegistry.js"; import { composeBinarySession } from "../../../src/application/binary/BinarySessionComposition.js"; import { parseConfig } from "../../../src/config/parseConfig.js"; import { ghidraProfileInstallationHost } from "../../../src/ghidra/GhidraInstallation.fixture.js"; import { readGhidraLanguageCatalog } from "../../../src/ghidra/GhidraLanguageCatalog.js"; import { GhidraProvider } from "../../../src/ghidra/GhidraProvider.js"; import { silentLogger } from "../../../src/logger.js"; import { createServer } from "../../../src/server/createServer.js"; import { nativeAotPeFixture } from "../../fixtures/nativeaotPe.js"; import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; const fault = vi.hoisted(() => ({ deny: false, calls: 0, enabled: false })); vi.mock("node:fs/promises", async (importOriginal) => { const actual = await importOriginal<typeof import("node:fs/promises")>(); return { ...actual, open: async (...args: Parameters<typeof actual.open>) => { const handle = await actual.open(...args); if (fault.enabled && String(args[0]).endsWith("/x86.ldefs")) { const close = handle.close.bind(handle); handle.close = async () => { fault.calls++; if (fault.deny) throw Object.assign(new Error("injected close EIO"), { code: "EIO" }); return close(); }; } return handle; }, }; }); it("cannot recover public admission after a retryable catalog close failure", async () => { const root = await mkdtemp(join(process.env.CATALOG_REPRO_ROOT!, "case-")); const languages = join(root, "Ghidra/Processors/x86/data/languages"); const target = join(root, "fixture.exe"); await mkdir(languages, { recursive: true }); await writeFile(join(languages, "x86.ldefs"), '<language_definitions><language id="x86:LE:64:default"><compiler id="windows"/></language></language_definitions>'); await writeFile(target, nativeAotPeFixture()); const config = parseConfig({ GHIDRA_INSTALL_DIR: root, REA_GHIDRA_LANGUAGE_ID: "x86:LE:64:default" }); if (!config.ok) throw config.error; let clients = 0; const provider = new GhidraProvider(config.value, silentLogger, {}, ghidraProfileInstallationHost, () => { clients++; throw new Error("unexpected engine startup"); }); const registry = new AnalysisProviderRegistry([provider]); const session = composeBinarySession(registry); const server = createServer({ kind: "session", session }, { logger: silentLogger }); const client = new Client({ name: "catalog-close-reproduction", version: "1.0.0" }); const [ct, st] = InMemoryTransport.createLinkedPair(); const call = (name: string) => client.callTool({ name, arguments: name === "open_binary" ? { path: target, provider_id: "ghidra" } : {} }); const report = (step: string, value: unknown) => console.log(step, JSON.stringify(value).split(root).join("<fixture>")); try { await server.connect(st); await client.connect(ct); expect((await call("open_binary")).isError).not.toBe(true); expect((await call("close_binary")).isError).not.toBe(true); fault.enabled = true; fault.deny = true; const first = parseMcpToolError(await call("open_binary")); expect(first.error).toMatchObject({ code: "cleanup_incomplete", details: { cleanup: "incomplete" } }); expect(registry.hasPendingOwnedResources()).toBe(true); expect(fault.calls).toBe(1); report("failed-open", first); expect(provider.hasPendingOwnedResources()).toBe(false); const providerClosed = await provider.closeOwnedResources(); expect(providerClosed.ok).toBe(true); expect(fault.calls).toBe(1); report("provider-close", { result: providerClosed, closeCalls: fault.calls, providerPending: provider.hasPendingOwnedResources(), registryPending: registry.hasPendingOwnedResources() }); fault.deny = false; for (const name of ["open_binary", "close_binary", "open_binary"]) { const error = parseMcpToolError(await call(name)); expect(error.error).toMatchObject({ code: "cleanup_incomplete", details: { cleanup: "incomplete" } }); expect(registry.hasPendingOwnedResources()).toBe(true); expect(fault.calls).toBe(1); report(name, { error, closeCalls: fault.calls }); } fault.enabled = false; expect(await readGhidraLanguageCatalog(root)).toEqual([{ id: "x86:LE:64:default", compilerSpecIds: ["windows"] }]); expect(fault.calls).toBe(2); const stale = parseMcpToolError(await call("open_binary")); expect(stale.error).toMatchObject({ code: "cleanup_incomplete", details: { cleanup: "incomplete" } }); expect(registry.hasPendingOwnedResources()).toBe(true); const stillBlocked = await registry.closeOwnedResources(); expect(stillBlocked.ok).toBe(false); if (!stillBlocked.ok) expect(stillBlocked.error.cleanupIncomplete).toBe(true); report("after-exact-owner-release", { error: stale, closeCalls: fault.calls, pending: registry.hasPendingOwnedResources() }); const freshProvider = new GhidraProvider(config.value, silentLogger, {}, ghidraProfileInstallationHost); const freshRegistry = new AnalysisProviderRegistry([freshProvider]); const selected = await freshRegistry.select({ path: target, sha256: "a".repeat(64), kind: "executable", format: "pe", architecture: "x86_64", availableArchitectures: ["x86_64"], executableRole: "application", managed: false }, "ghidra"); expect(selected.ok).toBe(true); expect((await freshRegistry.closeOwnedResources()).ok).toBe(true); report("fresh-registry", { selected: selected.ok }); expect(clients).toBe(0); } finally { fault.deny = false; fault.enabled = false; await readGhidraLanguageCatalog(root); await client.close(); await server.close(); await session.close(); await rm(root, { recursive: true, force: true }); } }); ``` Remove the test and empty `repro-data` directory afterward. ### Expected behavior Provider close and subsequent admission should retry the retained language-file owner. Once that exact owner confirms release, its original cleanup marker should resolve and the same registry should admit new work, while preserving the earlier diagnostic. The existing [descriptor ownership and provider-selection lifecycle contract](https://github.com/morluto/rea/blob/01788bac863ac786bfbb648cad69b11e23b40a89/docs/tool-design.md#L137-L155) requires retrying retained cleanup before new work, binding the originating error to its exact owner, and reopening admission after confirmed retry. [Language override documentation](https://github.com/morluto/rea/blob/01788bac863ac786bfbb648cad69b11e23b40a89/docs/installation.md#L1077-L1088) establishes that configured language validation reads the installed `.ldefs` catalog. ### Impact and scope A close fault during configured language validation blocks later opens in the affected long-lived registry even after the fault clears. Provider close also reports success and no pending resources while the registry retains the catalog failure. Reconstruction is a demonstrated recovery at the provider/registry boundary; a complete server-process restart was not exercised. Affected requests returned typed errors promptly. The fault was deliberately injected. Natural close-failure frequency, real Ghidra analysis, public status output, descriptor exhaustion and data loss were not tested. This is a bounded recovery failure with reconstruction available, assessed as P2. ### REA version 01788bac863ac786bfbb648cad69b11e23b40a89 (rea-agents 6.7.0) ### Runtime and platform Node.js 24.18.0; npm 11.16.0; macOS 27.0.1 arm64; locked Vitest 5.0.3. The repository's `ghidraProfileInstallationHost` simulates Linux x64 installation readiness for Ghidra 12.1.4 and Java 21.0.11. No actual engine was launched. ### Target and artifact context Synthetic native x86_64 PE from the repository's `nativeAotPeFixture`; real catalog XML defining `x86:LE:64:default` with compiler spec `windows`; configuration sets `GHIDRA_INSTALL_DIR` to the fixture root and `REA_GHIDRA_LANGUAGE_ID=x86:LE:64:default`. ### Interface MCP ### Earliest failure stage Cleanup, cancellation, or lifecycle ### Relevant output or evidence The focused execution produced these observations (fixture paths abbreviated): ```text healthy open_binary / close_binary: success failed-open: code=cleanup_incomplete, category=execution_failure, details.cleanup=incomplete, resources=[<fixture>/Ghidra/Processors/x86/data/languages/x86.ldefs] provider-close: result={"ok":true,"value":null}, closeCalls=1, providerPending=false, registryPending=true fault disabled: open_binary: cleanup_incomplete, closeCalls=1 close_binary: cleanup_incomplete, closeCalls=1 open_binary: cleanup_incomplete, closeCalls=1 direct catalog read: expected language returned, closeCalls=2 after-exact-owner-release: open_binary still cleanup_incomplete, pending=true fresh-registry: selected=true Test Files 1 passed (1) Tests 1 passed (1) ``` The helper-call results and call counts come from the executed assertions and log; the healthy control is asserted before injection. The source explanation below is derived from the pinned implementation. ### Typed outcome or recovery expected Keep `cleanup_incomplete` while the exact catalog owner remains unresolved. Retry it on close/recovery, and clear the original error's live cleanup marker after confirmed release so the same registry can select again. A provider must not report clean resource release while its catalog handle remains retained. ### Analysis or suspected cause Source-derived ownership gap: - [Catalog retention and retry](https://github.com/morluto/rea/blob/01788bac863ac786bfbb648cad69b11e23b40a89/src/ghidra/GhidraLanguageCatalog.ts#L23-L77) keeps failed handles in a module-global set. Retry is reached through catalog reads. - [Provider shutdown](https://github.com/morluto/rea/blob/01788bac863ac786bfbb648cad69b11e23b40a89/src/ghidra/GhidraProvider.ts#L289-L333) observes and retries only inspection-scope resources. [Catalog error translation](https://github.com/morluto/rea/blob/01788bac863ac786bfbb648cad69b11e23b40a89/src/ghidra/GhidraProvider.ts#L397-L417) constructs a cleanup error without binding it to the retained file owner. - [Registry close](https://github.com/morluto/rea/blob/01788bac863ac786bfbb648cad69b11e23b40a89/src/application/binary/AnalysisProviderRegistry.ts#L236-L258) retains that still-incomplete error. [Selection admission](https://github.com/morluto/rea/blob/01788bac863ac786bfbb648cad69b11e23b40a89/src/application/binary/AnalysisProviderRegistry.ts#L407-L441) retries provider close before resolving another profile, so it cannot reach the catalog read that would retry the retained handle. The unbound error remains incomplete even after a separate catalog read confirms release. Giving catalog reads a caller-owned cleanup capability and binding returned errors to it would let shutdown retry the owner and let confirmed release reopen admission. ### Additional context Closed, unmerged PR #2867 already discussed the same missing provider-close retry in [this review comment](https://github.com/morluto/rea/pull/2867#discussion_r4260456273); the focused test above confirms the recovery gap remains on the reported revision. Related but distinct: #5972 concerns swallowed directory-read errors, #6031 concerns override-policy error classification, and #6104 concerns a session-observation cleanup aggregate after engine-client startup. This report concerns retained language-file ownership during profile selection, before engine startup. Open and closed issues/PRs were searched; no existing issue or open PR fixing this behavior was found. The first affected revision and a last known good version are NOT_ENOUGH_INFO; they were not established.
Author
Owner

@XinwangTao commented on GitHub (Oct 22, 2026):

确认,这个比 #6134 的误报更严重一层:不仅把一次 close 的临时失败判成 cleanup_incomplete,还把 registry 留在"永久拒绝后续 admission"的状态——后续 open/close/open 全被挡,直到进程重建,等于一次可重试的 close 失败变成了持久锁死。"新 provider/registry 在同一进程能成功"说明是某个残留的内部标记(handle 所有权/清理状态)没在失败时复位。建议修的时候优先定位那个残留状态而不是 close 本身:cleanup 失败应允许句柄所有权回收或后续重试,admission gate 不该被一次失败的 cleanup 永久占用;这个也可以挂到 #4227 的 lifecycle 契约审计里统一过一遍"失败后状态必须可恢复"的约束。

<!-- gh-comment-id:6330429980 --> @XinwangTao commented on GitHub (Oct 22, 2026): 确认,这个比 #6134 的误报更严重一层:不仅把一次 close 的临时失败判成 cleanup_incomplete,还把 registry 留在"永久拒绝后续 admission"的状态——后续 open/close/open 全被挡,直到进程重建,等于一次可重试的 close 失败变成了持久锁死。"新 provider/registry 在同一进程能成功"说明是某个残留的内部标记(handle 所有权/清理状态)没在失败时复位。建议修的时候优先定位那个残留状态而不是 close 本身:cleanup 失败应允许句柄所有权回收或后续重试,admission gate 不该被一次失败的 cleanup 永久占用;这个也可以挂到 #4227 的 lifecycle 契约审计里统一过一遍"失败后状态必须可恢复"的约束。
Author
Owner

@kaoru0822-kitauji commented on GitHub (Oct 22, 2026):

Reproduced on latest main (719d56ebf, Node 26.5.0, macOS arm64) with the issue's focused MCP test unchanged (node node_modules/vitest/vitest.mjs run <test> --maxWorkers=1): 1 test passed, i.e. every assertion characterizing the defect held — after the injected x86.ldefs close fault cleared, open_binary/close_binary/open_binary still returned cleanup_incomplete with closeCalls unchanged at 1, and a fresh registry selected the target.

Source-derived cause: the catalog retains failed handles in a module-global set that only a later catalog read retries, while the provider's ProviderCleanupError is not bound to that owner, so neither provider close nor registry admission can retry or resolve it. Working on moving the catalog's descriptor ownership into the provider's caller-owned inspection scope (the boundary the sibling seed/extension readers already use) on branch fix/6133-ghidra-catalog-close-recovery.

<!-- gh-comment-id:6330968769 --> @kaoru0822-kitauji commented on GitHub (Oct 22, 2026): Reproduced on latest main (`719d56ebf`, Node 26.5.0, macOS arm64) with the issue's focused MCP test unchanged (`node node_modules/vitest/vitest.mjs run <test> --maxWorkers=1`): 1 test passed, i.e. every assertion characterizing the defect held — after the injected `x86.ldefs` close fault cleared, `open_binary`/`close_binary`/`open_binary` still returned `cleanup_incomplete` with closeCalls unchanged at 1, and a fresh registry selected the target. Source-derived cause: the catalog retains failed handles in a module-global set that only a later catalog read retries, while the provider's `ProviderCleanupError` is not bound to that owner, so neither provider close nor registry admission can retry or resolve it. Working on moving the catalog's descriptor ownership into the provider's caller-owned inspection scope (the boundary the sibling seed/extension readers already use) on branch `fix/6133-ghidra-catalog-close-recovery`.
Author
Owner

@XinwangTao commented on GitHub (Oct 22, 2026):

修复方向与 issue 推断一致:把 catalog 的 descriptor ownership 收进 provider 的 caller-owned inspection scope,让 shutdown 能 retry 到保留句柄,confirmed release 后再打开 admission,#6210 正好覆盖这条恢复路径。建议合并后补一次快速回归:注入 close 失败 → 清除故障 → 同一 registry 直接 open 成功,确认 cleanup 状态不会残留到其他 session。

<!-- gh-comment-id:6332399439 --> @XinwangTao commented on GitHub (Oct 22, 2026): 修复方向与 issue 推断一致:把 catalog 的 descriptor ownership 收进 provider 的 caller-owned inspection scope,让 shutdown 能 retry 到保留句柄,confirmed release 后再打开 admission,#6210 正好覆盖这条恢复路径。建议合并后补一次快速回归:注入 close 失败 → 清除故障 → 同一 registry 直接 open 成功,确认 cleanup 状态不会残留到其他 session。
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#3761
No description provided.