[GH-ISSUE #3553] [Bug] Passive browser and Inspector calls can wait indefinitely after successful discovery #1318

Closed
opened 2026-10-16 21:04:11 +00:00 by bsakaguchi · 0 comments
Owner

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

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

A debugging endpoint can successfully complete HTTP discovery, then leave a passive browser or JavaScript runtime observation waiting because it never completes the WebSocket handshake or never answers a setup command. This happens before the requested observation window starts, so --observation-ms 0 does not make the call finish.

I reproduced the command-response case through the compiled public CLI using a local HTTP/WebSocket fixture that advertises a valid target and deliberately withholds protocol replies. On revision c99c6a89d, both of these remained pending more than 12 seconds after the fixture received the first command:

CLI operation First unanswered command Observed wait after command
inspect-web-page Target.attachToTarget 12,012 ms
observe-javascript-runtime Runtime.enable 12,010 ms

Both used --observation-ms 0. Neither returned before I shut down the controlled fixture. After that shutdown, both exited with provider_unavailable, category unavailable, reason disconnected. That describes the induced disconnect, rather than identifying the earlier stalled setup.

The bounded reproduction establishes that the calls remained pending for the measured interval; I did not run an infinite-duration experiment. The absence of an internal deadline is a separate conclusion from the transport source described below. Caller cancellation can still end the wait.

Reproduction

Run from a checkout with its locked dependencies installed and runtime built:

npm ci
npm run build:cached

Save the following as rea-timeout-repro.mjs in the checkout. It uses the project's existing ws dependency, binds a randomly allocated loopback port, and serves only the discovery responses needed to reach the transport boundary. It launches no target application and executes no supplied JavaScript.

import { createServer } from 'node:http';
import { createRequire } from 'node:module';
import { resolve } from 'node:path';
const { WebSocketServer } = createRequire(resolve('package.json'))('ws');
const inspector = process.argv[2] === 'inspector';
const handshake = process.argv[3] === 'handshake';
const sockets = new Set();
const ws = new WebSocketServer({ noServer: true });
let endpoint;
const server = createServer((req, res) => {
  res.setHeader('content-type', 'application/json');
  const socketUrl = endpoint.replace('http:', 'ws:');
  if (req.url === '/json/version') res.end(JSON.stringify({
    Browser: inspector ? 'node.js/v24.18.0' : 'Chrome/155',
    'Protocol-Version': '1.3', 'V8-Version': '13.6',
    'User-Agent': 'fixture', 'WebKit-Version': 'fixture',
    webSocketDebuggerUrl: `${socketUrl}/devtools/browser/fixture`,
  }));
  else if (req.url === '/json/list') res.end(JSON.stringify([{
    id: 'fixture', type: inspector ? 'node' : 'page', title: 'fixture',
    url: `${endpoint}/app.js`,
    webSocketDebuggerUrl: `${socketUrl}/${inspector ? 'fixture' : 'devtools/page/fixture'}`,
  }]));
  else { res.statusCode = 404; res.end('{}'); }
});
server.on('connection', socket => {
  sockets.add(socket);
  socket.on('close', () => sockets.delete(socket));
  socket.on('end', () => socket.end());
});
server.on('upgrade', (req, socket, head) => {
  if (handshake) { socket.resume(); return; }
  ws.handleUpgrade(req, socket, head, connection => {
    connection.on('message', raw => console.log('withheld:', JSON.parse(raw).method));
  });
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
endpoint = `http://127.0.0.1:${server.address().port}`;
console.log(endpoint);
process.on('SIGINT', () => {
  for (const socket of sockets) socket.destroy();
  server.close(() => process.exit(0));
});

Start the fixture in one terminal, keeping the checkout as the working directory:

node rea-timeout-repro.mjs browser rpc

It prints an endpoint such as http://127.0.0.1:50544. In another terminal, substitute that endpoint:

node scripts/rea.mjs inspect-web-page http://127.0.0.1:50544 fixture --observation-ms 0 --json

The first terminal prints withheld: Target.attachToTarget. On the affected base, the CLI remains waiting. After observing the pending state, stop the fixture with Ctrl+C; the CLI reports a disconnect. Each fixture instance should be used for one reproduction.

For the Inspector path, restart with node rea-timeout-repro.mjs inspector rpc, copy its newly printed endpoint, and run:

node scripts/rea.mjs observe-javascript-runtime http://127.0.0.1:50544 fixture --observation-ms 0 --json

This withholds Runtime.enable. To exercise a stalled WebSocket upgrade instead, replace the fixture's rpc argument with handshake and use the corresponding command. HTTP discovery still succeeds, but no protocol command is received.

The reduced fixture above was also executed against the proposed fix through the compiled inspect-web-page CLI and returned the expected timeout. Separate actual-duration runs exercised both command-response and handshake stalls for both public CLI operations.

Expected behavior and typed recovery

A successful discovery response should not give the peer an unbounded subsequent transport wait. Each handshake and each command response should have a finite internal deadline and return the existing provider_timeout outcome, category timeout, if the peer stops responding. A caller abort should retain cancellation semantics. Closing the operation should release REA's own connection without closing the caller's browser, page, or Node process.

These transport deadlines should remain separate from the caller-selected observation window. A long observation may be intentional and healthy. Conversely, choosing a zero-duration observation should not require a target that never answers setup to be manually interrupted. A per-command deadline also should not be presented as one absolute deadline for the entire operation: several commands and the observation window are separate phases.

Analysis of the owning boundary

At the affected revision, CdpConnection.ts explicitly creates the socket with handshakeTimeout: 0. Its waitForOpen settles on open, error, close, or caller abort; it does not schedule a timer. send similarly waits for a correlated response, a transport/protocol failure, or an optional abort signal. It has no response deadline of its own.

The browser provider's target attach and domain-arming calls, and Inspector's Runtime.enable / Debugger.enable, use this shared transport. The ordinary passive CLI cancellation signal permits interruption but does not supply a setup timer. The earlier HTTP discovery deadline therefore does not cover these later waits. Active execution paths already have wrappers for their own timing phases; this report concerns the missing fallback at the shared transport boundary.

A focused correction can reuse the existing WEB_RUNTIME_LIMITS.commandTimeoutMs value of 20,000 ms for each handshake and response. The pending command should own timer and abort-listener disposal so success, rejection, timeout, cancellation, and disconnect release the same resources. Late replies should remain ignored after the corresponding pending entry is removed. Inspector observations received before a setup failure should remain available as partial evidence.

This can block an unattended CLI task or the corresponding MCP workflow when an otherwise discoverable peer becomes unresponsive during connection or setup. Ordinary observations do not need to hit this path; the controlled fixture intentionally produces it. Frequency with real production targets and the first affected release are unknown. MCP shares the provider/transport owner, but the original stalled-peer runtime reproduction here used CLI.

Original CLI reproduction: REA 6.5.0, revision c99c6a89d, Windows x64, Node.js 24.11.1 / npm 11.6.2. The fix and regression tests use baseline ca1b2f860 with pinned Node.js 24.18.0 / npm 11.16.0. The relevant shared connection, target-session and Inspector provider source is identical between those revisions. The reduced-fixture validation used the pinned toolchain. The target is self-owned loopback HTTP/WebSocket; earliest failure stage is transport/setup, after discovery and before capture.

I searched existing open and closed issues and PRs. #2346 addresses browser-socket discovery deadlines, which precede the HTTP-discovered connection/setup path here; #2879 concerns interruption, and #2961 concerns other provider admission boundaries. #3484 adjusts discovery documentation, while #3423 covers other documented runtime and setup caveats, including Debugger.enable behavior and Playwright timing. Neither supplies this shared handshake/RPC deadline. This proposal adds no protocol methods or explicit target pause/resume. I did not find an existing report or open PR owning this specific missing transport deadline.

Implementation

The accompanying fix is available in https://github.com/morluto/rea/pull/3555, from the dedicated fix/passive-target-setup-deadline branch. The PR is a draft pending the author's final review and readiness declaration.

Originally created by @dakjdakd on GitHub (Oct 14, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/3553 ## 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 A debugging endpoint can successfully complete HTTP discovery, then leave a passive browser or JavaScript runtime observation waiting because it never completes the WebSocket handshake or never answers a setup command. This happens before the requested observation window starts, so `--observation-ms 0` does not make the call finish. I reproduced the command-response case through the compiled public CLI using a local HTTP/WebSocket fixture that advertises a valid target and deliberately withholds protocol replies. On revision `c99c6a89d`, both of these remained pending more than 12 seconds after the fixture received the first command: | CLI operation | First unanswered command | Observed wait after command | |---|---|---:| | `inspect-web-page` | `Target.attachToTarget` | 12,012 ms | | `observe-javascript-runtime` | `Runtime.enable` | 12,010 ms | Both used `--observation-ms 0`. Neither returned before I shut down the controlled fixture. After that shutdown, both exited with `provider_unavailable`, category `unavailable`, reason `disconnected`. That describes the induced disconnect, rather than identifying the earlier stalled setup. The bounded reproduction establishes that the calls remained pending for the measured interval; I did not run an infinite-duration experiment. The absence of an internal deadline is a separate conclusion from the transport source described below. Caller cancellation can still end the wait. ## Reproduction Run from a checkout with its locked dependencies installed and runtime built: ```sh npm ci npm run build:cached ``` Save the following as `rea-timeout-repro.mjs` in the checkout. It uses the project's existing `ws` dependency, binds a randomly allocated loopback port, and serves only the discovery responses needed to reach the transport boundary. It launches no target application and executes no supplied JavaScript. ```js import { createServer } from 'node:http'; import { createRequire } from 'node:module'; import { resolve } from 'node:path'; const { WebSocketServer } = createRequire(resolve('package.json'))('ws'); const inspector = process.argv[2] === 'inspector'; const handshake = process.argv[3] === 'handshake'; const sockets = new Set(); const ws = new WebSocketServer({ noServer: true }); let endpoint; const server = createServer((req, res) => { res.setHeader('content-type', 'application/json'); const socketUrl = endpoint.replace('http:', 'ws:'); if (req.url === '/json/version') res.end(JSON.stringify({ Browser: inspector ? 'node.js/v24.18.0' : 'Chrome/155', 'Protocol-Version': '1.3', 'V8-Version': '13.6', 'User-Agent': 'fixture', 'WebKit-Version': 'fixture', webSocketDebuggerUrl: `${socketUrl}/devtools/browser/fixture`, })); else if (req.url === '/json/list') res.end(JSON.stringify([{ id: 'fixture', type: inspector ? 'node' : 'page', title: 'fixture', url: `${endpoint}/app.js`, webSocketDebuggerUrl: `${socketUrl}/${inspector ? 'fixture' : 'devtools/page/fixture'}`, }])); else { res.statusCode = 404; res.end('{}'); } }); server.on('connection', socket => { sockets.add(socket); socket.on('close', () => sockets.delete(socket)); socket.on('end', () => socket.end()); }); server.on('upgrade', (req, socket, head) => { if (handshake) { socket.resume(); return; } ws.handleUpgrade(req, socket, head, connection => { connection.on('message', raw => console.log('withheld:', JSON.parse(raw).method)); }); }); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); endpoint = `http://127.0.0.1:${server.address().port}`; console.log(endpoint); process.on('SIGINT', () => { for (const socket of sockets) socket.destroy(); server.close(() => process.exit(0)); }); ``` Start the fixture in one terminal, keeping the checkout as the working directory: ```sh node rea-timeout-repro.mjs browser rpc ``` It prints an endpoint such as `http://127.0.0.1:50544`. In another terminal, substitute that endpoint: ```sh node scripts/rea.mjs inspect-web-page http://127.0.0.1:50544 fixture --observation-ms 0 --json ``` The first terminal prints `withheld: Target.attachToTarget`. On the affected base, the CLI remains waiting. After observing the pending state, stop the fixture with Ctrl+C; the CLI reports a disconnect. Each fixture instance should be used for one reproduction. For the Inspector path, restart with `node rea-timeout-repro.mjs inspector rpc`, copy its newly printed endpoint, and run: ```sh node scripts/rea.mjs observe-javascript-runtime http://127.0.0.1:50544 fixture --observation-ms 0 --json ``` This withholds `Runtime.enable`. To exercise a stalled WebSocket upgrade instead, replace the fixture's `rpc` argument with `handshake` and use the corresponding command. HTTP discovery still succeeds, but no protocol command is received. The reduced fixture above was also executed against the proposed fix through the compiled `inspect-web-page` CLI and returned the expected timeout. Separate actual-duration runs exercised both command-response and handshake stalls for both public CLI operations. ## Expected behavior and typed recovery A successful discovery response should not give the peer an unbounded subsequent transport wait. Each handshake and each command response should have a finite internal deadline and return the existing `provider_timeout` outcome, category `timeout`, if the peer stops responding. A caller abort should retain cancellation semantics. Closing the operation should release REA's own connection without closing the caller's browser, page, or Node process. These transport deadlines should remain separate from the caller-selected observation window. A long observation may be intentional and healthy. Conversely, choosing a zero-duration observation should not require a target that never answers setup to be manually interrupted. A per-command deadline also should not be presented as one absolute deadline for the entire operation: several commands and the observation window are separate phases. ## Analysis of the owning boundary At the affected revision, [`CdpConnection.ts`](https://github.com/morluto/rea/blob/ca1b2f860/src/browser/CdpConnection.ts) explicitly creates the socket with `handshakeTimeout: 0`. Its `waitForOpen` settles on open, error, close, or caller abort; it does not schedule a timer. `send` similarly waits for a correlated response, a transport/protocol failure, or an optional abort signal. It has no response deadline of its own. The browser provider's target attach and domain-arming calls, and Inspector's `Runtime.enable` / `Debugger.enable`, use this shared transport. The ordinary passive CLI cancellation signal permits interruption but does not supply a setup timer. The earlier HTTP discovery deadline therefore does not cover these later waits. Active execution paths already have wrappers for their own timing phases; this report concerns the missing fallback at the shared transport boundary. A focused correction can reuse the existing `WEB_RUNTIME_LIMITS.commandTimeoutMs` value of 20,000 ms for each handshake and response. The pending command should own timer and abort-listener disposal so success, rejection, timeout, cancellation, and disconnect release the same resources. Late replies should remain ignored after the corresponding pending entry is removed. Inspector observations received before a setup failure should remain available as partial evidence. ## Impact, environment, and related work This can block an unattended CLI task or the corresponding MCP workflow when an otherwise discoverable peer becomes unresponsive during connection or setup. Ordinary observations do not need to hit this path; the controlled fixture intentionally produces it. Frequency with real production targets and the first affected release are unknown. MCP shares the provider/transport owner, but the original stalled-peer runtime reproduction here used CLI. Original CLI reproduction: REA 6.5.0, revision `c99c6a89d`, Windows x64, Node.js 24.11.1 / npm 11.6.2. The fix and regression tests use baseline `ca1b2f860` with pinned Node.js 24.18.0 / npm 11.16.0. The relevant shared connection, target-session and Inspector provider source is identical between those revisions. The reduced-fixture validation used the pinned toolchain. The target is self-owned loopback HTTP/WebSocket; earliest failure stage is transport/setup, after discovery and before capture. I searched existing open and closed issues and PRs. #2346 addresses browser-socket discovery deadlines, which precede the HTTP-discovered connection/setup path here; #2879 concerns interruption, and #2961 concerns other provider admission boundaries. #3484 adjusts discovery documentation, while #3423 covers other documented runtime and setup caveats, including `Debugger.enable` behavior and Playwright timing. Neither supplies this shared handshake/RPC deadline. This proposal adds no protocol methods or explicit target pause/resume. I did not find an existing report or open PR owning this specific missing transport deadline. ## Implementation The accompanying fix is available in https://github.com/morluto/rea/pull/3555, from the dedicated `fix/passive-target-setup-deadline` branch. The PR is a draft pending the author's final review and readiness declaration.
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#1318
No description provided.