[GH-ISSUE #1497] fix(browser): lock an attached CDP scenario by the URL the connection uses #516

Closed
opened 2026-10-15 18:25:46 +00:00 by bsakaguchi · 6 comments
Owner

Originally created by @cursor[bot] on GitHub (Oct 10, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/1497

TL;DR: Two attached browser scenarios that name the same CDP endpoint with different spellings take two connections. The single-connection lock is keyed by the raw string.

Area

Browser scenario capture

Observed behavior

acquireAttachedScenario in src/browser/PlaywrightScenarioBrowser.ts locks with:

const key = JSON.stringify([scenario.cdp_endpoint, scenario.target_id]);

Discovery of that same endpoint goes through new URL("/json/version", endpoint) in src/browser/CdpEndpoint.ts, which accepts more than one spelling of a base URL. http://127.0.0.1:9222 and http://127.0.0.1:9222/ are different mutex keys and the same browser.

Each admission has its own cleanupOwner. Concurrent captures can open two CDP connections and each can believe it owns cleanup.

Expected behavior

The lock key should be the URL identity discovery already uses, plus the target id, so one browser has one admission.

Where

  • src/browser/PlaywrightScenarioBrowser.ts — acquireAttachedScenario
  • src/browser/CdpEndpoint.ts — discoverCdpEndpoint
Originally created by @cursor[bot] on GitHub (Oct 10, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/1497 **TL;DR:** Two attached browser scenarios that name the same CDP endpoint with different spellings take two connections. The single-connection lock is keyed by the raw string. ### Area Browser scenario capture ### Observed behavior `acquireAttachedScenario` in `src/browser/PlaywrightScenarioBrowser.ts` locks with: ```ts const key = JSON.stringify([scenario.cdp_endpoint, scenario.target_id]); ``` Discovery of that same endpoint goes through `new URL("/json/version", endpoint)` in `src/browser/CdpEndpoint.ts`, which accepts more than one spelling of a base URL. `http://127.0.0.1:9222` and `http://127.0.0.1:9222/` are different mutex keys and the same browser. Each admission has its own `cleanupOwner`. Concurrent captures can open two CDP connections and each can believe it owns cleanup. ### Expected behavior The lock key should be the URL identity discovery already uses, plus the target id, so one browser has one admission. ### Where - `src/browser/PlaywrightScenarioBrowser.ts` — `acquireAttachedScenario` - `src/browser/CdpEndpoint.ts` — `discoverCdpEndpoint`
bsakaguchi 2026-10-15 18:25:46 +00:00
Author
Owner

@CassieFox23 commented on GitHub (Oct 10, 2026):

TL;DR: I could not reproduce this on Linux with Chromium 154 (BSD-3-Clause), before or after b4db35f09 (#1513). The scenario schema already maps every spelling to one origin, so the lock sees one key. Two captures with and without the trailing slash ran one after the other on both builds.

What I checked

browserEndpointSchema in src/domain/browserObservation.ts ends with return url.origin;. The CLI and the MCP tool both run browserScenarioSchema before the capture starts. The lock key in b4db35f09 now uses the /json/version URL. That URL still starts from the same origin.

I parsed each spelling through the built schema. Main 7a56fe626 and main 46c9cba03 gave the same output:

Endpoint given Key input after the schema
http://127.0.0.1:9222 http://127.0.0.1:9222
http://127.0.0.1:9222/ http://127.0.0.1:9222
HTTP://127.0.0.1:9222 http://127.0.0.1:9222
http://127.0.0.1:09222 http://127.0.0.1:9222
http://127.1:9222 http://127.0.0.1:9222
http://[0:0:0:0:0:0:0:1]:9222 http://[::1]:9222
http://localhost:9222 rejected (loopback rule)

Then I ran two captures at once in one Node process. Each held the page for 2,000 ms. One used the endpoint with a trailing slash and one without.

Build Same page Two pages (control)
7a56fe626 done at 2,075 and 4,110 ms done at 2,096 and 2,095 ms
46c9cba03 done at 2,073 and 4,113 ms done at 2,094 and 2,093 ms

On the same page, the second capture finished about 2,000 ms later, so it waited for the first one. On two pages, both finished together.

One case does give two keys: 127.0.0.1 and [::1] on the same port. My Chromium 154 listened on 127.0.0.1 only, and [::1] refused the connection.

Not run

Edit: two of these ran, in the follow-up. Still not run: other browser engines.

  • A browser that answers on both 127.0.0.1 and ::1 at one port. I did not set one up. Ran through a proxy, not a native dual listen.
  • The MCP server path. It runs the same schema first, but I did not send it two calls at once.
  • Other browser engines.
Commands
chromium-browser --headless=new --remote-debugging-port=9335 \
  --remote-allow-origins='*' --password-store=basic \
  --user-data-dir=<temp profile> about:blank
# schema check: browserScenarioSchema.safeParse(...).data.browser.cdp_endpoint
# live check: Promise.all of two captureBrowserScenario calls,
#   each one wait_for_timeout of 2000 ms
Test setup
  • Box: Nobara 44 (Fedora 44 base), Linux 7.2.9
  • CPU: AMD Ryzen 9 5900X, 12 cores / 24 threads
  • Node: 24.18.0
  • Builds: 7a56fe626 and 46c9cba03 (main, before and after b4db35f09)
  • Other: Chromium 154 (BSD-3-Clause), the one Fedora 44 ships
  • Build: 154.0.8037.57
  • Pages: headless, served from 127.0.0.1

🤖 Generated with Claude Code (Opus 5.5) 🦊

<!-- gh-comment-id:6096139300 --> @CassieFox23 commented on GitHub (Oct 10, 2026): **TL;DR:** I could not reproduce this on Linux with [Chromium 154](https://github.com/chromium/chromium) ([BSD-3-Clause](https://github.com/chromium/chromium/blob/main/LICENSE)), before or after `b4db35f09` (#1513). The scenario schema already maps every spelling to one origin, so the lock sees one key. Two captures with and without the trailing slash ran one after the other on both builds. ### What I checked `browserEndpointSchema` in `src/domain/browserObservation.ts` ends with `return url.origin;`. The CLI and the MCP tool both run `browserScenarioSchema` before the capture starts. The lock key in `b4db35f09` now uses the `/json/version` URL. That URL still starts from the same origin. I parsed each spelling through the built schema. Main `7a56fe626` and main `46c9cba03` gave the same output: | Endpoint given | Key input after the schema | |---|---| | `http://127.0.0.1:9222` | `http://127.0.0.1:9222` | | `http://127.0.0.1:9222/` | `http://127.0.0.1:9222` | | `HTTP://127.0.0.1:9222` | `http://127.0.0.1:9222` | | `http://127.0.0.1:09222` | `http://127.0.0.1:9222` | | `http://127.1:9222` | `http://127.0.0.1:9222` | | `http://[0:0:0:0:0:0:0:1]:9222` | `http://[::1]:9222` | | `http://localhost:9222` | rejected (loopback rule) | Then I ran two captures at once in one Node process. Each held the page for 2,000 ms. One used the endpoint with a trailing slash and one without. | Build | Same page | Two pages (control) | |---|---|---| | `7a56fe626` | done at 2,075 and 4,110 ms | done at 2,096 and 2,095 ms | | `46c9cba03` | done at 2,073 and 4,113 ms | done at 2,094 and 2,093 ms | On the same page, the second capture finished about 2,000 ms later, so it waited for the first one. On two pages, both finished together. One case does give two keys: `127.0.0.1` and `[::1]` on the same port. My Chromium 154 listened on `127.0.0.1` only, and `[::1]` refused the connection. ### Not run Edit: two of these ran, in [the follow-up](https://github.com/morluto/rea/issues/1497#issuecomment-6099255620). Still not run: other browser engines. - ~~A browser that answers on both `127.0.0.1` and `::1` at one port. I did not set one up.~~ Ran through a proxy, not a native dual listen. - ~~The MCP server path. It runs the same schema first, but I did not send it two calls at once.~~ - Other browser engines. <details> <summary>Commands</summary> ```sh chromium-browser --headless=new --remote-debugging-port=9335 \ --remote-allow-origins='*' --password-store=basic \ --user-data-dir=<temp profile> about:blank # schema check: browserScenarioSchema.safeParse(...).data.browser.cdp_endpoint # live check: Promise.all of two captureBrowserScenario calls, # each one wait_for_timeout of 2000 ms ``` </details> <details> <summary>Test setup</summary> - Box: Nobara 44 (Fedora 44 base), Linux 7.2.9 - CPU: AMD Ryzen 9 5900X, 12 cores / 24 threads - Node: 24.18.0 - Builds: `7a56fe626` and `46c9cba03` (main, before and after `b4db35f09`) - Other: [Chromium 154](https://github.com/chromium/chromium) ([BSD-3-Clause](https://github.com/chromium/chromium/blob/main/LICENSE)), the one Fedora 44 ships - Build: `154.0.8037.57` - Pages: headless, served from 127.0.0.1 </details> 🤖 Generated with Claude Code (Opus 5.5) 🦊
Author
Owner

@CassieFox23 commented on GitHub (Oct 10, 2026):

TL;DR: Follow-up to my earlier comment: 127.0.0.1 and [::1] do split the lock. I pointed both at one Chromium tab and sent two captures at once, and the second one to attach failed. The MCP server path and the library path behave the same.

Over the MCP server

I started REA's own MCP server from main 46c9cba03 over stdio. Then I sent two capture_browser_scenario calls at once from an SDK client. Each call holds the tab for 2,000 ms. Times come from the server's own log.

Pair of calls Server times Result
Same tab, http://127.0.0.1:9341 and .../9341/ 2,076 and 4,116 ms both ok, second waited
Two tabs, same two spellings (control) 2,130 and 2,143 ms both ok, ran together
Two tabs, both http://[::1]:9341 2,334 and 2,361 ms both ok, ran together
Same tab, 127.0.0.1 and [::1], 9 runs 2,075 to 2,093 ms, and 169 to 219 ms one ok, one failed, every run

The [::1] row with two tabs shows that [::1] works on its own. In the last row the two calls got two lock keys and ran together. Its times come from the 5 runs where I kept the server log. Other jobs ran on this box at the same time, so read the exact ms loosely. The signal is the 2,000 ms gap.

The failed call returned execution_failure with "Analysis could not complete. Retry once". It gave no cause. The library path shows the cause:

cdpSession.send: Protocol error (Emulation.setLocaleOverride):
Another locale override is already in effect

The address family does not decide which call fails. On the library path I delayed the 127.0.0.1 call by 400 or 1,000 ms, and then the 127.0.0.1 call failed instead. The second call to attach always failed.

How I got both addresses

Chromium 154 (BSD-3-Clause) ignored --remote-debugging-address=:: in headless mode. It still listened on 127.0.0.1 only. So I wrote a tiny Node TCP proxy from [::1]:9341 to 127.0.0.1:9341. Chromium answered through it and handed out ws://[::1]:9341/... URLs.

Still not run

  • A browser that listens on both addresses by itself. I used a proxy, so I don't know how common this setup is.
  • Other browser engines.
Commands
chromium-browser --headless=new --remote-debugging-port=9341 \
  --remote-allow-origins='*' --password-store=basic \
  --user-data-dir=<temp profile> about:blank
# proxy: net.createServer on [::1]:9341, piped to 127.0.0.1:9341
# MCP: StdioClientTransport running: node scripts/rea.mjs mcp
#   then two client.callTool("capture_browser_scenario") in Promise.all
# each scenario: connect mode, one wait_for_timeout of 2000 ms
Test setup
  • Box: Nobara 44 (Fedora 44 base), Linux 7.2.9
  • CPU: AMD Ryzen 9 5900X, 12 cores / 24 threads
  • Node: 24.18.0
  • REA: main 46c9cba03
  • Other: Chromium 154 (BSD-3-Clause), the one Fedora 44 ships
  • Build: 154.0.8037.57, run headless
  • Pages: served from 127.0.0.1

🤖 Generated with Claude Code (Opus 5.5) 🦊

<!-- gh-comment-id:6099255620 --> @CassieFox23 commented on GitHub (Oct 10, 2026): **TL;DR:** Follow-up to my earlier comment: `127.0.0.1` and `[::1]` do split the lock. I pointed both at one Chromium tab and sent two captures at once, and the second one to attach failed. The MCP server path and the library path behave the same. ### Over the MCP server I started REA's own MCP server from main `46c9cba03` over stdio. Then I sent two `capture_browser_scenario` calls at once from an SDK client. Each call holds the tab for 2,000 ms. Times come from the server's own log. | Pair of calls | Server times | Result | |---|---|---| | Same tab, `http://127.0.0.1:9341` and `.../9341/` | 2,076 and 4,116 ms | both ok, second waited | | Two tabs, same two spellings (control) | 2,130 and 2,143 ms | both ok, ran together | | Two tabs, both `http://[::1]:9341` | 2,334 and 2,361 ms | both ok, ran together | | Same tab, `127.0.0.1` and `[::1]`, 9 runs | 2,075 to 2,093 ms, and 169 to 219 ms | one ok, one failed, every run | The `[::1]` row with two tabs shows that `[::1]` works on its own. In the last row the two calls got two lock keys and ran together. Its times come from the 5 runs where I kept the server log. Other jobs ran on this box at the same time, so read the exact ms loosely. The signal is the 2,000 ms gap. The failed call returned `execution_failure` with "Analysis could not complete. Retry once". It gave no cause. The library path shows the cause: ```text cdpSession.send: Protocol error (Emulation.setLocaleOverride): Another locale override is already in effect ``` The address family does not decide which call fails. On the library path I delayed the `127.0.0.1` call by 400 or 1,000 ms, and then the `127.0.0.1` call failed instead. The second call to attach always failed. ### How I got both addresses [Chromium 154](https://github.com/chromium/chromium) ([BSD-3-Clause](https://github.com/chromium/chromium/blob/main/LICENSE)) ignored `--remote-debugging-address=::` in headless mode. It still listened on `127.0.0.1` only. So I wrote a tiny Node TCP proxy from `[::1]:9341` to `127.0.0.1:9341`. Chromium answered through it and handed out `ws://[::1]:9341/...` URLs. ### Still not run - A browser that listens on both addresses by itself. I used a proxy, so I don't know how common this setup is. - Other browser engines. <details> <summary>Commands</summary> ```sh chromium-browser --headless=new --remote-debugging-port=9341 \ --remote-allow-origins='*' --password-store=basic \ --user-data-dir=<temp profile> about:blank # proxy: net.createServer on [::1]:9341, piped to 127.0.0.1:9341 # MCP: StdioClientTransport running: node scripts/rea.mjs mcp # then two client.callTool("capture_browser_scenario") in Promise.all # each scenario: connect mode, one wait_for_timeout of 2000 ms ``` </details> <details> <summary>Test setup</summary> - Box: Nobara 44 (Fedora 44 base), Linux 7.2.9 - CPU: AMD Ryzen 9 5900X, 12 cores / 24 threads - Node: 24.18.0 - REA: main `46c9cba03` - Other: [Chromium 154](https://github.com/chromium/chromium) ([BSD-3-Clause](https://github.com/chromium/chromium/blob/main/LICENSE)), the one Fedora 44 ships - Build: `154.0.8037.57`, run headless - Pages: served from 127.0.0.1 </details> 🤖 Generated with Claude Code (Opus 5.5) 🦊
Author
Owner

@akushonkamen commented on GitHub (Oct 10, 2026):

Confirmed the root cause: the attach admission lock key is JSON.stringify([scenario.cdp_endpoint, scenario.target_id]) at src/browser/PlaywrightScenarioBrowser.ts:192, and cdp_endpoint is normalized to url.origin by browserEndpointSchema (src/domain/browserObservation.ts), so http://127.0.0.1:9222 and http://[::1]:9222 yield two different keys and both scenarios pass admission in parallel — matching @CassieFox23's reproduction where the browser rejects the second client (a CDP endpoint serves both loopback stacks on one port). Since isLiteralLoopbackHostname already exists in the domain layer, I'm working on a fix that folds loopback hosts to one identity in the lock key (keeping port and target_id) without changing schema validation. AI-assisted (Claude Code).

<!-- gh-comment-id:6099513441 --> @akushonkamen commented on GitHub (Oct 10, 2026): Confirmed the root cause: the attach admission lock key is `JSON.stringify([scenario.cdp_endpoint, scenario.target_id])` at `src/browser/PlaywrightScenarioBrowser.ts:192`, and `cdp_endpoint` is normalized to `url.origin` by `browserEndpointSchema` (`src/domain/browserObservation.ts`), so `http://127.0.0.1:9222` and `http://[::1]:9222` yield two different keys and both scenarios pass admission in parallel — matching @CassieFox23's reproduction where the browser rejects the second client (a CDP endpoint serves both loopback stacks on one port). Since `isLiteralLoopbackHostname` already exists in the domain layer, I'm working on a fix that folds loopback hosts to one identity in the lock key (keeping port and target_id) without changing schema validation. AI-assisted (Claude Code).
Author
Owner

@morluto commented on GitHub (Oct 10, 2026):

Thanks for reporting this. Closing as resolved on main. Attached scenario admission keys use the normalized /json/version discovery URL plus target ID, so equivalent endpoint spellings share admission.

Merged fix. I checked the current source against this report; no tests or real-provider workflows were run for this closure pass.

<!-- gh-comment-id:6099803507 --> @morluto commented on GitHub (Oct 10, 2026): Thanks for reporting this. Closing as resolved on main. Attached scenario admission keys use the normalized /json/version discovery URL plus target ID, so equivalent endpoint spellings share admission. [Merged fix](https://github.com/morluto/rea/commit/b4db35f092d7166aebdf4387b48f7652b1d1c3b6). I checked the current source against this report; no tests or real-provider workflows were run for this closure pass.
Author
Owner

@akushonkamen commented on GitHub (Oct 10, 2026):

Follow-up on the closure: b4db35f keys admission on the normalized /json/version discovery URL, but that URL is still built from the url.origin string, so http://127.0.0.1:9222 and http://[::1]:9222 remain two lease keys for one browser and admit in parallel (the browser rejects the second client). Reproduced on current main (603d04ee, which contains b4db35f): the two new regression tests fail there (2 failed | 7 passed) and pass with the fix (9 passed).

Opened PR #1604, which folds literal loopback hostnames in attachedScenarioLeaseKey to one identity via the existing isLiteralLoopbackHostname (port and target_id still partition). Full validation in the PR description. AI-assisted (Claude Code).

<!-- gh-comment-id:6099856704 --> @akushonkamen commented on GitHub (Oct 10, 2026): Follow-up on the closure: b4db35f keys admission on the normalized `/json/version` discovery URL, but that URL is still built from the `url.origin` string, so `http://127.0.0.1:9222` and `http://[::1]:9222` remain two lease keys for one browser and admit in parallel (the browser rejects the second client). Reproduced on current `main` (`603d04ee`, which contains b4db35f): the two new regression tests fail there (`2 failed | 7 passed`) and pass with the fix (`9 passed`). Opened PR #1604, which folds literal loopback hostnames in `attachedScenarioLeaseKey` to one identity via the existing `isLiteralLoopbackHostname` (port and `target_id` still partition). Full validation in the PR description. AI-assisted (Claude Code).
Author
Owner

@CassieFox23 commented on GitHub (Oct 10, 2026):

TL;DR: I retested on main 603d04ee3: the 127.0.0.1 and [::1] pair no longer fails through MCP or the CLI. That comes from the new queue in 2a66ec909, not from the key. The key still splits the two hosts, and two providers in one process still fail.

Edit: akushonkamen's fix for this is #1604.

Case, one tab unless noted Runs 603d04ee3
127.0.0.1 and [::1], library and MCP 18 All pass, one after the other
With and without a trailing / 6 All pass, one after the other
Two different tabs 6 All pass, but one after the other now
Two providers in one process, 127.0.0.1 and [::1] 3 3 of 3 fail: Another locale override is already in effect

attachedScenarioLeaseKey has not changed since 46c9cba03, where the pair failed 9 of 9. On the built tree, the two hosts still give two keys. The new queue chains every capture on one provider, so the pair now waits its turn. The queue also makes two different tabs wait for each other. That may be on purpose, but I could not tell from the commit.

Not run

Edit: Chromium 154 was checked in the #1604 test. Still not run: two processes at once, and other Chromium versions.

  • Two CLI runs or two MCP servers at once. The lease map is per process, so I expect the split there, but I did not run it.
  • A browser that listens on both addresses by itself. I used a small proxy from [::1] to 127.0.0.1. Headless Chromium 154 listens on 127.0.0.1 only, even with --remote-debugging-address=::.
  • Other Chromium versions than 154.

🤖 Generated with Claude Code (Sonnet 5.5, verified by Opus 5.5) 🦊

<!-- gh-comment-id:6100027472 --> @CassieFox23 commented on GitHub (Oct 10, 2026): **TL;DR:** I retested on main `603d04ee3`: the `127.0.0.1` and `[::1]` pair no longer fails through MCP or the CLI. That comes from the new queue in `2a66ec909`, not from the key. The key still splits the two hosts, and two providers in one process still fail. Edit: akushonkamen's fix for this is #1604. | Case, one tab unless noted | Runs | `603d04ee3` | |---|---|---| | `127.0.0.1` and `[::1]`, library and MCP | 18 | All pass, one after the other | | With and without a trailing `/` | 6 | All pass, one after the other | | Two different tabs | 6 | All pass, but one after the other now | | Two providers in one process, `127.0.0.1` and `[::1]` | 3 | 3 of 3 fail: `Another locale override is already in effect` | `attachedScenarioLeaseKey` has not changed since `46c9cba03`, where the pair failed 9 of 9. On the built tree, the two hosts still give two keys. The new queue chains every capture on one provider, so the pair now waits its turn. The queue also makes two different tabs wait for each other. That may be on purpose, but I could not tell from the commit. ### Not run Edit: Chromium 154 was checked in [the #1604 test](https://github.com/morluto/rea/pull/1604#issuecomment-6100220529). Still not run: two processes at once, and other Chromium versions. - Two CLI runs or two MCP servers at once. The lease map is per process, so I expect the split there, but I did not run it. - ~~A browser that listens on both addresses by itself. I used a small proxy from `[::1]` to `127.0.0.1`.~~ Headless Chromium 154 listens on `127.0.0.1` only, even with `--remote-debugging-address=::`. - Other Chromium versions than 154. 🤖 Generated with Claude Code (Sonnet 5.5, verified by Opus 5.5) 🦊
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#516
No description provided.