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) {