Skip to content

Fix #2472: scope frame ID generator to Browser, not Session#2474

Merged
karlseguin merged 1 commit into
lightpanda-io:mainfrom
staylor:fix/2472-frame-id-reset
May 16, 2026
Merged

Fix #2472: scope frame ID generator to Browser, not Session#2474
karlseguin merged 1 commit into
lightpanda-io:mainfrom
staylor:fix/2472-frame-id-reset

Conversation

@staylor
Copy link
Copy Markdown
Contributor

@staylor staylor commented May 15, 2026

Fixes #2472.

Summary

CDP target IDs (FID-{d:0>10}) must stay unique for the lifetime of the CDP connection. Playwright's CRBrowser._onAttachedToTarget records every attached target in an in-memory map and asserts on collision; the assertion is fatal and the connection is unusable afterwards.

Before this PR, Session.frame_id_gen reset to 0 in two places:

  1. tearDownActivePage explicitly reset to 0 after every page teardown. The reset was likely intended to mimic pre-pending-page numbering within a single Session, but invisible there because the immediately-following installNewActivePage typically reuses the old frame's explicit frame_id (see replaceRootImmediate).

  2. Fresh Sessions started from the field default of 0. Each Target.createBrowserContext calls Browser.newSession, which deinits the old Session and constructs a new one -- so even without (1), the next BrowserContext's first page would still get FID-0000000001.

(2) is the operative cause of the Duplicate target FID-0000000001 collision in the issue: the second browser.newContext() on a connection allocates a fresh Session whose first page re-issues the same FID-0000000001 as the first context's frame.

Fix

Move frame_id_gen (and nextFrameId) from Session to Browser. Browser is per-CDP-connection (each CDP holds one Browser, each Browser holds one Session at a time), so the counter now spans the entire connection lifetime as the CDP spec / Chrome behaviour requires.

Existing callers (Session.createPage, Frame.zig:1327, Frame.zig:1437, Worker.zig:74) still go through Session.nextFrameId -- it's now a thin pass-through to browser.nextFrameId() -- so no call sites change. The explicit reset in tearDownActivePage is removed; it was redundant within a Session (root navigation reuses the old frame_id) and harmful across Sessions.

loader_id_gen stays on Session: Loader IDs (LID-...) are per-frame in CDP and Playwright doesn't track them in the target registry, so the per-Session reset is correct there.

Verification

Repro from the issue (playwright-core@1.58.2):

for (let i = 1; i <= 5; i++) {
  const ctx = await browser.newContext();
  await ctx.newPage();
  await ctx.close();
  console.log(`cycle ${i} ok`);
}

Before:

cycle 1 ok
Error: Duplicate target FID-0000000001
    at CRBrowser._onAttachedToTarget

After:

cycle 1 ok
cycle 2 ok
cycle 3 ok
cycle 4 ok
cycle 5 ok
ALL CYCLES OK

Tests

zig build test: 653 / 653 pass (652 existing + 1 new).

Added cdp.target: createTarget assigns unique IDs across BrowserContexts (issue #2472) -- creates a target, disposes the context, creates another target, and asserts the two target_ids differ. Verified the regression test catches the bug:

$ git stash push src/browser/Browser.zig src/browser/Session.zig
$ zig build test
[31mcdp.target: createTarget assigns unique IDs across BrowserContexts (issue #2472) (51.05ms)
652 of 653 tests passed

Reverting the Browser.zig + Session.zig changes (keeping only the test) reproduces the failure.

Notes

Independent of #2399 (Playwright connectOverCDP synthetic-STARTUP promotion) but interacts with it. #2399 makes the synthetic STARTUP target's targetId deterministically match the first real frame ID (FID-0000000001) so the synthetic-to-real handoff doesn't mismatch in Playwright's registry. That alignment now holds across BrowserContext lifecycle too -- the second context's first frame is FID-0000000002, not a duplicate of the synthetic.

The deferred-dispose path in CDP.disposeBrowserContext (src/cdp/CDP.zig:383) was a possibly-related second finding noted in the issue (script-eval reentrant teardown leaving browser_context != null, surfacing as Cannot have more than one browser context at a time). It's not addressed here -- I couldn't isolate it in a standalone repro and it may turn out to be the same root cause surfacing differently. Filing separately if it persists once this lands.

CDP target IDs (`FID-{d:0>10}`) must stay unique for the lifetime of
the CDP connection -- Playwright's `CRBrowser._onAttachedToTarget`
asserts on duplicates and the assertion is fatal (the connection is
unusable afterwards).

Before this fix, `Session.frame_id_gen` reset to 0 in two places:

  1. `tearDownActivePage` explicitly reset to 0 after every page
     teardown (likely intended to mimic pre-pending-page numbering
     within a single Session, but invisible there because the
     immediately-following `installNewActivePage` typically reuses
     the old frame's explicit `frame_id`, see `replaceRootImmediate`).

  2. Fresh Sessions started from the field default of 0. Each
     `Target.createBrowserContext` calls `Browser.newSession`, which
     deinits the old Session and constructs a new one -- so even
     without (1), the next BrowserContext's first page would still
     get `FID-0000000001`.

(2) is what trips Playwright on the second `browser.newContext()`
on a connection: the second context's first frame re-issues
`FID-0000000001`, identical to the first context's frame, and
Playwright's `CRBrowser._onAttachedToTarget` raises
`Duplicate target FID-0000000001`.

Move `frame_id_gen` (and `nextFrameId`) from `Session` to `Browser`,
which is per-CDP-connection. Existing callers (`Session.createPage`,
`Frame.zig:1327`, `Frame.zig:1437`, `Worker.zig:74`) still go through
`Session.nextFrameId` -- it's now a thin pass-through to
`browser.nextFrameId()` -- so no call sites change. Removed the
explicit reset in `tearDownActivePage`; it was redundant within a
Session (root navigation reuses the old frame_id) and harmful across
Sessions.

`loader_id_gen` stays on Session: Loader IDs (`LID-...`) are scoped
per-frame in CDP and Playwright doesn't track them in the target
registry, so the per-Session reset is correct there.

Repro (`playwright-core@1.58.2`):

  for (let i = 1; i <= 3; i++) {
    const ctx = await browser.newContext();
    await ctx.newPage();
    await ctx.close();
  }

Before: cycle 2 throws `Duplicate target FID-0000000001`.
After: 5/5 cycles complete cleanly.

Tests: 653/653 pass. Added regression coverage in
`cdp.target: createTarget assigns unique IDs across BrowserContexts
(issue lightpanda-io#2472)` -- verified to fail against the original source
(reverted Browser.zig and Session.zig, kept the test, ran zig build
test: only the new test fails).
@karlseguin karlseguin merged commit 0b358fd into lightpanda-io:main May 16, 2026
22 of 23 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators May 16, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Frame ID generator resets on page teardown, causing CDP target ID collisions across BrowserContexts

2 participants