[GH-ISSUE #5756] [Bug] observe-web-execution loses the target-termination reason when cleanup fails #3500

Closed
opened 2026-10-31 00:41:12 +00:00 by bsakaguchi · 4 comments
Owner

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

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

Closing the selected Chrome page after observe-web-execution reports its observation window armed returns cleanup_incomplete, but the failure omits the target-termination reason. Its main diagnostic instead names a failed Profiler.stopPreciseCoverage command. The selected target ID and failed cleanup commands survive; the fact that target termination ended the window does not.

The independent verifier reproduced this in four closed-page trials at the baseline. The submitter replayed the exact reproduction below after rebuilding fetched main: both closed-page trials reproduced it, while the open-page control succeeded. Unconfirmed cleanup is correctly a failure; this report concerns the missing termination fact.

Reproduction

Run from the built source checkout with its locked dependencies installed. Set REA_BROWSER_EXECUTABLE to a Chrome executable. This executed script creates a loopback page, separate headless Chrome and disposable profile. A native-WebSocket CDP client closes each selected page only after actual CLI armed progress. The script stops only its recorded Chrome and HTTP-server PIDs; it never attaches to an existing browser. REA runs through env -i with isolated HOME/XDG/temporary directories.

Complete reproduction, including an open-page control and two closed-page trials
work_dir="$(mktemp -d)"
cat > "$work_dir/reproduce.mjs" <<'JS'
import { spawn } from 'node:child_process';
import { mkdirSync, writeFileSync, openSync, readFileSync } from 'node:fs';
import { createServer } from 'node:net';
import assert from 'node:assert/strict';
const S = new URL('.', import.meta.url).pathname;
const repo = process.cwd();
const delay = ms => new Promise(r => setTimeout(r, ms));
const freePort = async () => { const server=createServer(); await new Promise(r=>server.listen(0,'127.0.0.1',r)); const p=server.address().port; await new Promise(r=>server.close(r)); return p; };
for (const d of ['site','chrome-profile','home','cfg','data','cache','state','tmp']) mkdirSync(S+d,{recursive:true});
writeFileSync(S+'site/index.html','<!doctype html><html><body><button id="b">button</button><script>function tick(){return 1;} setInterval(tick,100);</script></body></html>');
const httpPort=await freePort(), cdpPort=await freePort();
const origin=`http://127.0.0.1:${httpPort}`, endpoint=`http://127.0.0.1:${cdpPort}`;
const launch=(exe,args,name)=>spawn(exe,args,{stdio:['ignore',openSync(S+name+'.stdout','w'),openSync(S+name+'.stderr','w')]});
assert(process.env.REA_BROWSER_EXECUTABLE, 'Set REA_BROWSER_EXECUTABLE to Chrome');
const http=launch('python3',['-I','-m','http.server','--bind','127.0.0.1',String(httpPort),'--directory',S+'site'],'http');
const chrome=launch(process.env.REA_BROWSER_EXECUTABLE,['--headless=new',`--user-data-dir=${S}chrome-profile`,`--remote-debugging-port=${cdpPort}`,'--no-first-run','--no-default-browser-check','about:blank'],'chrome');
writeFileSync(S+'pids.json',JSON.stringify({http:http.pid,chrome:chrome.pid,httpPort,cdpPort}));
let client;
const stopped=[];
async function stop(proc,name) { if(proc.exitCode===null && proc.signalCode===null) proc.kill('SIGTERM'); await Promise.race([new Promise(r=>proc.once('exit',r)),delay(3000)]); if(proc.exitCode===null&&proc.signalCode===null) {proc.kill('SIGKILL');await new Promise(r=>proc.once('exit',r));} stopped.push({name,pid:proc.pid,exitCode:proc.exitCode,signal:proc.signalCode}); }
const observations=[];
try {
 let version;
 for(let i=0;i<150;i++){try{version=await(await fetch(endpoint+'/json/version')).json();break;}catch{await delay(100);}}
 assert(version,'Chrome endpoint ready');
 for(let i=0;i<50;i++){try{await fetch(origin);break;}catch{await delay(100);}}
 // Independent CDP client: native WebSocket only, no REA imports.
 const socket=new WebSocket(version.webSocketDebuggerUrl);
 await new Promise((resolve,reject)=>{socket.onopen=resolve;socket.onerror=reject;});
 let id=0; const pending=new Map(),events=[];
 socket.onmessage=event=>{const m=JSON.parse(event.data);if(m.id){const p=pending.get(m.id);pending.delete(m.id);if(m.error)p.reject(new Error(JSON.stringify(m.error)));else p.resolve(m.result);}else events.push(m);};
 client={socket,send(method,params={},sessionId){return new Promise((resolve,reject)=>{const n=++id;pending.set(n,{resolve,reject});socket.send(JSON.stringify({id:n,method,params,...(sessionId?{sessionId}:{})}));});}};
 await client.send('Target.setDiscoverTargets',{discover:true});
 async function trial(name,close) {
  const {targetId}=await client.send('Target.createTarget',{url:origin+'/index.html'});
  const {sessionId}=await client.send('Target.attachToTarget',{targetId,flatten:true});
  let tree;
  for(let i=0;i<100;i++){tree=await client.send('Page.getFrameTree',{},sessionId);if(tree.frameTree.frame.url===origin+'/index.html' && tree.frameTree.frame.loaderId)break;await delay(30);}
  assert.equal(tree.frameTree.frame.url,origin+'/index.html');
  const env={PATH:process.env.PATH,HOME:S+'home',XDG_CONFIG_HOME:S+'cfg',XDG_DATA_HOME:S+'data',XDG_CACHE_HOME:S+'cache',XDG_STATE_HOME:S+'state',TMPDIR:S+'tmp',REA_LOG_LEVEL:'silent'};
  const child=spawn('env',['-i',...Object.entries(env).map(([k,v])=>`${k}=${v}`),process.execPath,'scripts/rea.mjs','observe-web-execution',endpoint,targetId,'--observation-ms',close?'30000':'1000','--json'],{cwd:repo,stdio:['ignore','pipe','pipe']});
  let stdout='',stderr='',armed=false,closeResult,closedAt;
  child.stdout.on('data',b=>stdout+=b); child.stderr.on('data',b=>stderr+=b);
  const exited=new Promise(r=>child.once('exit',(code,signal)=>r({code,signal})));
  for(let i=0;i<300;i++){if(stderr.split('\n').some(line=>{try{const p=JSON.parse(line).rea_progress;return p?.phase==='browser_execution'&&p.completed===1;}catch{return false;}})){armed=true;break;}if(child.exitCode!==null)break;await delay(20);}
  assert(armed,`Actual arming for ${name}: ${stderr}`);
  if(close){closedAt=performance.now();closeResult=await client.send('Target.closeTarget',{targetId});assert.equal(closeResult.success,true);}
  const timeout=setTimeout(()=>child.kill('SIGTERM'),15000);
  const exit=await exited;clearTimeout(timeout);
  const elapsedAfterCloseMs=closedAt?performance.now()-closedAt:null;
  writeFileSync(S+name+'.json',stdout);writeFileSync(S+name+'.stderr',stderr);
  const targets=await client.send('Target.getTargets');
  const rootVersion=await client.send('Browser.getVersion');
  const data=JSON.parse(stdout);
  observations.push({name,targetId,armed,exit,elapsedAfterCloseMs,closeResult,targetPresent:targets.targetInfos.some(t=>t.targetId===targetId),rootStillResponsive:rootVersion.product,events:events.filter(e=>e.params?.targetId===targetId||e.params?.sessionId===sessionId),result:data});
  if(!close)await client.send('Target.closeTarget',{targetId});
 }
 await trial('control-open',false);
 await trial('closed-1',true);
 await trial('closed-2',true);
 writeFileSync(S+'observations.json',JSON.stringify({version,observations},null,2));
 console.log(JSON.stringify(observations.map(o=>({name:o.name,exit:o.exit.code,targetPresent:o.targetPresent,rootStillResponsive:o.rootStillResponsive,code:o.result.code,message:o.result.message,window:o.result.normalized_result?.window.end_reason,coverage:o.result.normalized_result?.coverage.state,hasTerminationReason:JSON.stringify(o.result).includes('target_terminated')})),null,2));
} finally { client?.socket.close();await stop(chrome,'chrome');await stop(http,'http');writeFileSync(S+'stopped.json',JSON.stringify(stopped,null,2)); }
JS
node "$work_dir/reproduce.mjs"

Inspect closed-1.json, closed-2.json and observations.json in the generated work directory. Each closed trial requests 30000 ms but ends promptly after closure; the control requests 1000 ms and keeps the page open.

Expected behavior

When the selected target detaches and its dead session cannot acknowledge instrumentation cleanup, retain the original producer reason alongside the selected target and failed cleanup commands. The caller must be able to tell that target termination ended the observation. Keep unconfirmed cleanup as a failure; no particular outer error code or nested representation is required.

The closed-target contract explicitly promises those facts. The window outcome schema includes target_terminated.

Impact and scope

P2: bounded diagnostic loss makes a page closure indistinguishable from an unrelated stop-coverage command failure without separate browser evidence. All four baseline and both rebuilt-main closed-page trials reproduced the omission; the two baseline open controls and rebuilt-main open control succeeded. A caller with an independent CDP event recorder can establish closure, as this reproduction does. Re-listing a missing target afterward cannot by itself recover the original window-ending event.

No target damage, captured-data corruption, or security boundary crossing was observed. Runtime coverage is CLI with native Chrome on macOS; MCP, browser crashes, whole-browser disconnects and other engines/platforms were not executed.

REA version

rea-agents 6.7.0, independently verified source baseline 9d8e7803326a382cbbb3ffdb900f5e93e5442d68. Rebuilt with npm run build:cached and replayed on fetched main d0067ba34a39ea47dd7e11bb2e6169bc0938cc3f (also 6.7.0): still reproduces. The lockfile is unchanged between these revisions. These are source-checkout reproductions.

Runtime and platform

Node.js v26.12.0; macOS 27.0.1 arm64. Baseline: Google Chrome 156.0.8078.47. Rebuilt-main replay: separate official Google Chrome 156.0.8078.48, headless with a throwaway profile. Python 3 serves the loopback fixture.

Target and artifact context

Self-authored HTTP page on 127.0.0.1 with a button and timer; one selected page per trial, browser-scoped loopback CDP endpoint, native V8 precise coverage. No user profile, private artifact or external API is involved in the reproduction.

Interface

CLI

Earliest failure stage

Cleanup, cancellation, or lifecycle

Relevant output or evidence

Executed public command inside the isolated child:

node scripts/rea.mjs observe-web-execution ENDPOINT TARGET --observation-ms 30000 --json

Each rebuilt-main closed trial exits 1. Selected JSON fields:

{
  "code": "cleanup_incomplete",
  "category": "execution_failure",
  "message": "CDP Profiler.stopPreciseCoverage failed (-32001): Session with given id not found.",
  "details": {
    "cleanup": "incomplete",
    "diagnostics": {
      "primary_error": {
        "code": "unreadable_output",
        "details": { "operation": "observe_web_execution", "reason": "protocol_error" }
      }
    }
  }
}

The complete envelope also retains the exact selected target ID, seven failed cleanup commands, and previous_error repeating the stop-coverage command failure. It contains neither target_terminated nor another explicit termination explanation. Independent CDP returned success: true for both Target.closeTarget calls, emitted Target.detachedFromTarget and Target.targetDestroyed for each exact selected target, showed it absent from Target.getTargets, and still answered Browser.getVersion on the root connection. Both calls completed promptly after closure.

The open control exits 0 with window.end_reason: window_elapsed, coverage.state: captured, instrumentation.cleanup: confirmed, and the target still present. Evidence provenance: observed public CLI and independent CDP; baseline independently verified, main replay executed by the submitter.

Typed outcome or recovery expected

Keep a non-success failure with unconfirmed cleanup and failed-command diagnostics, while explicitly retaining target termination as the observation-ending reason. The open control must continue to succeed. Do not label unrelated protocol or cleanup failures on still-open pages as termination. This is a proposed restoration check; no fix was tested.

Analysis or suspected cause

Source-derived explanation: CdpExecutionCapture.ts:53-68 receives the window reason, then sends Profiler.stopPreciseCoverage before constructing a result retaining that reason. Its rejection becomes the capture failure. CdpRuntimeSession.ts:269-276 retains that command failure as previous_error; CdpWebRuntimeProvider.ts:95-125 composes it with cleanup failure. The window reason was lost earlier. No bundled dependency was identified as the cause.

The existing synthetic termination test acknowledges cleanup and expects target_terminated in a successful unavailable-coverage observation. The native termination check requires a failure, target ID and failed cleanup command, but does not assert preservation of the original reason.

Additional context

Related #5440 concerns retained partial Evidence incorrectly claiming confirmed cleanup after successful capture. This case fails before constructing that observation and concerns the missing termination fact; correcting partial-record cleanup state alone does not restore it. Closed #5089 preserved caller abort causes, a different terminal path. #5724 and #5725 concern reload outcomes in page inspection rather than selected-target closure during execution observation. Merged #917 introduced this operation and documented reason preservation, but current main still omits the reason in the real closed-session case above.

Prepared with AI assistance and independent verification. NOT_ENOUGH_INFO: no last-known-good version or regression range was established.

Originally created by @coygeek on GitHub (Oct 21, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/5756 ### 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 Closing the selected Chrome page after `observe-web-execution` reports its observation window armed returns `cleanup_incomplete`, but the failure omits the target-termination reason. Its main diagnostic instead names a failed `Profiler.stopPreciseCoverage` command. The selected target ID and failed cleanup commands survive; the fact that target termination ended the window does not. The independent verifier reproduced this in four closed-page trials at the baseline. The submitter replayed the exact reproduction below after rebuilding fetched main: both closed-page trials reproduced it, while the open-page control succeeded. Unconfirmed cleanup is correctly a failure; this report concerns the missing termination fact. ### Reproduction Run from the built source checkout with its locked dependencies installed. Set `REA_BROWSER_EXECUTABLE` to a Chrome executable. This executed script creates a loopback page, separate headless Chrome and disposable profile. A native-WebSocket CDP client closes each selected page only after actual CLI armed progress. The script stops only its recorded Chrome and HTTP-server PIDs; it never attaches to an existing browser. REA runs through `env -i` with isolated HOME/XDG/temporary directories. <details> <summary>Complete reproduction, including an open-page control and two closed-page trials</summary> ```sh work_dir="$(mktemp -d)" cat > "$work_dir/reproduce.mjs" <<'JS' import { spawn } from 'node:child_process'; import { mkdirSync, writeFileSync, openSync, readFileSync } from 'node:fs'; import { createServer } from 'node:net'; import assert from 'node:assert/strict'; const S = new URL('.', import.meta.url).pathname; const repo = process.cwd(); const delay = ms => new Promise(r => setTimeout(r, ms)); const freePort = async () => { const server=createServer(); await new Promise(r=>server.listen(0,'127.0.0.1',r)); const p=server.address().port; await new Promise(r=>server.close(r)); return p; }; for (const d of ['site','chrome-profile','home','cfg','data','cache','state','tmp']) mkdirSync(S+d,{recursive:true}); writeFileSync(S+'site/index.html','<!doctype html><html><body><button id="b">button</button><script>function tick(){return 1;} setInterval(tick,100);</script></body></html>'); const httpPort=await freePort(), cdpPort=await freePort(); const origin=`http://127.0.0.1:${httpPort}`, endpoint=`http://127.0.0.1:${cdpPort}`; const launch=(exe,args,name)=>spawn(exe,args,{stdio:['ignore',openSync(S+name+'.stdout','w'),openSync(S+name+'.stderr','w')]}); assert(process.env.REA_BROWSER_EXECUTABLE, 'Set REA_BROWSER_EXECUTABLE to Chrome'); const http=launch('python3',['-I','-m','http.server','--bind','127.0.0.1',String(httpPort),'--directory',S+'site'],'http'); const chrome=launch(process.env.REA_BROWSER_EXECUTABLE,['--headless=new',`--user-data-dir=${S}chrome-profile`,`--remote-debugging-port=${cdpPort}`,'--no-first-run','--no-default-browser-check','about:blank'],'chrome'); writeFileSync(S+'pids.json',JSON.stringify({http:http.pid,chrome:chrome.pid,httpPort,cdpPort})); let client; const stopped=[]; async function stop(proc,name) { if(proc.exitCode===null && proc.signalCode===null) proc.kill('SIGTERM'); await Promise.race([new Promise(r=>proc.once('exit',r)),delay(3000)]); if(proc.exitCode===null&&proc.signalCode===null) {proc.kill('SIGKILL');await new Promise(r=>proc.once('exit',r));} stopped.push({name,pid:proc.pid,exitCode:proc.exitCode,signal:proc.signalCode}); } const observations=[]; try { let version; for(let i=0;i<150;i++){try{version=await(await fetch(endpoint+'/json/version')).json();break;}catch{await delay(100);}} assert(version,'Chrome endpoint ready'); for(let i=0;i<50;i++){try{await fetch(origin);break;}catch{await delay(100);}} // Independent CDP client: native WebSocket only, no REA imports. const socket=new WebSocket(version.webSocketDebuggerUrl); await new Promise((resolve,reject)=>{socket.onopen=resolve;socket.onerror=reject;}); let id=0; const pending=new Map(),events=[]; socket.onmessage=event=>{const m=JSON.parse(event.data);if(m.id){const p=pending.get(m.id);pending.delete(m.id);if(m.error)p.reject(new Error(JSON.stringify(m.error)));else p.resolve(m.result);}else events.push(m);}; client={socket,send(method,params={},sessionId){return new Promise((resolve,reject)=>{const n=++id;pending.set(n,{resolve,reject});socket.send(JSON.stringify({id:n,method,params,...(sessionId?{sessionId}:{})}));});}}; await client.send('Target.setDiscoverTargets',{discover:true}); async function trial(name,close) { const {targetId}=await client.send('Target.createTarget',{url:origin+'/index.html'}); const {sessionId}=await client.send('Target.attachToTarget',{targetId,flatten:true}); let tree; for(let i=0;i<100;i++){tree=await client.send('Page.getFrameTree',{},sessionId);if(tree.frameTree.frame.url===origin+'/index.html' && tree.frameTree.frame.loaderId)break;await delay(30);} assert.equal(tree.frameTree.frame.url,origin+'/index.html'); const env={PATH:process.env.PATH,HOME:S+'home',XDG_CONFIG_HOME:S+'cfg',XDG_DATA_HOME:S+'data',XDG_CACHE_HOME:S+'cache',XDG_STATE_HOME:S+'state',TMPDIR:S+'tmp',REA_LOG_LEVEL:'silent'}; const child=spawn('env',['-i',...Object.entries(env).map(([k,v])=>`${k}=${v}`),process.execPath,'scripts/rea.mjs','observe-web-execution',endpoint,targetId,'--observation-ms',close?'30000':'1000','--json'],{cwd:repo,stdio:['ignore','pipe','pipe']}); let stdout='',stderr='',armed=false,closeResult,closedAt; child.stdout.on('data',b=>stdout+=b); child.stderr.on('data',b=>stderr+=b); const exited=new Promise(r=>child.once('exit',(code,signal)=>r({code,signal}))); for(let i=0;i<300;i++){if(stderr.split('\n').some(line=>{try{const p=JSON.parse(line).rea_progress;return p?.phase==='browser_execution'&&p.completed===1;}catch{return false;}})){armed=true;break;}if(child.exitCode!==null)break;await delay(20);} assert(armed,`Actual arming for ${name}: ${stderr}`); if(close){closedAt=performance.now();closeResult=await client.send('Target.closeTarget',{targetId});assert.equal(closeResult.success,true);} const timeout=setTimeout(()=>child.kill('SIGTERM'),15000); const exit=await exited;clearTimeout(timeout); const elapsedAfterCloseMs=closedAt?performance.now()-closedAt:null; writeFileSync(S+name+'.json',stdout);writeFileSync(S+name+'.stderr',stderr); const targets=await client.send('Target.getTargets'); const rootVersion=await client.send('Browser.getVersion'); const data=JSON.parse(stdout); observations.push({name,targetId,armed,exit,elapsedAfterCloseMs,closeResult,targetPresent:targets.targetInfos.some(t=>t.targetId===targetId),rootStillResponsive:rootVersion.product,events:events.filter(e=>e.params?.targetId===targetId||e.params?.sessionId===sessionId),result:data}); if(!close)await client.send('Target.closeTarget',{targetId}); } await trial('control-open',false); await trial('closed-1',true); await trial('closed-2',true); writeFileSync(S+'observations.json',JSON.stringify({version,observations},null,2)); console.log(JSON.stringify(observations.map(o=>({name:o.name,exit:o.exit.code,targetPresent:o.targetPresent,rootStillResponsive:o.rootStillResponsive,code:o.result.code,message:o.result.message,window:o.result.normalized_result?.window.end_reason,coverage:o.result.normalized_result?.coverage.state,hasTerminationReason:JSON.stringify(o.result).includes('target_terminated')})),null,2)); } finally { client?.socket.close();await stop(chrome,'chrome');await stop(http,'http');writeFileSync(S+'stopped.json',JSON.stringify(stopped,null,2)); } JS node "$work_dir/reproduce.mjs" ``` </details> Inspect `closed-1.json`, `closed-2.json` and `observations.json` in the generated work directory. Each closed trial requests 30000 ms but ends promptly after closure; the control requests 1000 ms and keeps the page open. ### Expected behavior When the selected target detaches and its dead session cannot acknowledge instrumentation cleanup, retain the original producer reason alongside the selected target and failed cleanup commands. The caller must be able to tell that target termination ended the observation. Keep unconfirmed cleanup as a failure; no particular outer error code or nested representation is required. The [closed-target contract](https://github.com/morluto/rea/blob/d0067ba34a39ea47dd7e11bb2e6169bc0938cc3f/docs/web-runtime.md#L35) explicitly promises those facts. The [window outcome schema](https://github.com/morluto/rea/blob/d0067ba34a39ea47dd7e11bb2e6169bc0938cc3f/src/domain/webExecution.ts#L39-L43) includes `target_terminated`. ### Impact and scope P2: bounded diagnostic loss makes a page closure indistinguishable from an unrelated stop-coverage command failure without separate browser evidence. All four baseline and both rebuilt-main closed-page trials reproduced the omission; the two baseline open controls and rebuilt-main open control succeeded. A caller with an independent CDP event recorder can establish closure, as this reproduction does. Re-listing a missing target afterward cannot by itself recover the original window-ending event. No target damage, captured-data corruption, or security boundary crossing was observed. Runtime coverage is CLI with native Chrome on macOS; MCP, browser crashes, whole-browser disconnects and other engines/platforms were not executed. ### REA version `rea-agents` 6.7.0, independently verified source baseline `9d8e7803326a382cbbb3ffdb900f5e93e5442d68`. Rebuilt with `npm run build:cached` and replayed on fetched main `d0067ba34a39ea47dd7e11bb2e6169bc0938cc3f` (also 6.7.0): still reproduces. The lockfile is unchanged between these revisions. These are source-checkout reproductions. ### Runtime and platform Node.js v26.12.0; macOS 27.0.1 arm64. Baseline: Google Chrome 156.0.8078.47. Rebuilt-main replay: separate official Google Chrome 156.0.8078.48, headless with a throwaway profile. Python 3 serves the loopback fixture. ### Target and artifact context Self-authored HTTP page on `127.0.0.1` with a button and timer; one selected page per trial, browser-scoped loopback CDP endpoint, native V8 precise coverage. No user profile, private artifact or external API is involved in the reproduction. ### Interface CLI ### Earliest failure stage Cleanup, cancellation, or lifecycle ### Relevant output or evidence Executed public command inside the isolated child: ```sh node scripts/rea.mjs observe-web-execution ENDPOINT TARGET --observation-ms 30000 --json ``` Each rebuilt-main closed trial exits 1. Selected JSON fields: ```json { "code": "cleanup_incomplete", "category": "execution_failure", "message": "CDP Profiler.stopPreciseCoverage failed (-32001): Session with given id not found.", "details": { "cleanup": "incomplete", "diagnostics": { "primary_error": { "code": "unreadable_output", "details": { "operation": "observe_web_execution", "reason": "protocol_error" } } } } } ``` The complete envelope also retains the exact selected target ID, seven failed cleanup commands, and `previous_error` repeating the stop-coverage command failure. It contains neither `target_terminated` nor another explicit termination explanation. Independent CDP returned `success: true` for both `Target.closeTarget` calls, emitted `Target.detachedFromTarget` and `Target.targetDestroyed` for each exact selected target, showed it absent from `Target.getTargets`, and still answered `Browser.getVersion` on the root connection. Both calls completed promptly after closure. The open control exits 0 with `window.end_reason: window_elapsed`, `coverage.state: captured`, `instrumentation.cleanup: confirmed`, and the target still present. Evidence provenance: observed public CLI and independent CDP; baseline independently verified, main replay executed by the submitter. ### Typed outcome or recovery expected Keep a non-success failure with unconfirmed cleanup and failed-command diagnostics, while explicitly retaining target termination as the observation-ending reason. The open control must continue to succeed. Do not label unrelated protocol or cleanup failures on still-open pages as termination. This is a proposed restoration check; no fix was tested. ### Analysis or suspected cause Source-derived explanation: [CdpExecutionCapture.ts:53-68](https://github.com/morluto/rea/blob/d0067ba34a39ea47dd7e11bb2e6169bc0938cc3f/src/browser/execution/CdpExecutionCapture.ts#L53-L68) receives the window reason, then sends `Profiler.stopPreciseCoverage` before constructing a result retaining that reason. Its rejection becomes the capture failure. [CdpRuntimeSession.ts:269-276](https://github.com/morluto/rea/blob/d0067ba34a39ea47dd7e11bb2e6169bc0938cc3f/src/browser/execution/CdpRuntimeSession.ts#L269-L276) retains that command failure as `previous_error`; [CdpWebRuntimeProvider.ts:95-125](https://github.com/morluto/rea/blob/d0067ba34a39ea47dd7e11bb2e6169bc0938cc3f/src/browser/execution/CdpWebRuntimeProvider.ts#L95-L125) composes it with cleanup failure. The window reason was lost earlier. No bundled dependency was identified as the cause. The existing synthetic [termination test](https://github.com/morluto/rea/blob/d0067ba34a39ea47dd7e11bb2e6169bc0938cc3f/tests/boundary/browser/cdpWebRuntimeTermination.test.ts#L45-L74) acknowledges cleanup and expects `target_terminated` in a successful unavailable-coverage observation. The [native termination check](https://github.com/morluto/rea/blob/d0067ba34a39ea47dd7e11bb2e6169bc0938cc3f/scripts/lib/browser-runtime-termination-e2e.mjs#L57-L68) requires a failure, target ID and failed cleanup command, but does not assert preservation of the original reason. ### Additional context Related #5440 concerns retained partial Evidence incorrectly claiming confirmed cleanup after successful capture. This case fails before constructing that observation and concerns the missing termination fact; correcting partial-record cleanup state alone does not restore it. Closed #5089 preserved caller abort causes, a different terminal path. #5724 and #5725 concern reload outcomes in page inspection rather than selected-target closure during execution observation. Merged #917 introduced this operation and documented reason preservation, but current main still omits the reason in the real closed-session case above. Prepared with AI assistance and independent verification. NOT_ENOUGH_INFO: no last-known-good version or regression range was established.
Author
Owner

@aron-intframe commented on GitHub (Oct 21, 2026):

Reproduced on main 335c042d (6.7.0) with real Chrome 153 on Linux x64 / Node 24.21.0, and the dropped fact is recoverable without changing the failure contract.

Cause

On a closed page the first thing that fails is not cleanup, it is Profiler.stopPreciseCoverage inside the capture itself, at src/browser/execution/CdpExecutionCapture.ts:67. By then the window has already resolved with target_terminated (set in CdpExecutionWindow.ts:32), but the detached flat session rejects that command, so captureWebExecution throws a protocol error and never builds the envelope that would carry window.end_reason. CdpWebRuntimeProvider.run then composes the throw as the primary error together with the cleanup failure (CdpWebRuntimeProvider.ts:125 to analysisErrorWithCleanupFailure), which is why the top-level message, diagnostics.primary_error and cleanup_error.diagnostics.previous_error all name the stop-coverage command. The termination reason lives only in CdpExecutionWindow.reason and is never read again, so the "original producer reason" promised by docs/web-runtime.md is gone while the target ID and failed cleanup commands survive, exactly as reported.

There is an asymmetry in the same function: a termination before arming is reported with its reason (CdpExecutionCapture.ts:39-42 throws target_changed with "ended with ${window.reason} before execution observation could be armed"), while a termination after arming is not.

Evidence

Your script, unmodified except that Chrome runs through a one-line wrapper adding --no-sandbox (this host disables unprivileged user namespaces). Both closed trials reproduce, the open control passes:

  {
    "name": "closed-1",
    "exit": 1,
    "targetPresent": false,
    "rootStillResponsive": "Chrome/153.0.8010.12",
    "code": "cleanup_incomplete",
    "message": "CDP Profiler.stopPreciseCoverage failed (-32001): Session with given id not found.",
    "hasTerminationReason": false
  },

closed-1.json in detail:

primary_error.code: unreadable_output
primary_error.details: {"operation": "observe_web_execution", "reason": "protocol_error"}
cleanup target_id: F875734A4A8C0D4E48B7E227A572186A
cleanup_failures count: 7
previous_error: CDP Profiler.stopPreciseCoverage failed (-32001): Session with given id not found.
has target_terminated anywhere: False

The same failure also reproduces without a browser, at the provider boundary, by making the fixture's detached session reject commands the way Chrome does (commandError returning -32001 once Target.detachedFromTarget is emitted):

hasTerminationReason: false
code: cleanup_incomplete
message: CDP Profiler.stopPreciseCoverage failed (-32001): Session with given id not found.

That seam is also why no current test fails: tests/fixtures/webRuntime.ts:29-30 answers Profiler.stopPreciseCoverage with {} whatever the session state, so cdpWebRuntimeTermination.test.ts "ends immediately on its root detachment" takes a success path that a real closed page cannot take.

Fix that worked here

Convert the dead-session rejection into the termination fact only when the window actually ended that way, leaving every other stop-coverage failure untouched:

    collection.freezeMetadata();
    try {
      await session.command("Profiler.stopPreciseCoverage");
    } catch (cause: unknown) {
      if (reason !== "target_terminated") throw cause;
      throw new BrowserObservationError(session.operation, "target_changed", {
        detail: `Selected target ${session.target.target_id} ended with ${reason}; its detached session could not acknowledge instrumentation cleanup.`,
        cause,
      });
    }

Same script, same box, after rebuilding:

code: cleanup_incomplete
primary_error.code: target_unavailable
primary_error.details: {"operation": "observe_web_execution", "reason": "target_changed"}
cleanup target_id: 54E8D691DEA2F95D4D93DB0E441EC6D5
cleanup_failures count: 7
previous_error: Selected target 54E8D691DEA2F95D4D93DB0E441EC6D5 ended with target_terminated; its detached session could not acknowledge instrumentation cleanup.
has target_terminated anywhere: True

Unconfirmed cleanup stays a failure: the outer code is still cleanup_incomplete, the selected target and all 7 failed cleanup commands are still listed, and the open-page control is untouched (window_elapsed, coverage captured). npx turbo run typecheck is clean, src/browser is 723 passed, and the four lifecycle tests in tests/boundary/browser/cdpWebRuntimeTermination.test.ts still pass, including the one that must keep returning a successful observation with end_reason: "target_terminated" when cleanup does confirm.

If you would rather not special-case the command, the other shape that works is to carry window.reason into the cleanup composition so previous_error is the producer reason rather than the last failed command.

We hit this class of thing a lot, where a late cleanup error claims the error field and masks the event that actually ended the work; the note we keep on it is in our own repo (ours): https://github.com/aron-intframe/agent-guards#8-read-the-raw-session-not-the-first-line-of-the-log

<!-- gh-comment-id:6307540523 --> @aron-intframe commented on GitHub (Oct 21, 2026): Reproduced on `main` 335c042d (6.7.0) with real Chrome 153 on Linux x64 / Node 24.21.0, and the dropped fact is recoverable without changing the failure contract. ## Cause On a closed page the first thing that fails is not cleanup, it is `Profiler.stopPreciseCoverage` inside the capture itself, at `src/browser/execution/CdpExecutionCapture.ts:67`. By then the window has already resolved with `target_terminated` (set in `CdpExecutionWindow.ts:32`), but the detached flat session rejects that command, so `captureWebExecution` throws a protocol error and never builds the envelope that would carry `window.end_reason`. `CdpWebRuntimeProvider.run` then composes the throw as the primary error together with the cleanup failure (`CdpWebRuntimeProvider.ts:125` to `analysisErrorWithCleanupFailure`), which is why the top-level `message`, `diagnostics.primary_error` and `cleanup_error.diagnostics.previous_error` all name the stop-coverage command. The termination reason lives only in `CdpExecutionWindow.reason` and is never read again, so the "original producer reason" promised by `docs/web-runtime.md` is gone while the target ID and failed cleanup commands survive, exactly as reported. There is an asymmetry in the same function: a termination *before* arming is reported with its reason (`CdpExecutionCapture.ts:39-42` throws `target_changed` with "ended with ${window.reason} before execution observation could be armed"), while a termination *after* arming is not. ## Evidence Your script, unmodified except that Chrome runs through a one-line wrapper adding `--no-sandbox` (this host disables unprivileged user namespaces). Both closed trials reproduce, the open control passes: ```json { "name": "closed-1", "exit": 1, "targetPresent": false, "rootStillResponsive": "Chrome/153.0.8010.12", "code": "cleanup_incomplete", "message": "CDP Profiler.stopPreciseCoverage failed (-32001): Session with given id not found.", "hasTerminationReason": false }, ``` `closed-1.json` in detail: ``` primary_error.code: unreadable_output primary_error.details: {"operation": "observe_web_execution", "reason": "protocol_error"} cleanup target_id: F875734A4A8C0D4E48B7E227A572186A cleanup_failures count: 7 previous_error: CDP Profiler.stopPreciseCoverage failed (-32001): Session with given id not found. has target_terminated anywhere: False ``` The same failure also reproduces without a browser, at the provider boundary, by making the fixture's detached session reject commands the way Chrome does (`commandError` returning `-32001` once `Target.detachedFromTarget` is emitted): ``` hasTerminationReason: false code: cleanup_incomplete message: CDP Profiler.stopPreciseCoverage failed (-32001): Session with given id not found. ``` That seam is also why no current test fails: `tests/fixtures/webRuntime.ts:29-30` answers `Profiler.stopPreciseCoverage` with `{}` whatever the session state, so `cdpWebRuntimeTermination.test.ts` "ends immediately on its root detachment" takes a success path that a real closed page cannot take. ## Fix that worked here Convert the dead-session rejection into the termination fact only when the window actually ended that way, leaving every other stop-coverage failure untouched: ```ts collection.freezeMetadata(); try { await session.command("Profiler.stopPreciseCoverage"); } catch (cause: unknown) { if (reason !== "target_terminated") throw cause; throw new BrowserObservationError(session.operation, "target_changed", { detail: `Selected target ${session.target.target_id} ended with ${reason}; its detached session could not acknowledge instrumentation cleanup.`, cause, }); } ``` Same script, same box, after rebuilding: ``` code: cleanup_incomplete primary_error.code: target_unavailable primary_error.details: {"operation": "observe_web_execution", "reason": "target_changed"} cleanup target_id: 54E8D691DEA2F95D4D93DB0E441EC6D5 cleanup_failures count: 7 previous_error: Selected target 54E8D691DEA2F95D4D93DB0E441EC6D5 ended with target_terminated; its detached session could not acknowledge instrumentation cleanup. has target_terminated anywhere: True ``` Unconfirmed cleanup stays a failure: the outer code is still `cleanup_incomplete`, the selected target and all 7 failed cleanup commands are still listed, and the open-page control is untouched (`window_elapsed`, coverage `captured`). `npx turbo run typecheck` is clean, `src/browser` is 723 passed, and the four lifecycle tests in `tests/boundary/browser/cdpWebRuntimeTermination.test.ts` still pass, including the one that must keep returning a successful observation with `end_reason: "target_terminated"` when cleanup does confirm. If you would rather not special-case the command, the other shape that works is to carry `window.reason` into the cleanup composition so `previous_error` is the producer reason rather than the last failed command. We hit this class of thing a lot, where a late cleanup error claims the error field and masks the event that actually ended the work; the note we keep on it is in our own repo (ours): https://github.com/aron-intframe/agent-guards#8-read-the-raw-session-not-the-first-line-of-the-log
Author
Owner

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

Agreed with aron-intframe's analysis — the dead-session rejection (-32001) claims the error field and masks the event that actually ended the window, so a page closure becomes indistinguishable from a stop-coverage failure. Both suggested shapes look sound; I'd lean toward keeping the original producer reason as the primary error while still carrying the failed cleanup commands as secondary detail, so the caller gets both facts at once: the window ended because the target terminated, and which cleanup commands could not be acknowledged. That matches the closed-target contract without weakening "unconfirmed cleanup is a failure". The fixture seam (webRuntime.ts answering stopPreciseCoverage with {} regardless of session state) is a particularly good catch — it explains why no current test fails on this path.

<!-- gh-comment-id:6307808538 --> @XinwangTao commented on GitHub (Oct 21, 2026): Agreed with aron-intframe's analysis — the dead-session rejection (-32001) claims the error field and masks the event that actually ended the window, so a page closure becomes indistinguishable from a stop-coverage failure. Both suggested shapes look sound; I'd lean toward keeping the original producer reason as the primary error while still carrying the failed cleanup commands as secondary detail, so the caller gets both facts at once: the window ended because the target terminated, and which cleanup commands could not be acknowledged. That matches the closed-target contract without weakening "unconfirmed cleanup is a failure". The fixture seam (webRuntime.ts answering stopPreciseCoverage with {} regardless of session state) is a particularly good catch — it explains why no current test fails on this path.
Author
Owner

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

5891bfb covers the core ask: the primary error now names target_changed instead of the misleading protocol_error/unreadable_output, while the outer cleanup_incomplete and the failed cleanup commands stay intact for callers that need them. That classification change alone makes page closure distinguishable from a real stop-coverage failure, which was the whole point. One follow-up worth pinning down: aron-intframe's seam observation (tests/fixtures/webRuntime.ts always answering stopPreciseCoverage with {}) is still the reason this class of bug can escape the suite — a test where the detached session rejects with -32001 would lock in the new behavior.

<!-- gh-comment-id:6311146208 --> @XinwangTao commented on GitHub (Oct 21, 2026): 5891bfb covers the core ask: the primary error now names target_changed instead of the misleading protocol_error/unreadable_output, while the outer cleanup_incomplete and the failed cleanup commands stay intact for callers that need them. That classification change alone makes page closure distinguishable from a real stop-coverage failure, which was the whole point. One follow-up worth pinning down: aron-intframe's seam observation (tests/fixtures/webRuntime.ts always answering stopPreciseCoverage with {}) is still the reason this class of bug can escape the suite — a test where the detached session rejects with -32001 would lock in the new behavior.
Author
Owner

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

The fix in 5891bfb resolves exactly the asymmetry you pointed out in CdpExecutionCapture: termination before arming already surfaced window.reason in the target_changed error, while termination after arming silently fell through to the unrelated stop-coverage failure. Converting the dead-session rejection into target_changed only when the window truly ended with target_terminated keeps the diagnostic honest without masking genuine stop-coverage errors in other scenarios. Also nice that the no-browser provider-boundary repro proved the seam — that makes the regression fixture cheap to maintain.

<!-- gh-comment-id:6311148185 --> @XinwangTao commented on GitHub (Oct 21, 2026): The fix in 5891bfb resolves exactly the asymmetry you pointed out in CdpExecutionCapture: termination before arming already surfaced `window.reason` in the target_changed error, while termination after arming silently fell through to the unrelated stop-coverage failure. Converting the dead-session rejection into target_changed only when the window truly ended with target_terminated keeps the diagnostic honest without masking genuine stop-coverage errors in other scenarios. Also nice that the no-browser provider-boundary repro proved the seam — that makes the regression fixture cheap to maintain.
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#3500
No description provided.