[GH-ISSUE #3382] [Bug] Electron script frame attribution and identity change after context destruction #1250

Closed
opened 2026-10-16 21:03:21 +00:00 by bsakaguchi · 2 comments
Owner

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

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

inspect-electron-page loses a script's already supplied execution-context frame ID when the context is destroyed before collection finishes. The script remains in the result, but its frame_id becomes null and its script_key changes. Identical script observations therefore differ according to whether their context survives until projection.

Both public CLI calls in the reproduction exited with code 0. The control returned frame_id: "main"; the destroyed-context case returned frame_id: null and a different script_key. The authorized frame remained present in both results. Both captures reported only the expected script_sources policy exclusion, with no unavailable section or dropped script event.

The same loss was also observed with --include-script-sources; those runs reported complete_within_window. The primary reproduction omits that option so it does not depend on source retrieval after context destruction. These observations establish behavior with a protocol fixture, not a real Electron producer.

Reproduction

  1. Create a clean source checkout, select the reproduced baseline, install locked dependencies, and build the runtime.
git clone https://github.com/morluto/rea.git rea-repro
cd rea-repro
git checkout 8dde6370d0809ad04cda3342d4e6f290f48aa063
npm ci
npm run build:cached
  1. From the repository root, create sample-observation/sample-repro.mjs with the following contents. The fixture creates every input file, including index.html containing <script src="app.js"></script> and app.js containing globalThis.marker=1;, and isolates the CLI home directory. It uses a local CDP protocol fixture and ordinary files, requires no Electron installation, and executes no application JavaScript.
mkdir -p sample-observation
cat > sample-observation/sample-repro.mjs <<'JS'
import {createServer} from 'node:http';
import {writeFile, mkdir} from 'node:fs/promises';
import {pathToFileURL,fileURLToPath} from 'node:url';
import {dirname,join} from 'node:path';
import {createRequire} from 'node:module';
import {spawn} from 'node:child_process';
const checkout=process.argv[2];
if(!checkout)throw new Error('Supply the absolute REA checkout path');
const root=dirname(fileURLToPath(import.meta.url));
const {WebSocketServer}=createRequire(join(checkout,'package.json'))('ws');
await mkdir(root+'/home',{recursive:true});
await writeFile(root+'/index.html','<script src="app.js"></script>');
await writeFile(root+'/app.js','globalThis.marker=1;');
const page=pathToFileURL(root+'/index.html').href;
const script=pathToFileURL(root+'/app.js').href;
let mode='control';
const http=createServer((req,res)=>{
 res.setHeader('content-type','application/json');
 const port=http.address().port;
 res.end(JSON.stringify(req.url==='/json/version'?{Browser:'FakeChrome/1.0','Protocol-Version':'1.3','User-Agent':'FakeChrome','V8-Version':'13','WebKit-Version':'fake',webSocketDebuggerUrl:`ws://127.0.0.1:${port}/devtools/browser/fake`}:[{id:'page',type:'page',url:page,title:'fixture',attached:false}]));
});
const wss=new WebSocketServer({server:http});
wss.on('connection',socket=>socket.on('message',raw=>{
 const c=JSON.parse(String(raw));
 const event=(method,params)=>socket.send(JSON.stringify({method,params,sessionId:c.sessionId}));
 let result={};
 if(c.method==='Target.attachToTarget')result={sessionId:'session'};
 if(c.method==='Page.getFrameTree')result={frameTree:{frame:{id:'main',url:page}}};
 if(c.method==='Page.getResourceTree')result={frameTree:{frame:{id:'main',url:page},resources:mode==='malformed-resources'?null:[{url:script,type:'Script',mimeType:'text/javascript',contentSize:20}]}};
 if(c.method==='DOMSnapshot.captureSnapshot')result=mode==='malformed-dom'?{}:{strings:[page,'#document',''],documents:[{documentURL:0,title:2,baseURL:0,contentLanguage:2,encodingName:2,publicId:2,systemId:2,frameId:'main',nodes:{nodeType:[9],nodeName:[1],nodeValue:[2],parentIndex:[-1],attributes:[[]],backendNodeId:[1]},layout:{nodeIndex:[],styles:[],bounds:[],text:[],stackingContexts:{index:[]}},textBoxes:{layoutIndex:[],bounds:[],start:[],length:[]}}]};
 if(c.method==='Target.getTargets')result=mode==='malformed-workers'?{targetInfos:null}:{targetInfos:[{targetId:'worker-1',type:'worker',title:'sample worker',url:script,attached:false,openerId:'page',parentFrameId:'main',canAccessOpener:false}]};
 if(c.method==='Debugger.getScriptSource')result={scriptSource:'globalThis.marker=1;'};
 if(c.method==='Runtime.enable')event('Runtime.executionContextCreated',{context:{id:1,origin:'file://',name:'',uniqueId:'context-1',auxData:{frameId:'main',isDefault:true,type:'default'}}});
 if(c.method==='Debugger.enable'){
  event('Debugger.scriptParsed',{scriptId:'script-1',url:script,startLine:0,startColumn:0,endLine:0,endColumn:20,hash:'sample-hash',length:20,isModule:false,executionContextId:1,scriptLanguage:'JavaScript'});
  if(mode==='destroyed-context')event('Runtime.executionContextDestroyed',{executionContextId:1,executionContextUniqueId:'context-1'});
 }
 socket.send(JSON.stringify({id:c.id,sessionId:c.sessionId,result}));
}));
await new Promise(r=>http.listen(0,'127.0.0.1',r));
const endpoint=`http://127.0.0.1:${http.address().port}`;
try {
 for(mode of ['control','destroyed-context']){
  const {code,stdout,stderr}=await new Promise(resolve=>{
   const p=spawn(process.execPath,['scripts/rea.mjs','inspect-electron-page',endpoint,'page','--observation-ms','100','--json'],{cwd:checkout,env:{...process.env,HOME:root+'/home'}});
   let stdout='',stderr='';p.stdout.on('data',x=>stdout+=x);p.stderr.on('data',x=>stderr+=x);p.on('exit',code=>resolve({code,stdout,stderr}));
  });
  await writeFile(root+'/'+mode+'.json',stdout);
  const output=JSON.parse(stdout);
  const x=output.normalized_result;
  console.log(JSON.stringify({mode,exit_code:code,frame_id:x?.scripts.items[0]?.frame_id,script_key:x?.scripts.items[0]?.script_key,dom_nodes:x?.dom.total_nodes,resources:x?.resources.length,workers:x?.workers.length,completeness:x?.completeness}));
 }
}finally{
 for(const client of wss.clients)client.terminate();
 await new Promise(r=>wss.close(r)); await new Promise(r=>http.close(r));
}
JS
  1. Run the fixture. It starts a loopback HTTP/WebSocket endpoint, invokes the public CLI twice, saves each result as sample-observation/control.json and sample-observation/destroyed-context.json, and closes the endpoint and sockets before exiting.
node sample-observation/sample-repro.mjs "$PWD"

The underlying command is node scripts/rea.mjs inspect-electron-page ENDPOINT page --observation-ms 100 --json, with ENDPOINT supplied by the fixture and an isolated home directory. Both runs receive the same local page, frame, context, and script metadata. The second run additionally receives Runtime.executionContextDestroyed after Debugger.scriptParsed.

Expected behavior

docs/electron-observation.md:51-54 promises stable script/resource identities and retention of execution-context frame IDs supplied by CDP. The script should retain frame_id: "main" after its context is destroyed because CDP already supplied that relationship while the script was observed. Its identity should remain the same for the same retained script metadata. Script-source filtering should not discard frame attribution.

Impact and scope

Impact level: P2. Assessment status: supported. Observed script-to-frame attribution is discarded and its identity changes under a supported context lifecycle event. This affects the usefulness and comparability of passive Electron script Evidence; broader occurrence on real applications was not measured.

Workaround status: unverified. Capturing before context destruction may avoid the triggering sequence, but this depends on target timing and was not established as a practical way to retain the requested observations. Omitting script sources was executed and did not avoid the loss.

REA version

6.4.0 (main at 8dde6370d0)

Runtime and platform

Node.js v26.10.0; macOS 27.0.1. Architecture was not recorded for this reproduction.

Target and artifact context

A simulated CDP producer serves one ordinary local file page and one local JavaScript file, a valid main frame, one execution context, and one parsed script. The fixture sends the context-destroyed event only in the second run. Inspection uses the built runtime through the public CLI; no Electron installation or application JavaScript execution is needed. The retained artifacts are the full CLI JSON results for both modes.

Interface

CLI

Earliest failure stage

Transport or result projection

Relevant output or evidence

The following selected fields summarize the observed public CLI results. Evidence provenance: observed. Local verification: reproduced. Reproduction completeness: complete. Exact script-key hashes were not recorded here; the observed comparison established that they differed.

control:
  exit_code: 0
  frame_id: "main"

destroyed-context:
  exit_code: 0
  frame_id: null
  script_key: differs from control

Both results retain the authorized frame.
Both results have only the expected script_sources policy exclusion.
Neither result has an unavailable section or a dropped script event.
With --include-script-sources, the same loss occurs and status is complete_within_window.

The executed CLI fixture results establish the violation. The source locators below explain the observed boundary.

Typed outcome or recovery expected

Run both fixture modes against the same local files. Each should retain the supplied frame_id: "main", and their script keys should agree. Keep the control's existing source-filtering behavior. Also preserve frame_id: null when CDP never supplies a usable frame relationship; restoration must not invent attribution for genuinely unknown contexts.

Analysis or suspected cause

Source-derived reasoning: src/browser/CdpElectronScriptEvents.ts:31-35 removes the context mapping after Runtime.executionContextDestroyed. src/browser/CdpElectronScripts.ts:38-53 consults that later mapping when projecting the script and hashing its identity. The surviving script consequently loses its previously supplied frame ID, and the changed identity input changes its script key. This source-derived explanation is separate from the observed CLI results.

Additional context

Related completed issue #1500 addresses frame attribution changing after context-ID reuse in browser page capture; this report reproduces attribution loss after context destruction in the Electron inspector's separate implementation. The earlier issue was closed as completed, so it does not establish a by-design exception to retained frame attribution.

Related completed issue #2556 concerns malformed Electron child frame IDs being classified as scope exclusions; this report supplies valid identities and loses an already observed relationship after a lifecycle event.

Related issue #3192 concerns false absence states for ambiguous static reconciliation candidates; this report changes captured script metadata before reconciliation.

Related issue #3316 concerns a unique alternate static digest match hiding a mapped-path mismatch; this report loses captured frame attribution and changes script identity.

Related completed issue #2625 concerns parse-gap attribution to unaffected web-bundle scripts; this reproduction does not parse script source and concerns Electron script lifecycle retention.

Originally created by @coygeek on GitHub (Oct 14, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/3382 ### 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 `inspect-electron-page` loses a script's already supplied execution-context frame ID when the context is destroyed before collection finishes. The script remains in the result, but its `frame_id` becomes `null` and its `script_key` changes. Identical script observations therefore differ according to whether their context survives until projection. Both public CLI calls in the reproduction exited with code `0`. The control returned `frame_id: "main"`; the destroyed-context case returned `frame_id: null` and a different `script_key`. The authorized frame remained present in both results. Both captures reported only the expected `script_sources` policy exclusion, with no unavailable section or dropped script event. The same loss was also observed with `--include-script-sources`; those runs reported `complete_within_window`. The primary reproduction omits that option so it does not depend on source retrieval after context destruction. These observations establish behavior with a protocol fixture, not a real Electron producer. ### Reproduction 1. Create a clean source checkout, select the reproduced baseline, install locked dependencies, and build the runtime. ```sh git clone https://github.com/morluto/rea.git rea-repro cd rea-repro git checkout 8dde6370d0809ad04cda3342d4e6f290f48aa063 npm ci npm run build:cached ``` 2. From the repository root, create `sample-observation/sample-repro.mjs` with the following contents. The fixture creates every input file, including `index.html` containing `<script src="app.js"></script>` and `app.js` containing `globalThis.marker=1;`, and isolates the CLI home directory. It uses a local CDP protocol fixture and ordinary files, requires no Electron installation, and executes no application JavaScript. ```sh mkdir -p sample-observation cat > sample-observation/sample-repro.mjs <<'JS' import {createServer} from 'node:http'; import {writeFile, mkdir} from 'node:fs/promises'; import {pathToFileURL,fileURLToPath} from 'node:url'; import {dirname,join} from 'node:path'; import {createRequire} from 'node:module'; import {spawn} from 'node:child_process'; const checkout=process.argv[2]; if(!checkout)throw new Error('Supply the absolute REA checkout path'); const root=dirname(fileURLToPath(import.meta.url)); const {WebSocketServer}=createRequire(join(checkout,'package.json'))('ws'); await mkdir(root+'/home',{recursive:true}); await writeFile(root+'/index.html','<script src="app.js"></script>'); await writeFile(root+'/app.js','globalThis.marker=1;'); const page=pathToFileURL(root+'/index.html').href; const script=pathToFileURL(root+'/app.js').href; let mode='control'; const http=createServer((req,res)=>{ res.setHeader('content-type','application/json'); const port=http.address().port; res.end(JSON.stringify(req.url==='/json/version'?{Browser:'FakeChrome/1.0','Protocol-Version':'1.3','User-Agent':'FakeChrome','V8-Version':'13','WebKit-Version':'fake',webSocketDebuggerUrl:`ws://127.0.0.1:${port}/devtools/browser/fake`}:[{id:'page',type:'page',url:page,title:'fixture',attached:false}])); }); const wss=new WebSocketServer({server:http}); wss.on('connection',socket=>socket.on('message',raw=>{ const c=JSON.parse(String(raw)); const event=(method,params)=>socket.send(JSON.stringify({method,params,sessionId:c.sessionId})); let result={}; if(c.method==='Target.attachToTarget')result={sessionId:'session'}; if(c.method==='Page.getFrameTree')result={frameTree:{frame:{id:'main',url:page}}}; if(c.method==='Page.getResourceTree')result={frameTree:{frame:{id:'main',url:page},resources:mode==='malformed-resources'?null:[{url:script,type:'Script',mimeType:'text/javascript',contentSize:20}]}}; if(c.method==='DOMSnapshot.captureSnapshot')result=mode==='malformed-dom'?{}:{strings:[page,'#document',''],documents:[{documentURL:0,title:2,baseURL:0,contentLanguage:2,encodingName:2,publicId:2,systemId:2,frameId:'main',nodes:{nodeType:[9],nodeName:[1],nodeValue:[2],parentIndex:[-1],attributes:[[]],backendNodeId:[1]},layout:{nodeIndex:[],styles:[],bounds:[],text:[],stackingContexts:{index:[]}},textBoxes:{layoutIndex:[],bounds:[],start:[],length:[]}}]}; if(c.method==='Target.getTargets')result=mode==='malformed-workers'?{targetInfos:null}:{targetInfos:[{targetId:'worker-1',type:'worker',title:'sample worker',url:script,attached:false,openerId:'page',parentFrameId:'main',canAccessOpener:false}]}; if(c.method==='Debugger.getScriptSource')result={scriptSource:'globalThis.marker=1;'}; if(c.method==='Runtime.enable')event('Runtime.executionContextCreated',{context:{id:1,origin:'file://',name:'',uniqueId:'context-1',auxData:{frameId:'main',isDefault:true,type:'default'}}}); if(c.method==='Debugger.enable'){ event('Debugger.scriptParsed',{scriptId:'script-1',url:script,startLine:0,startColumn:0,endLine:0,endColumn:20,hash:'sample-hash',length:20,isModule:false,executionContextId:1,scriptLanguage:'JavaScript'}); if(mode==='destroyed-context')event('Runtime.executionContextDestroyed',{executionContextId:1,executionContextUniqueId:'context-1'}); } socket.send(JSON.stringify({id:c.id,sessionId:c.sessionId,result})); })); await new Promise(r=>http.listen(0,'127.0.0.1',r)); const endpoint=`http://127.0.0.1:${http.address().port}`; try { for(mode of ['control','destroyed-context']){ const {code,stdout,stderr}=await new Promise(resolve=>{ const p=spawn(process.execPath,['scripts/rea.mjs','inspect-electron-page',endpoint,'page','--observation-ms','100','--json'],{cwd:checkout,env:{...process.env,HOME:root+'/home'}}); let stdout='',stderr='';p.stdout.on('data',x=>stdout+=x);p.stderr.on('data',x=>stderr+=x);p.on('exit',code=>resolve({code,stdout,stderr})); }); await writeFile(root+'/'+mode+'.json',stdout); const output=JSON.parse(stdout); const x=output.normalized_result; console.log(JSON.stringify({mode,exit_code:code,frame_id:x?.scripts.items[0]?.frame_id,script_key:x?.scripts.items[0]?.script_key,dom_nodes:x?.dom.total_nodes,resources:x?.resources.length,workers:x?.workers.length,completeness:x?.completeness})); } }finally{ for(const client of wss.clients)client.terminate(); await new Promise(r=>wss.close(r)); await new Promise(r=>http.close(r)); } JS ``` 3. Run the fixture. It starts a loopback HTTP/WebSocket endpoint, invokes the public CLI twice, saves each result as `sample-observation/control.json` and `sample-observation/destroyed-context.json`, and closes the endpoint and sockets before exiting. ```sh node sample-observation/sample-repro.mjs "$PWD" ``` The underlying command is `node scripts/rea.mjs inspect-electron-page ENDPOINT page --observation-ms 100 --json`, with `ENDPOINT` supplied by the fixture and an isolated home directory. Both runs receive the same local page, frame, context, and script metadata. The second run additionally receives `Runtime.executionContextDestroyed` after `Debugger.scriptParsed`. ### Expected behavior `docs/electron-observation.md:51-54` promises stable script/resource identities and retention of execution-context frame IDs supplied by CDP. The script should retain `frame_id: "main"` after its context is destroyed because CDP already supplied that relationship while the script was observed. Its identity should remain the same for the same retained script metadata. Script-source filtering should not discard frame attribution. ### Impact and scope Impact level: P2. Assessment status: supported. Observed script-to-frame attribution is discarded and its identity changes under a supported context lifecycle event. This affects the usefulness and comparability of passive Electron script Evidence; broader occurrence on real applications was not measured. Workaround status: unverified. Capturing before context destruction may avoid the triggering sequence, but this depends on target timing and was not established as a practical way to retain the requested observations. Omitting script sources was executed and did not avoid the loss. ### REA version 6.4.0 (main at 8dde6370d0809ad04cda3342d4e6f290f48aa063) ### Runtime and platform Node.js v26.10.0; macOS 27.0.1. Architecture was not recorded for this reproduction. ### Target and artifact context A simulated CDP producer serves one ordinary local file page and one local JavaScript file, a valid main frame, one execution context, and one parsed script. The fixture sends the context-destroyed event only in the second run. Inspection uses the built runtime through the public CLI; no Electron installation or application JavaScript execution is needed. The retained artifacts are the full CLI JSON results for both modes. ### Interface CLI ### Earliest failure stage Transport or result projection ### Relevant output or evidence The following selected fields summarize the observed public CLI results. Evidence provenance: observed. Local verification: reproduced. Reproduction completeness: complete. Exact script-key hashes were not recorded here; the observed comparison established that they differed. ```text control: exit_code: 0 frame_id: "main" destroyed-context: exit_code: 0 frame_id: null script_key: differs from control Both results retain the authorized frame. Both results have only the expected script_sources policy exclusion. Neither result has an unavailable section or a dropped script event. With --include-script-sources, the same loss occurs and status is complete_within_window. ``` The executed CLI fixture results establish the violation. The source locators below explain the observed boundary. ### Typed outcome or recovery expected Run both fixture modes against the same local files. Each should retain the supplied `frame_id: "main"`, and their script keys should agree. Keep the control's existing source-filtering behavior. Also preserve `frame_id: null` when CDP never supplies a usable frame relationship; restoration must not invent attribution for genuinely unknown contexts. ### Analysis or suspected cause Source-derived reasoning: `src/browser/CdpElectronScriptEvents.ts:31-35` removes the context mapping after `Runtime.executionContextDestroyed`. `src/browser/CdpElectronScripts.ts:38-53` consults that later mapping when projecting the script and hashing its identity. The surviving script consequently loses its previously supplied frame ID, and the changed identity input changes its script key. This source-derived explanation is separate from the observed CLI results. ### Additional context Related completed issue [#1500](https://github.com/morluto/rea/issues/1500) addresses frame attribution changing after context-ID reuse in browser page capture; this report reproduces attribution loss after context destruction in the Electron inspector's separate implementation. The earlier issue was closed as completed, so it does not establish a by-design exception to retained frame attribution. Related completed issue [#2556](https://github.com/morluto/rea/issues/2556) concerns malformed Electron child frame IDs being classified as scope exclusions; this report supplies valid identities and loses an already observed relationship after a lifecycle event. Related issue [#3192](https://github.com/morluto/rea/issues/3192) concerns false absence states for ambiguous static reconciliation candidates; this report changes captured script metadata before reconciliation. Related issue [#3316](https://github.com/morluto/rea/issues/3316) concerns a unique alternate static digest match hiding a mapped-path mismatch; this report loses captured frame attribution and changes script identity. Related completed issue [#2625](https://github.com/morluto/rea/issues/2625) concerns parse-gap attribution to unaffected web-bundle scripts; this reproduction does not parse script source and concerns Electron script lifecycle retention.
Author
Owner

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

This appears fixed on main by 6b991d3aa (fix(electron): snapshot script frame identity at ingestion, 2026-10-16). Script frame identity is now captured when Debugger.scriptParsed is ingested, instead of being looked up from the live context at projection time.

Executed on main ae029fe2f (macOS arm64): npm run test:local -- src/browser/CdpElectronInspection.test.ts → 3/3 passed. That includes preserves script records across context teardown and ID reuse, added by that commit. It parses a script in context 7 (frame-old), destroys context 7, reuses the ID for frame-new, and asserts both scripts keep their own frame_id and distinct script_keys. Scripts whose context is genuinely unknown still report frame_id: null.

I did not rerun this report's public CLI fixture, and no real Electron producer was used. Leaving closure to a maintainer.

<!-- gh-comment-id:6233031359 --> @kaoru0822-kitauji commented on GitHub (Oct 17, 2026): This appears fixed on main by 6b991d3aa (`fix(electron): snapshot script frame identity at ingestion`, 2026-10-16). Script frame identity is now captured when `Debugger.scriptParsed` is ingested, instead of being looked up from the live context at projection time. Executed on main ae029fe2f (macOS arm64): `npm run test:local -- src/browser/CdpElectronInspection.test.ts` → 3/3 passed. That includes `preserves script records across context teardown and ID reuse`, added by that commit. It parses a script in context 7 (`frame-old`), destroys context 7, reuses the ID for `frame-new`, and asserts both scripts keep their own `frame_id` and distinct `script_key`s. Scripts whose context is genuinely unknown still report `frame_id: null`. I did not rerun this report's public CLI fixture, and no real Electron producer was used. Leaving closure to a maintainer.
Author
Owner

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

Addressed on main by github.com/morluto/rea@6b991d3aa7. Parsed scripts now retain their observed frame at ingestion, so destroying or reusing the execution context no longer changes the recorded frame attribution or the script identity derived from it. Unknown and out-of-scope frame mappings remain null. I checked the current source and the included regression coverage; I have not rerun the CLI fixture. Closing as completed.

<!-- gh-comment-id:6244131451 --> @coygeek commented on GitHub (Oct 18, 2026): Addressed on main by https://github.com/morluto/rea/commit/6b991d3aa73b5930c89a8620b912c9ac76c29f13. Parsed scripts now retain their observed frame at ingestion, so destroying or reusing the execution context no longer changes the recorded frame attribution or the script identity derived from it. Unknown and out-of-scope frame mappings remain null. I checked the current source and the included regression coverage; I have not rerun the CLI fixture. Closing as completed.
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#1250
No description provided.