[GH-ISSUE #6102] [Bug] Inspector ASAR finalization cleanup failure discards captured scripts and contexts #3735

Closed
opened 2026-10-31 00:43:29 +00:00 by bsakaguchi · 1 comment
Owner

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

Before submitting

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

Area

Process capture or runtime observation

Observed behavior

observe_javascript_runtime can return cleanup_incomplete without any captured script/context observation when an ASAR descriptor cannot close during final location authorization. A loopback Inspector fixture sent and REA consumed one Debugger.scriptParsed and one Runtime.executionContextCreated event. The matching successful-close control returned both; the controlled failed close returned no partialObservation. After the fault was removed, provider closure released the exact descriptor and cleared the cleanup marker.

This was executed through the production V8 provider and shared observation service with real HTTP/WebSocket traffic and real ASAR files. Only descriptor close refusal was injected. No native Electron/Node application or public transport client 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.each([false])('Inspector capture lost on ASAR close failure; cancel=%s', async cancel=>{
 const root=await realpath(await mkdtemp(join(tmpdir(),'rea-cleanup-probe-'))); const archive=join(root,'app.asar');
 const json=Buffer.from(JSON.stringify({files:{'script.js':{size:1,offset:'0'}}}));
 const bytes=Buffer.alloc(17+json.length);bytes.writeUInt32LE(4,0);bytes.writeUInt32LE(8+json.length,4);bytes.writeUInt32LE(4+json.length,8);bytes.writeUInt32LE(json.length,12);json.copy(bytes,16);await writeFile(archive,bytes);
 const script=pathToFileURL(join(archive,'script.js')).href;
 let endpoint=''; const server=createServer((req,res)=>{res.setHeader('content-type','application/json');res.end(JSON.stringify(req.url==='/json/version'?{Browser:'Node.js/v24.18.0','Protocol-Version':'1.3','V8-Version':'fixture'}:[{id:'fixture',type:'node',url:'http://localhost/entry.js',attached:false,webSocketDebuggerUrl:endpoint.replace('http:','ws:')+'/fixture'}]));});
 const sockets=new WebSocketServer({server,path:'/fixture'});let emitted=0;
 sockets.on('connection',socket=>socket.on('message',raw=>{const command=JSON.parse(raw.toString());if(command.method==='Runtime.enable'){
 for(const event of [{method:'Runtime.executionContextCreated',params:{context:{id:1,name:'main',origin:script}}},{method:'Debugger.scriptParsed',params:{scriptId:'1',url:script,executionContextId:1,hash:'captured',length:1,isModule:false}}]){socket.send(JSON.stringify(event));emitted++;}}
 socket.send(JSON.stringify({id:command.id,result:{}}));}));
 await new Promise<void>(r=>server.listen(0,'127.0.0.1',r)); const address=server.address() as import('node:net').AddressInfo;endpoint=`http://127.0.0.1:${address.port}`;
 const input={inspector_endpoint:endpoint,target_id:'fixture',observation_ms:0}; const provider=new V8InspectorProvider();const controller=new AbortController();
 try{
 const control=await observeJavaScriptRuntime(provider,input);expect(control.ok).toBe(true);if(!control.ok)throw control.error;expect(control.value.normalized_result).toMatchObject({scripts:{observed_total:1},execution_contexts:[{name:'main'}]});
 await writeFile(archive,Buffer.concat([bytes,Buffer.from([0])]));fault.path=archive;fault.blocked=true;fault.abort=cancel?controller:undefined;
 const result=await observeJavaScriptRuntime(provider,input,{signal:controller.signal});expect(result.ok).toBe(false);if(result.ok)throw Error('expected failure');
 expect(emitted).toBe(4);expect(fault.handles.length).toBeGreaterThan(0);if(cancel){expect(result.error.cleanupIncomplete).toBe(false);expect(result.error.partialObservation).toBeDefined();}else{expect(result.error.cleanupIncomplete).toBe(true);expect(result.error.partialObservation).toBeUndefined();}
 if(cancel)expect(result.error.cause).toBe(controller.signal.reason);
 console.log('INSPECTOR',JSON.stringify({cancel,eventsSent:2,partialMissing:result.error.partialObservation===undefined,projection:projectAnalysisError(result.error)}));
 fault.blocked=false;fault.abort=undefined;await provider.close();expect(result.error.cleanupIncomplete).toBe(false);expect(fault.handles.every(h=>h.fd===-1)).toBe(true);
 }finally{fault.blocked=false;fault.abort=undefined;await provider.close();for(const s of sockets.clients)s.terminate();await new Promise<void>(r=>sockets.close(()=>r()));await new Promise<void>(r=>server.close(()=>r()));await rm(root,{recursive:true,force:true});fault.path='';fault.handles=[];}
});

Expected behavior

Attach captured runtime/script/context facts to the typed failure using reported, explicitly unverified locations if final location authorization cannot finish. Preserve the actual descriptor cleanup status and retry owner. The lifecycle contract requires completed observations alongside cleanup failure. Existing partial-failure paths already project the same capture without filesystem authorization.

Impact and scope

P2: bounded runtime evidence loss on the failed-finalization branch. It affects captured metadata whose file locations require ASAR header access and encounter a close failure. A subsequent observation is possible after cleanup succeeds, but it is a new capture window. The trigger was controlled; natural fault frequency, other platforms and native Electron behavior were not measured. No lost descriptor ownership or security-boundary crossing was observed.

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

An inert ASAR archive with one script member, a separate HTTP target location, and one script plus one execution-context event from a loopback Inspector fixture.

Interface

Other

Earliest failure stage

Cleanup, cancellation, or lifecycle

Relevant output or evidence

Observed: successful-close control contained one script and one context; failed non-cancelled close produced code=cleanup_incomplete, one ASAR resource, and partialObservation=undefined; subsequent provider close made the descriptor fd=-1 and cleanupIncomplete=false.

The catch condition filters on the raw failure class after translation. Location authorization can fail after capture is complete.

Typed outcome or recovery expected

Attach captured runtime/script/context facts to the typed failure using reported, explicitly unverified locations if final location authorization cannot finish. Preserve the actual descriptor cleanup status and retry owner. The lifecycle contract requires completed observations alongside cleanup failure. Existing partial-failure paths already project the same capture without filesystem authorization.

Analysis or suspected cause

The raw cleanup failure mapping correctly creates a cleanup-bearing error. However, #observe only enters reported-location partial projection for a raw browser-observation or cancellation error. ArtifactFileCleanupFailure does not match either class, although capture state, discovery and target are already available. Preserving diagnostics in the mapper alone does not populate this capture; the V8 producer must attach it.

Additional context

Related #5184 repaired swallowed ASAR close failures/ownership, #5083 repaired Inspector transport-close cancellation, #5993 concerns an Electron target-list prefix lost in the file mapper, and #5641 concerns validation of an already supplied incomplete observation. This case supplies no partial to that service. #4628 restores primary classification after cleanup resolves; the non-cancelled capture is already missing before resolution. These require different producing-boundary fixes.

The cancellation overlap is excluded from this capture-loss claim: the executed cancellation control retained the captured Evidence. No natural OS close 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/6102 ### 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 Process capture or runtime observation ### Observed behavior `observe_javascript_runtime` can return `cleanup_incomplete` without any captured script/context observation when an ASAR descriptor cannot close during final location authorization. A loopback Inspector fixture sent and REA consumed one `Debugger.scriptParsed` and one `Runtime.executionContextCreated` event. The matching successful-close control returned both; the controlled failed close returned no `partialObservation`. After the fault was removed, provider closure released the exact descriptor and cleared the cleanup marker. This was executed through the production V8 provider and shared observation service with real HTTP/WebSocket traffic and real ASAR files. Only descriptor close refusal was injected. No native Electron/Node application or public transport client 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.each([false])('Inspector capture lost on ASAR close failure; cancel=%s', async cancel=>{ const root=await realpath(await mkdtemp(join(tmpdir(),'rea-cleanup-probe-'))); const archive=join(root,'app.asar'); const json=Buffer.from(JSON.stringify({files:{'script.js':{size:1,offset:'0'}}})); const bytes=Buffer.alloc(17+json.length);bytes.writeUInt32LE(4,0);bytes.writeUInt32LE(8+json.length,4);bytes.writeUInt32LE(4+json.length,8);bytes.writeUInt32LE(json.length,12);json.copy(bytes,16);await writeFile(archive,bytes); const script=pathToFileURL(join(archive,'script.js')).href; let endpoint=''; const server=createServer((req,res)=>{res.setHeader('content-type','application/json');res.end(JSON.stringify(req.url==='/json/version'?{Browser:'Node.js/v24.18.0','Protocol-Version':'1.3','V8-Version':'fixture'}:[{id:'fixture',type:'node',url:'http://localhost/entry.js',attached:false,webSocketDebuggerUrl:endpoint.replace('http:','ws:')+'/fixture'}]));}); const sockets=new WebSocketServer({server,path:'/fixture'});let emitted=0; sockets.on('connection',socket=>socket.on('message',raw=>{const command=JSON.parse(raw.toString());if(command.method==='Runtime.enable'){ for(const event of [{method:'Runtime.executionContextCreated',params:{context:{id:1,name:'main',origin:script}}},{method:'Debugger.scriptParsed',params:{scriptId:'1',url:script,executionContextId:1,hash:'captured',length:1,isModule:false}}]){socket.send(JSON.stringify(event));emitted++;}} socket.send(JSON.stringify({id:command.id,result:{}}));})); await new Promise<void>(r=>server.listen(0,'127.0.0.1',r)); const address=server.address() as import('node:net').AddressInfo;endpoint=`http://127.0.0.1:${address.port}`; const input={inspector_endpoint:endpoint,target_id:'fixture',observation_ms:0}; const provider=new V8InspectorProvider();const controller=new AbortController(); try{ const control=await observeJavaScriptRuntime(provider,input);expect(control.ok).toBe(true);if(!control.ok)throw control.error;expect(control.value.normalized_result).toMatchObject({scripts:{observed_total:1},execution_contexts:[{name:'main'}]}); await writeFile(archive,Buffer.concat([bytes,Buffer.from([0])]));fault.path=archive;fault.blocked=true;fault.abort=cancel?controller:undefined; const result=await observeJavaScriptRuntime(provider,input,{signal:controller.signal});expect(result.ok).toBe(false);if(result.ok)throw Error('expected failure'); expect(emitted).toBe(4);expect(fault.handles.length).toBeGreaterThan(0);if(cancel){expect(result.error.cleanupIncomplete).toBe(false);expect(result.error.partialObservation).toBeDefined();}else{expect(result.error.cleanupIncomplete).toBe(true);expect(result.error.partialObservation).toBeUndefined();} if(cancel)expect(result.error.cause).toBe(controller.signal.reason); console.log('INSPECTOR',JSON.stringify({cancel,eventsSent:2,partialMissing:result.error.partialObservation===undefined,projection:projectAnalysisError(result.error)})); fault.blocked=false;fault.abort=undefined;await provider.close();expect(result.error.cleanupIncomplete).toBe(false);expect(fault.handles.every(h=>h.fd===-1)).toBe(true); }finally{fault.blocked=false;fault.abort=undefined;await provider.close();for(const s of sockets.clients)s.terminate();await new Promise<void>(r=>sockets.close(()=>r()));await new Promise<void>(r=>server.close(()=>r()));await rm(root,{recursive:true,force:true});fault.path='';fault.handles=[];} }); ``` ### Expected behavior Attach captured runtime/script/context facts to the typed failure using reported, explicitly unverified locations if final location authorization cannot finish. Preserve the actual descriptor cleanup status and retry owner. The lifecycle contract requires [completed observations alongside cleanup failure](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/docs/tool-design.md#L123-L137). Existing partial-failure paths already project the same capture without filesystem authorization. ### Impact and scope P2: bounded runtime evidence loss on the failed-finalization branch. It affects captured metadata whose file locations require ASAR header access and encounter a close failure. A subsequent observation is possible after cleanup succeeds, but it is a new capture window. The trigger was controlled; natural fault frequency, other platforms and native Electron behavior were not measured. No lost descriptor ownership or security-boundary crossing was observed. ### 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 An inert ASAR archive with one script member, a separate HTTP target location, and one script plus one execution-context event from a loopback Inspector fixture. ### Interface Other ### Earliest failure stage Cleanup, cancellation, or lifecycle ### Relevant output or evidence Observed: successful-close control contained one script and one context; failed non-cancelled close produced `code=cleanup_incomplete`, one ASAR resource, and `partialObservation=undefined`; subsequent provider close made the descriptor `fd=-1` and `cleanupIncomplete=false`. [The catch condition](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/inspector/V8InspectorProvider.ts#L383-L395) filters on the raw failure class after translation. [Location authorization](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/inspector/V8InspectorCaptureProjection.ts#L66-L124) can fail after capture is complete. ### Typed outcome or recovery expected Attach captured runtime/script/context facts to the typed failure using reported, explicitly unverified locations if final location authorization cannot finish. Preserve the actual descriptor cleanup status and retry owner. The lifecycle contract requires [completed observations alongside cleanup failure](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/docs/tool-design.md#L123-L137). Existing partial-failure paths already project the same capture without filesystem authorization. ### Analysis or suspected cause [The raw cleanup failure mapping](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/browser/ElectronFileScope.ts#L59-L109) correctly creates a cleanup-bearing error. However, `#observe` only enters reported-location partial projection for a raw browser-observation or cancellation error. `ArtifactFileCleanupFailure` does not match either class, although capture state, discovery and target are already available. Preserving diagnostics in the mapper alone does not populate this capture; the V8 producer must attach it. ### Additional context Related #5184 repaired swallowed ASAR close failures/ownership, #5083 repaired Inspector transport-close cancellation, #5993 concerns an Electron target-list prefix lost in the file mapper, and #5641 concerns validation of an already supplied incomplete observation. This case supplies no partial to that service. #4628 restores primary classification after cleanup resolves; the non-cancelled capture is already missing before resolution. These require different producing-boundary fixes. The cancellation overlap is excluded from this capture-loss claim: the executed cancellation control retained the captured Evidence. No natural OS close failure or last-known-good version was established.
Author
Owner

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

Checked the reported fix on main at d87e73f2db.

The catch around normal finalization now always projects captured state in locationMode reported, without reauthorizing locations or reopening the failed ASAR. It retains that result on the original error, including a cleanup error. The partial projector retains scripts, raw URLs, contexts and counters without granting location authority.

Implementation: V8InspectorProvider.ts, V8InspectorCaptureProjection.ts.

The relevant regression checks in V8InspectorCaptureProjection.test.ts, V8InspectorProvider.partial.test.ts passed in the focused test run on this revision. This closure is based on the current source and those checks; provider fixtures do not establish a real-engine run. Closing as completed on main.

<!-- gh-comment-id:6380751305 --> @coygeek commented on GitHub (Oct 24, 2026): Checked the reported fix on `main` at d87e73f2db50be72d33a4b3c7199af35518b441c. The catch around normal finalization now always projects captured state in locationMode reported, without reauthorizing locations or reopening the failed ASAR. It retains that result on the original error, including a cleanup error. The partial projector retains scripts, raw URLs, contexts and counters without granting location authority. Implementation: [V8InspectorProvider.ts](https://github.com/morluto/rea/blob/d87e73f2db50be72d33a4b3c7199af35518b441c/src/inspector/V8InspectorProvider.ts#L412), [V8InspectorCaptureProjection.ts](https://github.com/morluto/rea/blob/d87e73f2db50be72d33a4b3c7199af35518b441c/src/inspector/V8InspectorCaptureProjection.ts#L142). The relevant regression checks in [V8InspectorCaptureProjection.test.ts](https://github.com/morluto/rea/blob/d87e73f2db50be72d33a4b3c7199af35518b441c/src/inspector/V8InspectorCaptureProjection.test.ts), [V8InspectorProvider.partial.test.ts](https://github.com/morluto/rea/blob/d87e73f2db50be72d33a4b3c7199af35518b441c/src/inspector/V8InspectorProvider.partial.test.ts) passed in the focused test run on this revision. This closure is based on the current source and those checks; provider fixtures do not establish a real-engine run. 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#3735
No description provided.