From a96f5e777a493d7817896b743ab6b3beaaae6f2b Mon Sep 17 00:00:00 2001 From: 20syldev Date: Fri, 10 Apr 2026 11:00:53 +0200 Subject: [PATCH 1/2] fix: skip frozen/discarded targets in page enumeration --- src/McpContext.ts | 49 ++++++++++++++++++++++++++++++++++++---- tests/McpContext.test.ts | 26 +++++++++++++++++++++ 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/src/McpContext.ts b/src/McpContext.ts index 32b7413e6..1250cea9b 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -562,11 +562,47 @@ export class McpContext implements Context { isolatedContextNames: Map; }> { const defaultCtx = this.browser.defaultBrowserContext(); - const allPages = await this.browser.pages( - this.#options.experimentalIncludeAllPages, - ); + // Enumerate targets individually instead of calling browser.pages() so + // that a single frozen/discarded background tab that times out on + // Network.enable cannot abort the entire page enumeration (see #1230). const allTargets = this.browser.targets(); + const pageTargets = allTargets.filter(target => { + const type = target.type(); + if (type === 'page') return true; + if (this.#options.experimentalIncludeAllPages) { + return type === 'background_page' || type === 'webview'; + } + return false; + }); + const pageResults = await Promise.all( + pageTargets.map(async target => { + try { + const page = await Promise.race([ + target.page(), + new Promise(resolve => + setTimeout(() => resolve(null), DEFAULT_TIMEOUT), + ), + ]); + if (!page) { + this.logger( + 'Timed out attaching to target at', + target.url(), + '— likely frozen or discarded', + ); + } + return page; + } catch (err) { + this.logger( + 'Skipping frozen/discarded target at', + target.url(), + err, + ); + return null; + } + }), + ); + const allPages = pageResults.filter((p): p is Page => p !== null); const extensionTargets = allTargets.filter(target => { return ( target.url().startsWith('chrome-extension://') && @@ -576,7 +612,12 @@ export class McpContext implements Context { for (const target of extensionTargets) { // Right now target.page() returns null for popup and side panel pages. - let page = await target.page(); + let page = await Promise.race([ + target.page(), + new Promise(resolve => + setTimeout(() => resolve(null), DEFAULT_TIMEOUT), + ), + ]); if (!page) { // We need to cache pages instances for targets because target.asPage() // returns a new page instance every time. diff --git a/tests/McpContext.test.ts b/tests/McpContext.test.ts index 31a6c88b3..fcb289cf4 100644 --- a/tests/McpContext.test.ts +++ b/tests/McpContext.test.ts @@ -7,6 +7,7 @@ import assert from 'node:assert'; import {afterEach, describe, it} from 'node:test'; +import type {Target} from 'puppeteer-core'; import sinon from 'sinon'; import {NetworkFormatter} from '../src/formatters/NetworkFormatter.js'; @@ -208,4 +209,29 @@ describe('McpContext', () => { fromStub.restore(); }); }); + + it('should skip frozen targets that hang on target.page()', async () => { + await withMcpContext(async (_response, context) => { + const browser = context.browser; + const realTargets = browser.targets(); + + // Inject a fake target whose page() never resolves (simulates a frozen tab). + const frozenTarget = { + type: () => 'page', + url: () => 'https://frozen-tab.example.com', + page: () => new Promise(() => {}), + } as unknown as Target; + + sinon.stub(browser, 'targets').returns([...realTargets, frozenTarget]); + + const start = Date.now(); + const pages = await context.createPagesSnapshot(); + const elapsed = Date.now() - start; + + // The frozen target should be skipped, not block for 180s. + assert.ok(elapsed < 15_000, `Took ${elapsed}ms, expected < 15s`); + // Real pages should still be enumerated. + assert.ok(pages.length > 0, 'Should still enumerate healthy pages'); + }); + }); }); From 2667068fc9988fdc0f266f6d82657ed1b67927eb Mon Sep 17 00:00:00 2001 From: 20syldev Date: Fri, 10 Apr 2026 14:02:00 +0200 Subject: [PATCH 2/2] test: add integration test with crashed renderer tab Launches Chrome, crashes a tab via chrome://crash, disconnects and reconnects to verify page enumeration skips the dead target. --- tests/McpContext.test.ts | 66 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/tests/McpContext.test.ts b/tests/McpContext.test.ts index fcb289cf4..134c622e4 100644 --- a/tests/McpContext.test.ts +++ b/tests/McpContext.test.ts @@ -7,9 +7,12 @@ import assert from 'node:assert'; import {afterEach, describe, it} from 'node:test'; +import logger from 'debug'; +import puppeteer, {Locator} from 'puppeteer'; import type {Target} from 'puppeteer-core'; import sinon from 'sinon'; +import {McpContext} from '../src/McpContext.js'; import {NetworkFormatter} from '../src/formatters/NetworkFormatter.js'; import type {HTTPResponse} from '../src/third_party/index.js'; import type {TraceResult} from '../src/trace-processing/parse.js'; @@ -234,4 +237,67 @@ describe('McpContext', () => { assert.ok(pages.length > 0, 'Should still enumerate healthy pages'); }); }); + + it('should enumerate pages when a tab has a crashed renderer', async () => { + const browser = await puppeteer.launch({ + headless: true, + args: ['--remote-debugging-port=0'], + enableExtensions: true, + handleDevToolsAsPage: true, + executablePath: process.env.PUPPETEER_EXECUTABLE_PATH, + }); + + try { + const wsEndpoint = browser.wsEndpoint(); + + const activePage = await browser.newPage(); + await activePage.goto('data:text/html,

Active

'); + + const crashPage = await browser.newPage(); + await crashPage.goto('data:text/html,

Will crash

'); + try { + await crashPage.goto('chrome://crash'); + } catch {} + + browser.disconnect(); + + const reconnected = await puppeteer.connect({ + browserWSEndpoint: wsEndpoint, + handleDevToolsAsPage: true, + }); + + try { + const start = Date.now(); + const ctx = await McpContext.from( + reconnected, + logger('test'), + { + experimentalDevToolsDebugging: false, + performanceCrux: false, + }, + Locator, + ); + const elapsed = Date.now() - start; + + assert.ok( + elapsed < 20_000, + `Took ${elapsed}ms, expected < 20s`, + ); + const pages = ctx.getPages(); + assert.ok( + pages.length >= 1, + `Expected at least 1 page, got ${pages.length}`, + ); + + ctx.dispose(); + } finally { + await reconnected.close(); + } + } catch (e) { + if (browser.connected) { + await browser.close(); + } + throw e; + } + }); });