chore: migrate Promise.race to scopes to prevent leaks (#24160)

This commit is contained in:
Pavel Feldman 2023-07-11 10:38:08 -07:00 committed by GitHub
parent aeba083da0
commit 067faa50d7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 52 additions and 49 deletions

View file

@ -190,7 +190,7 @@ export abstract class BrowserContext extends SdkObject {
const [, ...otherPages] = this.pages();
for (const p of otherPages)
await p.close(metadata);
if (page && page._crashedPromise.isDone()) {
if (page && page._crashedRace.isDone()) {
await page.close(metadata);
page = undefined;
}

View file

@ -29,7 +29,7 @@ import * as types from './types';
import { BrowserContext } from './browserContext';
import type { Progress } from './progress';
import { ProgressController } from './progress';
import { assert, constructURLBasedOnBaseURL, makeWaitForNextTask, monotonicTime } from '../utils';
import { ScopedRace, assert, constructURLBasedOnBaseURL, makeWaitForNextTask, monotonicTime } from '../utils';
import { ManualPromise } from '../utils/manualPromise';
import { debugLogger } from '../common/debugLogger';
import type { CallMetadata } from './instrumentation';
@ -476,15 +476,13 @@ export class Frame extends SdkObject {
readonly _page: Page;
private _parentFrame: Frame | null;
_url = '';
private _detached = false;
private _contextData = new Map<types.World, ContextData>();
private _childFrames = new Set<Frame>();
_name = '';
_inflightRequests = new Set<network.Request>();
private _networkIdleTimer: NodeJS.Timer | undefined;
private _setContentCounter = 0;
readonly _detachedPromise: Promise<void>;
private _detachedCallback = () => {};
readonly _detachedRace = new ScopedRace();
private _raceAgainstEvaluationStallingEventsPromises = new Set<ManualPromise<any>>();
readonly _redirectedNavigations = new Map<string, { url: string, gotoPromise: Promise<network.Response | null> }>(); // documentId -> data
readonly selectors: FrameSelectors;
@ -498,8 +496,6 @@ export class Frame extends SdkObject {
this._currentDocument = { documentId: undefined, request: undefined };
this.selectors = new FrameSelectors(this);
this._detachedPromise = new Promise<void>(x => this._detachedCallback = x);
this._contextData.set('main', { contextPromise: new ManualPromise(), context: null });
this._contextData.set('utility', { contextPromise: new ManualPromise(), context: null });
this._setContext('main', null);
@ -514,7 +510,7 @@ export class Frame extends SdkObject {
}
isDetached(): boolean {
return this._detached;
return this._detachedRace.isDone();
}
_onLifecycleEvent(event: RegularLifecycleEvent) {
@ -617,21 +613,20 @@ export class Frame extends SdkObject {
}
async raceNavigationAction(progress: Progress, options: types.GotoOptions, action: () => Promise<network.Response | null>): Promise<network.Response | null> {
return Promise.race([
this._page._disconnectedPromise.then(() => { throw new Error('Navigation failed because page was closed!'); }),
this._page._crashedPromise.then(() => { throw new Error('Navigation failed because page crashed!'); }),
this._detachedPromise.then(() => { throw new Error('Navigating frame was detached!'); }),
action().catch(e => {
if (e instanceof NavigationAbortedError && e.documentId) {
const data = this._redirectedNavigations.get(e.documentId);
if (data) {
progress.log(`waiting for redirected navigation to "${data.url}"`);
return data.gotoPromise;
}
return ScopedRace.raceMultiple([
this._detachedRace,
this._page._disconnectedRace,
this._page._crashedRace,
], action().catch(e => {
if (e instanceof NavigationAbortedError && e.documentId) {
const data = this._redirectedNavigations.get(e.documentId);
if (data) {
progress.log(`waiting for redirected navigation to "${data.url}"`);
return data.gotoPromise;
}
throw e;
}),
]);
}
throw e;
}));
}
redirectNavigation(url: string, documentId: string, referer: string | undefined) {
@ -1057,12 +1052,12 @@ export class Frame extends SdkObject {
if (timeout) {
// Make sure we react immediately upon page close or frame detach.
// We need this to show expected/received values in time.
await Promise.race([
this._page._disconnectedPromise,
this._page._crashedPromise,
this._detachedPromise,
new Promise(f => setTimeout(f, timeout)),
]);
const actionPromise = new Promise(f => setTimeout(f, timeout));
await ScopedRace.raceMultiple([
this._page._disconnectedRace,
this._page._crashedRace,
this._detachedRace,
], actionPromise);
}
progress.throwIfAborted();
try {
@ -1538,9 +1533,8 @@ export class Frame extends SdkObject {
_onDetached() {
this._stopNetworkIdleTimer();
this._detached = true;
this._detachedCallback();
const error = new Error('Frame was detached');
this._detachedRace.scopeClosed(error);
for (const data of this._contextData.values()) {
if (data.context)
data.context.contextDestroyed(error);
@ -1606,7 +1600,7 @@ export class Frame extends SdkObject {
_contextDestroyed(context: dom.FrameExecutionContext) {
// Sometimes we get this after detach, in which case we should not reset
// our already destroyed contexts to something that will never resolve.
if (this._detached)
if (this._detachedRace.isDone())
return;
context.contextDestroyed(new Error('Execution context was destroyed, most likely because of a navigation'));
for (const [world, data] of this._contextData) {
@ -1620,7 +1614,7 @@ export class Frame extends SdkObject {
// We should not start a timer and report networkidle in detached frames.
// This happens at least in Firefox for child frames, where we may get requestFinished
// after the frame was detached - probably a race in the Firefox itself.
if (this._firedLifecycleEvents.has('networkidle') || this._detached)
if (this._firedLifecycleEvents.has('networkidle') || this._detachedRace.isDone())
return;
this._networkIdleTimer = setTimeout(() => {
this._firedNetworkIdleSelf = true;
@ -1709,12 +1703,11 @@ class SignalBarrier {
this._progress.log(` navigated to "${frame._url}"`);
return true;
});
await Promise.race([
frame._page._disconnectedPromise,
frame._page._crashedPromise,
frame._detachedPromise,
waiter.promise,
]).catch(e => {});
await ScopedRace.raceMultiple([
frame._page._disconnectedRace,
frame._page._crashedRace,
frame._detachedRace,
], waiter.promise).catch(() => {});
waiter.dispose();
this.release();
}

View file

@ -31,7 +31,7 @@ import * as accessibility from './accessibility';
import { FileChooser } from './fileChooser';
import type { Progress } from './progress';
import { ProgressController } from './progress';
import { assert, isError } from '../utils';
import { ScopedRace, assert, isError } from '../utils';
import { ManualPromise } from '../utils/manualPromise';
import { debugLogger } from '../common/debugLogger';
import type { ImageComparatorOptions } from '../utils/comparators';
@ -142,8 +142,8 @@ export class Page extends SdkObject {
private _disconnected = false;
private _initialized = false;
private _eventsToEmitAfterInitialized: { event: string | symbol, args: any[] }[] = [];
readonly _disconnectedPromise = new ManualPromise<Error>();
readonly _crashedPromise = new ManualPromise<Error>();
readonly _disconnectedRace = new ScopedRace();
readonly _crashedRace = new ScopedRace();
readonly _browserContext: BrowserContext;
readonly keyboard: input.Keyboard;
readonly mouse: input.Mouse;
@ -285,7 +285,7 @@ export class Page extends SdkObject {
this._frameManager.dispose();
this._frameThrottler.dispose();
this.emit(Page.Events.Crash);
this._crashedPromise.resolve(new Error('Page crashed'));
this._crashedRace.scopeClosed(new Error('Page crashed'));
this.instrumentation.onPageClose(this);
}
@ -294,7 +294,7 @@ export class Page extends SdkObject {
this._frameThrottler.dispose();
assert(!this._disconnected, 'Page disconnected twice');
this._disconnected = true;
this._disconnectedPromise.resolve(new Error('Page closed'));
this._disconnectedRace.scopeClosed(new Error('Page closed'));
}
async _onFileChooserOpened(handle: dom.ElementHandle) {
@ -632,7 +632,7 @@ export class Page extends SdkObject {
}
isClosedOrClosingOrCrashed() {
return this._closedState !== 'open' || this._crashedPromise.isDone();
return this._closedState !== 'open' || this._crashedRace.isDone();
}
_addWorker(workerId: string, worker: Worker) {

View file

@ -59,8 +59,10 @@ export class ManualPromise<T = void> extends Promise<T> {
export class ScopedRace {
private _terminateError: Error | undefined;
private _terminatePromises = new Map<ManualPromise<Error>, Error>();
private _isDone = false;
scopeClosed(error: Error) {
this._isDone = true;
this._terminateError = error;
for (const [p, e] of this._terminatePromises) {
rewriteErrorMessage(e, error.message);
@ -68,8 +70,16 @@ export class ScopedRace {
}
}
async race<T>(promise: Promise<T>): Promise<T> {
return this._race([promise], false) as Promise<T>;
isDone() {
return this._isDone;
}
static async raceMultiple<T>(scopes: ScopedRace[], promise: Promise<T>): Promise<T> {
return Promise.race(scopes.map(s => s.race(promise)));
}
async race<T>(promise: Promise<T> | Promise<T>[]): Promise<T> {
return this._race(Array.isArray(promise) ? promise : [promise], false) as Promise<T>;
}
async safeRace<T>(promise: Promise<T>, defaultValue?: T): Promise<T> {

View file

@ -67,7 +67,7 @@ test('should cancel navigation when page crashes', async ({ server, page, crash
await page.waitForNavigation({ waitUntil: 'domcontentloaded' });
crash();
const error = await promise;
expect(error.message).toContain('Navigation failed because page crashed');
expect(error.message).toContain('page.goto: Page crashed');
});
test('should be able to close context when page crashes', async ({ isAndroid, isElectron, isWebView2, page, crash }) => {

View file

@ -38,9 +38,9 @@ it('should reject when frame detaches', async ({ page, server, browserName }) =>
await page.$eval('iframe', frame => frame.remove());
const error = await navigationPromise;
if (browserName === 'chromium')
expect(error.message.includes('net::ERR_ABORTED') || error.message.includes('frame was detached')).toBe(true);
expect(error.message.includes('net::ERR_ABORTED') || error.message.includes('Frame was detached')).toBe(true);
else
expect(error.message).toContain('frame was detached');
expect(error.message).toContain('Frame was detached');
});
it('should continue after client redirect', async ({ page, server, isAndroid }) => {