[GH-ISSUE #6104] [Bug] Ghidra session-observation cleanup aggregate stays incomplete after successful close #3737

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

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

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

Native analysis or decompilation

Observed behavior

A Ghidra session-observation failure creates a cleanup aggregate that stays incomplete after its source cleanup owner confirms release. In the executed case, a configured extension report is malformed; the first close fails and the next session close succeeds. The source cleanup error becomes complete, but the operation error still lists the released resource and projects cleanup_incomplete, including through finishAnalysisCommand.

The reproduction uses production BinarySession, routing, Ghidra provider projection, error composition and shared CLI finalization, with a simulated native Ghidra client. No native Ghidra process or public CLI/MCP transport was run.

Reproduction

I ran the following focused source test under the repository-pinned Node 24.18.0 with locked dependencies and one worker. Save it as tests/process-global/cleanupRecovery.test.ts and run:

node node_modules/vitest/vitest.mjs run tests/process-global/cleanupRecovery.test.ts --maxWorkers 1

The assertions characterize the affected behavior; they are not a fix. The exact standalone excerpt below was replayed independently and passed. Production code remained unchanged.

import { createServer } from 'node:http';
import { mkdtemp, rm, writeFile, realpath, type FileHandle } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { createHash } from 'node:crypto';
import { it, expect, vi } from 'vitest';
import { WebSocketServer } from 'ws';
const fault = vi.hoisted(() => ({path:'', blocked:false, abort:undefined as AbortController|undefined, handles:[] as FileHandle[]}));
vi.mock('node:fs/promises', async original => {
 const real = await original<typeof import('node:fs/promises')>();
 return {...real,open:async (...args:Parameters<typeof real.open>) => {
  const h=await real.open(...args); if(String(args[0])!==fault.path) return h;
  fault.handles.push(h); const close=h.close.bind(h);
  h.close=async()=>{if(fault.blocked){fault.abort?.abort('abort during descriptor close');throw new Error('controlled descriptor close denial');} await close();};return h;
 }};
});
import { V8InspectorProvider } from '../../src/inspector/V8InspectorProvider.js';
import { observeJavaScriptRuntime } from '../../src/application/javascript/JavaScriptRuntimeObservationService.js';
import { finishAnalysisCommand } from '../../src/cli/finishAnalysisCommand.js';
import { projectAnalysisError } from '../../src/domain/analysisErrorProjection.js';
import { CutterBridgeClient } from '../../src/cutter/CutterBridgeClient.js';
import { cutterCommandInputSchema } from '../../src/contracts/cutterToolContracts.js';
import { CutterBridgeService } from '../../src/cutter/CutterBridgeService.js';
import { authenticatedServer, writeDescriptor } from '../fixtures/cutterBridgeProtocol.js';
import { GhidraProvider } from '../../src/ghidra/GhidraProvider.js';
import { parseConfig } from '../../src/config/parseConfig.js';
import { silentLogger } from '../../src/logger.js';
import { err, ok } from '../../src/domain/result.js';
import { ProviderCleanupError } from '../../src/domain/providerCleanupError.js';
import { GhidraSessionError } from '../../src/ghidra/GhidraSessionError.js';
import { GHIDRA_SESSION_CAPABILITIES } from '../../src/ghidra/GhidraSessionValues.js';
import { elf } from '../../src/domain/binaryTarget.fixture.js';
import { createTestBinarySession } from '../fixtures/binarySession.js';

it('Ghidra aggregate stays incomplete after session close succeeds',async()=>{
 const root=await mkdtemp(join(tmpdir(),'rea-ghidra-probe-'));const jar=join(root,'addon.jar');const jarBytes=Buffer.from([0x50,0x4b,3,4]);await writeFile(jar,jarBytes);const target=join(root,'fixture.elf');const targetBytes=elf(2,1,62);targetBytes.writeUInt16LE(2,16);await writeFile(target,targetBytes);
 const config=parseConfig({GHIDRA_INSTALL_DIR:'/opt/ghidra_12.1.4_PUBLIC',REA_GHIDRA_NATIVEAOT_JAR:jar});if(!config.ok)throw config.error;
 const cleanup=new ProviderCleanupError('ghidra',['owned-ghidra-runtime'],{reason:'controlled first-close denial'});let closes=0;
 const ghidra=new GhidraProvider(config.value,silentLogger,{}, {platform:'linux',architecture:'x64',readText:()=> 'application.version=12.1.4\napplication.java.min=21\n',executable:()=>true,probeJava:()=>ok({osArch:'amd64',osName:'Linux',version:'21.0.11',major:21,home:'/usr/lib/jvm/jdk-21',bits:64,runtime:'jdk',vmName:'OpenJDK 64-Bit Server VM'})},()=>({
 start:async()=>ok({name:'REA Ghidra bridge',run_id:'11111111-1111-4111-8111-111111111111',profile_digest:'a'.repeat(64),provider:{id:'ghidra',version:'12.1.4'},read_only:false,analysis_complete:true,analysis_timed_out:false,capabilities:[...GHIDRA_SESSION_CAPABILITIES],target:{name:'fixture',language_id:'x86:LE:64:default',compiler_spec_id:'gcc',image_base:'0x1000',default_address_space:'ram',sha256:createHash('sha256').update(targetBytes).digest('hex')},analysis_extensions:[{id:'nativeaot',sha256:createHash('sha256').update(jarBytes).digest('hex'),status:'unsupported',reason:'unsupported fixture layout'}]}),
 callTool:async()=>ok(null),close:async()=>{closes++;if(closes===1)return err(cleanup);cleanup.resolveCleanup();return ok(null);}
 }));
 const session=createTestBinarySession(ghidra);
 try{
 const admission=await session.open(target);expect(admission.ok).toBe(true);const opened=await session.execute('list_procedures',{});expect(opened.ok).toBe(false);if(opened.ok)throw Error('expected extension rejection');await session.close();
 expect(closes).toBe(2);expect(cleanup.cleanupIncomplete).toBe(false);expect(opened.error.cleanupIncomplete).toBe(true);expect(opened.error.cleanupResources).toContain('owned-ghidra-runtime');
 const projected=await finishAnalysisCommand(async()=>opened,{close:async()=>{const closed=await session.close();if(!closed.ok)throw closed.error;}},'ghidra','list_procedures',new AbortController().signal);expect(projected).toMatchObject({code:'cleanup_incomplete',details:{resources:['owned-ghidra-runtime']}});console.log('GHIDRA',JSON.stringify({closes,ownerIncomplete:cleanup.cleanupIncomplete,result:projected}));
 }finally{await session.close();await rm(root,{recursive:true,force:true});}
});

Expected behavior

Bind the session-observation aggregate to the exact source cleanup owner. Once that owner confirms release, the aggregate must stop reporting incomplete cleanup and released resources while preserving the original extension/seed/soft-float diagnostic. See the aggregate ownership rule. Successful one-shot close cannot itself infer that an unrelated producer error has resolved.

Impact and scope

P2: callers can see a current cleanup failure and released-resource recovery guidance after confirmed owner release. The actual session can close; no leaked process or file was demonstrated. The affected producer handles configured extension, seed and MIPS soft-float observation mismatches; the executed mismatch is a malformed extension report. Incidence with real Ghidra and downstream client decisions were not measured.

REA version

rea-agents 6.7.0; source revision 52415975942a4b9c53a817915b313ccc404f280e.

Runtime and platform

macOS 27.0.1 arm64; Node.js v24.18.0; locked Vitest 5.0.3. Simulated protocol/native-provider boundaries as described above.

Target and artifact context

A minimal x86_64 ELF, an inert configured extension JAR fixture, and a simulated malformed extension report. The simulated close owner fails once, resolves its exact ProviderCleanupError on the next close and returns success.

Interface

Other

Earliest failure stage

Cleanup, cancellation, or lifecycle

Relevant output or evidence

Observed:

{"native_close_calls":2,"source_cleanup_incomplete":false,"aggregate_cleanup_incomplete":true,"finalizer_code":"cleanup_incomplete","finalizer_resources":["owned-ghidra-runtime"]}

session.open() succeeded without starting native analysis. The subsequent session.execute("list_procedures", {}) failed, then successful session.close() resolved the source owner before the error was projected. The current one-shot finalizer continued to return the stale aggregate.

Typed outcome or recovery expected

Bind the session-observation aggregate to the exact source cleanup owner. Once that owner confirms release, the aggregate must stop reporting incomplete cleanup and released resources while preserving the original extension/seed/soft-float diagnostic. See the aggregate ownership rule. Successful one-shot close cannot itself infer that an unrelated producer error has resolved.

Analysis or suspected cause

checkSessionObservations calls analysisErrorWithCleanupFailure without binding its dependencies. Composition records the cleanup state but binding is a separate step. resolveCleanup updates only registered dependents. The adjacent client-close composition binds its source errors. The missing dependency belongs at this producer, rather than teaching the finalizer to assume release.

Additional context

Related “cleanup stays incomplete” reports: #6063 covers managed descriptors and #6084 covers source-map codecs. Their owner-specific fixes do not bind this Ghidra session-observation aggregate. #4752 concerns snapshot-reader outcomes, #1190 actual runtime-directory cleanup, and #3032 macOS process-exit observation. #4628 restores typed primary projection only after live cleanup resolves. The current finalizer explicitly leaves that confirmation to resource owners.

list_procedures is a supported official tool. The error was exercised through the shared session and finalizer, not a separate CLI/MCP process. The lazy open_binary call itself did not reproduce the failure. No natural native cleanup failure or last-known-good version was established.

Originally created by @coygeek on GitHub (Oct 21, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/6104 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 Native analysis or decompilation ### Observed behavior A Ghidra session-observation failure creates a cleanup aggregate that stays incomplete after its source cleanup owner confirms release. In the executed case, a configured extension report is malformed; the first close fails and the next session close succeeds. The source cleanup error becomes complete, but the operation error still lists the released resource and projects `cleanup_incomplete`, including through `finishAnalysisCommand`. The reproduction uses production `BinarySession`, routing, Ghidra provider projection, error composition and shared CLI finalization, with a simulated native Ghidra client. No native Ghidra process or public CLI/MCP transport was run. ### Reproduction I ran the following focused source test under the repository-pinned Node 24.18.0 with locked dependencies and one worker. Save it as `tests/process-global/cleanupRecovery.test.ts` and run: ```sh node node_modules/vitest/vitest.mjs run tests/process-global/cleanupRecovery.test.ts --maxWorkers 1 ``` The assertions characterize the affected behavior; they are not a fix. The exact standalone excerpt below was replayed independently and passed. Production code remained unchanged. ```ts import { createServer } from 'node:http'; import { mkdtemp, rm, writeFile, realpath, type FileHandle } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { createHash } from 'node:crypto'; import { it, expect, vi } from 'vitest'; import { WebSocketServer } from 'ws'; const fault = vi.hoisted(() => ({path:'', blocked:false, abort:undefined as AbortController|undefined, handles:[] as FileHandle[]})); vi.mock('node:fs/promises', async original => { const real = await original<typeof import('node:fs/promises')>(); return {...real,open:async (...args:Parameters<typeof real.open>) => { const h=await real.open(...args); if(String(args[0])!==fault.path) return h; fault.handles.push(h); const close=h.close.bind(h); h.close=async()=>{if(fault.blocked){fault.abort?.abort('abort during descriptor close');throw new Error('controlled descriptor close denial');} await close();};return h; }}; }); import { V8InspectorProvider } from '../../src/inspector/V8InspectorProvider.js'; import { observeJavaScriptRuntime } from '../../src/application/javascript/JavaScriptRuntimeObservationService.js'; import { finishAnalysisCommand } from '../../src/cli/finishAnalysisCommand.js'; import { projectAnalysisError } from '../../src/domain/analysisErrorProjection.js'; import { CutterBridgeClient } from '../../src/cutter/CutterBridgeClient.js'; import { cutterCommandInputSchema } from '../../src/contracts/cutterToolContracts.js'; import { CutterBridgeService } from '../../src/cutter/CutterBridgeService.js'; import { authenticatedServer, writeDescriptor } from '../fixtures/cutterBridgeProtocol.js'; import { GhidraProvider } from '../../src/ghidra/GhidraProvider.js'; import { parseConfig } from '../../src/config/parseConfig.js'; import { silentLogger } from '../../src/logger.js'; import { err, ok } from '../../src/domain/result.js'; import { ProviderCleanupError } from '../../src/domain/providerCleanupError.js'; import { GhidraSessionError } from '../../src/ghidra/GhidraSessionError.js'; import { GHIDRA_SESSION_CAPABILITIES } from '../../src/ghidra/GhidraSessionValues.js'; import { elf } from '../../src/domain/binaryTarget.fixture.js'; import { createTestBinarySession } from '../fixtures/binarySession.js'; it('Ghidra aggregate stays incomplete after session close succeeds',async()=>{ const root=await mkdtemp(join(tmpdir(),'rea-ghidra-probe-'));const jar=join(root,'addon.jar');const jarBytes=Buffer.from([0x50,0x4b,3,4]);await writeFile(jar,jarBytes);const target=join(root,'fixture.elf');const targetBytes=elf(2,1,62);targetBytes.writeUInt16LE(2,16);await writeFile(target,targetBytes); const config=parseConfig({GHIDRA_INSTALL_DIR:'/opt/ghidra_12.1.4_PUBLIC',REA_GHIDRA_NATIVEAOT_JAR:jar});if(!config.ok)throw config.error; const cleanup=new ProviderCleanupError('ghidra',['owned-ghidra-runtime'],{reason:'controlled first-close denial'});let closes=0; const ghidra=new GhidraProvider(config.value,silentLogger,{}, {platform:'linux',architecture:'x64',readText:()=> 'application.version=12.1.4\napplication.java.min=21\n',executable:()=>true,probeJava:()=>ok({osArch:'amd64',osName:'Linux',version:'21.0.11',major:21,home:'/usr/lib/jvm/jdk-21',bits:64,runtime:'jdk',vmName:'OpenJDK 64-Bit Server VM'})},()=>({ start:async()=>ok({name:'REA Ghidra bridge',run_id:'11111111-1111-4111-8111-111111111111',profile_digest:'a'.repeat(64),provider:{id:'ghidra',version:'12.1.4'},read_only:false,analysis_complete:true,analysis_timed_out:false,capabilities:[...GHIDRA_SESSION_CAPABILITIES],target:{name:'fixture',language_id:'x86:LE:64:default',compiler_spec_id:'gcc',image_base:'0x1000',default_address_space:'ram',sha256:createHash('sha256').update(targetBytes).digest('hex')},analysis_extensions:[{id:'nativeaot',sha256:createHash('sha256').update(jarBytes).digest('hex'),status:'unsupported',reason:'unsupported fixture layout'}]}), callTool:async()=>ok(null),close:async()=>{closes++;if(closes===1)return err(cleanup);cleanup.resolveCleanup();return ok(null);} })); const session=createTestBinarySession(ghidra); try{ const admission=await session.open(target);expect(admission.ok).toBe(true);const opened=await session.execute('list_procedures',{});expect(opened.ok).toBe(false);if(opened.ok)throw Error('expected extension rejection');await session.close(); expect(closes).toBe(2);expect(cleanup.cleanupIncomplete).toBe(false);expect(opened.error.cleanupIncomplete).toBe(true);expect(opened.error.cleanupResources).toContain('owned-ghidra-runtime'); const projected=await finishAnalysisCommand(async()=>opened,{close:async()=>{const closed=await session.close();if(!closed.ok)throw closed.error;}},'ghidra','list_procedures',new AbortController().signal);expect(projected).toMatchObject({code:'cleanup_incomplete',details:{resources:['owned-ghidra-runtime']}});console.log('GHIDRA',JSON.stringify({closes,ownerIncomplete:cleanup.cleanupIncomplete,result:projected})); }finally{await session.close();await rm(root,{recursive:true,force:true});} }); ``` ### Expected behavior Bind the session-observation aggregate to the exact source cleanup owner. Once that owner confirms release, the aggregate must stop reporting incomplete cleanup and released resources while preserving the original extension/seed/soft-float diagnostic. See [the aggregate ownership rule](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/docs/tool-design.md#L123-L131). Successful one-shot close cannot itself infer that an unrelated producer error has resolved. ### Impact and scope P2: callers can see a current cleanup failure and released-resource recovery guidance after confirmed owner release. The actual session can close; no leaked process or file was demonstrated. The affected producer handles configured extension, seed and MIPS soft-float observation mismatches; the executed mismatch is a malformed extension report. Incidence with real Ghidra and downstream client decisions were not measured. ### REA version `rea-agents` 6.7.0; source revision `52415975942a4b9c53a817915b313ccc404f280e`. ### Runtime and platform macOS 27.0.1 arm64; Node.js v24.18.0; locked Vitest 5.0.3. Simulated protocol/native-provider boundaries as described above. ### Target and artifact context A minimal x86_64 ELF, an inert configured extension JAR fixture, and a simulated malformed extension report. The simulated close owner fails once, resolves its exact `ProviderCleanupError` on the next close and returns success. ### Interface Other ### Earliest failure stage Cleanup, cancellation, or lifecycle ### Relevant output or evidence Observed: ```json {"native_close_calls":2,"source_cleanup_incomplete":false,"aggregate_cleanup_incomplete":true,"finalizer_code":"cleanup_incomplete","finalizer_resources":["owned-ghidra-runtime"]} ``` `session.open()` succeeded without starting native analysis. The subsequent `session.execute("list_procedures", {})` failed, then successful `session.close()` resolved the source owner before the error was projected. The current one-shot finalizer continued to return the stale aggregate. ### Typed outcome or recovery expected Bind the session-observation aggregate to the exact source cleanup owner. Once that owner confirms release, the aggregate must stop reporting incomplete cleanup and released resources while preserving the original extension/seed/soft-float diagnostic. See [the aggregate ownership rule](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/docs/tool-design.md#L123-L131). Successful one-shot close cannot itself infer that an unrelated producer error has resolved. ### Analysis or suspected cause [checkSessionObservations](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/ghidra/GhidraProviderClient.ts#L429-L479) calls `analysisErrorWithCleanupFailure` without binding its dependencies. [Composition](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/domain/analysisErrorCleanup.ts#L5-L49) records the cleanup state but binding is a separate step. [resolveCleanup](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/domain/analysisErrorBase.ts#L166-L201) updates only registered dependents. The adjacent [client-close composition](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/ghidra/GhidraProviderClient.ts#L743-L755) binds its source errors. The missing dependency belongs at this producer, rather than teaching the finalizer to assume release. ### Additional context Related “cleanup stays incomplete” reports: [#6063](https://github.com/morluto/rea/issues/6063) covers managed descriptors and [#6084](https://github.com/morluto/rea/issues/6084) covers source-map codecs. Their owner-specific fixes do not bind this Ghidra session-observation aggregate. #4752 concerns snapshot-reader outcomes, #1190 actual runtime-directory cleanup, and #3032 macOS process-exit observation. #4628 restores typed primary projection only after live cleanup resolves. [The current finalizer](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/cli/finishAnalysisCommand.ts#L36-L39) explicitly leaves that confirmation to resource owners. `list_procedures` is a supported [official tool](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/contracts/officialToolContracts.ts#L120-L125). The error was exercised through the shared session and finalizer, not a separate CLI/MCP process. The lazy `open_binary` call itself did not reproduce the failure. No natural native cleanup failure or last-known-good version was established.
Author
Owner

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

Reproduced on latest main (4a4bbab3c) with a trimmed version of the reported Ghidra case: production GhidraProvider + BinarySession (createTestBinarySession) + finishAnalysisCommand, simulated native client whose first close returns a ProviderCleanupError and second close resolves it. No real Ghidra/Java was run.

Observed:

{"closes":2,"ownerIncomplete":false,"aggregateIncomplete":true,"code":"cleanup_incomplete","resources":["owned-ghidra-runtime"]}

Source-derived cause: checkSessionObservations in src/ghidra/GhidraProviderClient.ts composes analysisErrorWithCleanupFailure(extensionFailure, closed.error) without bindCleanupDependencies, so the owner's later resolveCleanup() never reaches the aggregate. Working on a fix at that producer.

<!-- gh-comment-id:6332565253 --> @kaoru0822-kitauji commented on GitHub (Oct 22, 2026): Reproduced on latest main (`4a4bbab3c`) with a trimmed version of the reported Ghidra case: production `GhidraProvider` + `BinarySession` (`createTestBinarySession`) + `finishAnalysisCommand`, simulated native client whose first close returns a `ProviderCleanupError` and second close resolves it. No real Ghidra/Java was run. Observed: ```json {"closes":2,"ownerIncomplete":false,"aggregateIncomplete":true,"code":"cleanup_incomplete","resources":["owned-ghidra-runtime"]} ``` Source-derived cause: `checkSessionObservations` in `src/ghidra/GhidraProviderClient.ts` composes `analysisErrorWithCleanupFailure(extensionFailure, closed.error)` without `bindCleanupDependencies`, so the owner's later `resolveCleanup()` never reaches the aggregate. Working on a fix at that producer.
Author
Owner

@coygeek commented on GitHub (Oct 24, 2026):

At d87e73f2db50be72d33a4b3c7199af35518b441c, the session-observation cleanup aggregate is now bound to its actual source failures (producer). The regression fails the first close, resolves the retained owner on retry, and checks that the original operation error clears cleanupIncomplete and projects execution_failure. This addresses the stale aggregate described here.

I ran the cited regression file on this revision; all passed as part of the focused 28-file, 603-test run. Provider-facing checks here use controlled fixtures; this does not claim a run with the real analysis engine. Closing as completed on main.

<!-- gh-comment-id:6380642599 --> @coygeek commented on GitHub (Oct 24, 2026): At `d87e73f2db50be72d33a4b3c7199af35518b441c`, the session-observation cleanup aggregate is now bound to its actual source failures ([producer](https://github.com/morluto/rea/blob/d87e73f2db50be72d33a4b3c7199af35518b441c/src/ghidra/GhidraProviderClient.ts#L496)). The [regression](https://github.com/morluto/rea/blob/d87e73f2db50be72d33a4b3c7199af35518b441c/src/ghidra/GhidraProvider.behavior.test.ts#L4549) fails the first close, resolves the retained owner on retry, and checks that the original operation error clears cleanupIncomplete and projects execution_failure. This addresses the stale aggregate described here. I ran the cited regression file on this revision; all passed as part of the focused 28-file, 603-test run. Provider-facing checks here use controlled fixtures; this does not claim a run with the real analysis engine. Closing as completed on main.
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#3737
No description provided.