From 213a234d10820b4c658a110f28c1eaa08fdc62a7 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Wed, 27 May 2026 07:23:09 -0700 Subject: [PATCH] migrate 3 CDP-session sites to lifecycle helpers Fixes the CDP-target leak class identified by /codex outside-voice on the eng review (D11 EXPAND_SCOPE). All three sites called `page.context().newCDPSession(page)` directly and either forgot the detach entirely (cdp-bridge cache cleanup), only detached on the success path (write-commands archive), or detached on framenavigated but not page-close (cdp-inspector). - cdp-bridge.ts: `getCdpSession` now delegates to `getOrCreateCdpSession`, which registers a `page.once('close')` hook that BOTH removes the cache entry AND calls `session.detach()`. - cdp-inspector.ts: same migration for the inspector's session pool. Keeps the existing framenavigated detach (more granular than close for DOM/CSS state invalidation) plus an inspector-layer close hook for the initializedPages WeakSet. - write-commands.ts archive: wraps Page.captureSnapshot in withCdpSession so the detach runs in `finally`, including the path where captureSnapshot throws. The static-grep tripwire (next commit) pins the invariant so future direct calls to newCDPSession fail CI. Co-Authored-By: Claude Opus 4.7 (1M context) --- browse/src/cdp-bridge.ts | 16 +++++----------- browse/src/cdp-inspector.ts | 23 ++++++++++++++++------- browse/src/write-commands.ts | 8 +++++--- 3 files changed, 26 insertions(+), 21 deletions(-) diff --git a/browse/src/cdp-bridge.ts b/browse/src/cdp-bridge.ts index cf1b43eef..3d1fa3d8d 100644 --- a/browse/src/cdp-bridge.ts +++ b/browse/src/cdp-bridge.ts @@ -95,20 +95,14 @@ export async function getOrCreateCdpSession( // ─── $B cdp bridge ───────────────────────────────────────────── -// Per-page CDPSession cache. Created lazily on first allow-listed call, -// cleaned up when the page closes. TODO(C2): migrate to getOrCreateCdpSession -// so the session also detaches on close (currently only the cache entry is -// removed; the Chromium-side target lingers). +// Per-page CDPSession cache. Lifecycle delegated to getOrCreateCdpSession +// which registers a close hook that BOTH removes the cache entry AND calls +// session.detach() — pre-helper code only did the former, leaving the +// Chromium-side target attached. const sessionCache: WeakMap = new WeakMap(); async function getCdpSession(page: Page): Promise { - let s = sessionCache.get(page); - if (s) return s; - s = await page.context().newCDPSession(page); - sessionCache.set(page, s); - // Clear cache on detach so we don't hold a stale handle. - page.once('close', () => sessionCache.delete(page)); - return s; + return getOrCreateCdpSession(page, sessionCache); } export interface CdpDispatchInput { diff --git a/browse/src/cdp-inspector.ts b/browse/src/cdp-inspector.ts index 4315ddd89..293b6eb8b 100644 --- a/browse/src/cdp-inspector.ts +++ b/browse/src/cdp-inspector.ts @@ -13,6 +13,7 @@ */ import type { Page } from 'playwright'; +import { getOrCreateCdpSession } from './cdp-bridge'; // ─── Types ────────────────────────────────────────────────────── @@ -106,15 +107,23 @@ async function getOrCreateSession(page: Page): Promise { } } - session = await page.context().newCDPSession(page); - cdpSessions.set(page, session); + session = await getOrCreateCdpSession(page, cdpSessions); - // Enable DOM and CSS domains - await session.send('DOM.enable'); - await session.send('CSS.enable'); - initializedPages.add(page); + // Enable DOM and CSS domains on first init for this page. The session + // itself is cached + close-detached by getOrCreateCdpSession; the + // initializedPages WeakSet is inspector-layer state that needs its + // own close hook to stay in sync. + if (!initializedPages.has(page)) { + await session.send('DOM.enable'); + await session.send('CSS.enable'); + initializedPages.add(page); + page.once('close', () => initializedPages.delete(page)); + } - // Auto-detach on navigation + // Auto-detach on navigation — DOM/CSS domain state is tied to the + // document. Close-detach (from getOrCreateCdpSession) handles the + // tab-close case; framenavigated catches in-tab navigation that + // invalidates inspector state without closing the tab. page.once('framenavigated', () => { try { session.detach().catch(() => {}); diff --git a/browse/src/write-commands.ts b/browse/src/write-commands.ts index daebd18a0..4a847141d 100644 --- a/browse/src/write-commands.ts +++ b/browse/src/write-commands.ts @@ -18,6 +18,7 @@ import type { SetContentWaitUntil } from './tab-session'; import { TEMP_DIR, isPathWithin } from './platform'; import { SAFE_DIRECTORIES } from './path-security'; import { modifyStyle, undoModification, resetModifications, getModificationHistory } from './cdp-inspector'; +import { withCdpSession } from './cdp-bridge'; /** * Aggressive page cleanup selectors and heuristics. @@ -1409,9 +1410,10 @@ export async function handleWriteCommand( validateOutputPath(outputPath); try { - const cdp = await page.context().newCDPSession(page); - const { data } = await cdp.send('Page.captureSnapshot', { format: 'mhtml' }); - await cdp.detach(); + const data = await withCdpSession(page, async (cdp) => { + const result = await cdp.send('Page.captureSnapshot', { format: 'mhtml' }); + return (result as { data: string }).data; + }); fs.writeFileSync(outputPath, data); return `Archive saved: ${outputPath} (${Math.round(data.length / 1024)}KB, MHTML)`; } catch (err: any) {