Skip to content

Commit 92aa2fc

Browse files
committed
fix: skip frozen/discarded targets in page enumeration
1 parent 582c9e0 commit 92aa2fc

File tree

2 files changed

+71
-4
lines changed

2 files changed

+71
-4
lines changed

src/McpContext.ts

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -562,11 +562,47 @@ export class McpContext implements Context {
562562
isolatedContextNames: Map<Page, string>;
563563
}> {
564564
const defaultCtx = this.browser.defaultBrowserContext();
565-
const allPages = await this.browser.pages(
566-
this.#options.experimentalIncludeAllPages,
567-
);
568565

566+
// Enumerate targets individually instead of calling browser.pages() so
567+
// that a single frozen/discarded background tab that times out on
568+
// Network.enable cannot abort the entire page enumeration (see #1230).
569569
const allTargets = this.browser.targets();
570+
const pageTargets = allTargets.filter(target => {
571+
const type = target.type();
572+
if (type === 'page') return true;
573+
if (this.#options.experimentalIncludeAllPages) {
574+
return type === 'background_page' || type === 'webview';
575+
}
576+
return false;
577+
});
578+
const pageResults = await Promise.all(
579+
pageTargets.map(async target => {
580+
try {
581+
const page = await Promise.race([
582+
target.page(),
583+
new Promise<null>(resolve =>
584+
setTimeout(() => resolve(null), DEFAULT_TIMEOUT),
585+
),
586+
]);
587+
if (!page) {
588+
this.logger(
589+
'Timed out attaching to target at',
590+
target.url(),
591+
'— likely frozen or discarded',
592+
);
593+
}
594+
return page;
595+
} catch (err) {
596+
this.logger(
597+
'Skipping frozen/discarded target at',
598+
target.url(),
599+
err,
600+
);
601+
return null;
602+
}
603+
}),
604+
);
605+
const allPages = pageResults.filter((p): p is Page => p !== null);
570606
const extensionTargets = allTargets.filter(target => {
571607
return (
572608
target.url().startsWith('chrome-extension://') &&
@@ -576,7 +612,12 @@ export class McpContext implements Context {
576612

577613
for (const target of extensionTargets) {
578614
// Right now target.page() returns null for popup and side panel pages.
579-
let page = await target.page();
615+
let page = await Promise.race([
616+
target.page(),
617+
new Promise<null>(resolve =>
618+
setTimeout(() => resolve(null), DEFAULT_TIMEOUT),
619+
),
620+
]);
580621
if (!page) {
581622
// We need to cache pages instances for targets because target.asPage()
582623
// returns a new page instance every time.

tests/McpContext.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import assert from 'node:assert';
88
import {afterEach, describe, it} from 'node:test';
99

10+
import type {Target} from 'puppeteer-core';
1011
import sinon from 'sinon';
1112

1213
import {NetworkFormatter} from '../src/formatters/NetworkFormatter.js';
@@ -208,4 +209,29 @@ describe('McpContext', () => {
208209
fromStub.restore();
209210
});
210211
});
212+
213+
it('should skip frozen targets that hang on target.page()', async () => {
214+
await withMcpContext(async (_response, context) => {
215+
const browser = context.browser;
216+
const realTargets = browser.targets();
217+
218+
// Inject a fake target whose page() never resolves (simulates a frozen tab).
219+
const frozenTarget = {
220+
type: () => 'page',
221+
url: () => 'https://frozen-tab.example.com',
222+
page: () => new Promise<null>(() => {}),
223+
} as unknown as Target;
224+
225+
sinon.stub(browser, 'targets').returns([...realTargets, frozenTarget]);
226+
227+
const start = Date.now();
228+
const pages = await context.createPagesSnapshot();
229+
const elapsed = Date.now() - start;
230+
231+
// The frozen target should be skipped, not block for 180s.
232+
assert.ok(elapsed < 15_000, `Took ${elapsed}ms, expected < 15s`);
233+
// Real pages should still be enumerated.
234+
assert.ok(pages.length > 0, 'Should still enumerate healthy pages');
235+
});
236+
});
211237
});

0 commit comments

Comments
 (0)