diff --git a/packages/playwright-core/src/client/browserContext.ts b/packages/playwright-core/src/client/browserContext.ts index cd7333f0f3..5637965bc8 100644 --- a/packages/playwright-core/src/client/browserContext.ts +++ b/packages/playwright-core/src/client/browserContext.ts @@ -149,7 +149,7 @@ export class BrowserContext extends ChannelOwner if (page) page.emit(Events.Page.RequestFinished, request); if (response) - response._finishedPromise.resolve(); + response._finishedPromise.resolve(null); } async _onRoute(route: network.Route) { diff --git a/packages/playwright-core/src/client/network.ts b/packages/playwright-core/src/client/network.ts index 6290492466..b0280a73fe 100644 --- a/packages/playwright-core/src/client/network.ts +++ b/packages/playwright-core/src/client/network.ts @@ -23,7 +23,7 @@ import type { Headers, RemoteAddr, SecurityDetails, WaitForEventOptions } from ' import fs from 'fs'; import { mime } from '../utilsBundle'; import { assert, isString, headersObjectToArray, isRegExp } from '../utils'; -import { ManualPromise } from '../utils/manualPromise'; +import { ManualPromise, ScopedRace } from '../utils/manualPromise'; import { Events } from './events'; import type { Page } from './page'; import { Waiter } from './waiter'; @@ -33,7 +33,6 @@ import { urlMatches } from '../utils/network'; import { MultiMap } from '../utils/multimap'; import { APIResponse } from './fetch'; import type { Serializable } from '../../types/structs'; -import { kBrowserOrContextClosedError } from '../common/errors'; export type NetworkCookie = { name: string, @@ -271,8 +270,8 @@ export class Request extends ChannelOwner implements ap return this._fallbackOverrides; } - _targetClosedPromise(): Promise { - return this.serviceWorker()?._closedPromise || this.frame()._page?._closedOrCrashedPromise || new Promise(() => {}); + _targetClosedRace(): ScopedRace { + return this.serviceWorker()?._closedRace || this.frame()._page?._closedOrCrashedRace || new ScopedRace(); } } @@ -295,10 +294,7 @@ export class Route extends ChannelOwner implements api.Ro // When page closes or crashes, we catch any potential rejects from this Route. // Note that page could be missing when routing popup's initial request that // does not have a Page initialized just yet. - return Promise.race([ - promise, - this.request()._targetClosedPromise(), - ]); + return this.request()._targetClosedRace().safeRace(promise); } _startHandling(): Promise { @@ -452,7 +448,7 @@ export class Response extends ChannelOwner implements private _provisionalHeaders: RawHeaders; private _actualHeadersPromise: Promise | undefined; private _request: Request; - readonly _finishedPromise = new ManualPromise(); + readonly _finishedPromise = new ManualPromise(); static from(response: channels.ResponseChannel): Response { return (response as any)._object; @@ -523,12 +519,7 @@ export class Response extends ChannelOwner implements } async finished(): Promise { - return Promise.race([ - this._finishedPromise.then(() => null), - this.request()._targetClosedPromise().then(() => { - throw new Error(kBrowserOrContextClosedError); - }), - ]); + return this.request()._targetClosedRace().race(this._finishedPromise); } async body(): Promise { diff --git a/packages/playwright-core/src/client/page.ts b/packages/playwright-core/src/client/page.ts index 2282e5a391..07fa5286cc 100644 --- a/packages/playwright-core/src/client/page.ts +++ b/packages/playwright-core/src/client/page.ts @@ -19,12 +19,12 @@ import fs from 'fs'; import path from 'path'; import type * as structs from '../../types/structs'; import type * as api from '../../types/types'; -import { isSafeCloseError } from '../common/errors'; +import { isSafeCloseError, kBrowserOrContextClosedError } from '../common/errors'; import { urlMatches } from '../utils/network'; import { TimeoutSettings } from '../common/timeoutSettings'; import type * as channels from '@protocol/channels'; import { parseError, serializeError } from '../protocol/serializers'; -import { assert, headersObjectToArray, isObject, isRegExp, isString } from '../utils'; +import { assert, headersObjectToArray, isObject, isRegExp, isString, ScopedRace } from '../utils'; import { mkdirIfNeeded } from '../utils/fileUtils'; import { Accessibility } from './accessibility'; import { Artifact } from './artifact'; @@ -80,7 +80,7 @@ export class Page extends ChannelOwner implements api.Page private _frames = new Set(); _workers = new Set(); private _closed = false; - _closedOrCrashedPromise: Promise; + readonly _closedOrCrashedRace = new ScopedRace(); private _viewportSize: Size | null; private _routes: RouteHandler[] = []; @@ -153,10 +153,8 @@ export class Page extends ChannelOwner implements api.Page this.coverage = new Coverage(this._channel); - this._closedOrCrashedPromise = Promise.race([ - new Promise(f => this.once(Events.Page.Close, f)), - new Promise(f => this.once(Events.Page.Crash, f)), - ]); + this.once(Events.Page.Close, () => this._closedOrCrashedRace.scopeClosed(new Error(kBrowserOrContextClosedError))); + this.once(Events.Page.Crash, () => this._closedOrCrashedRace.scopeClosed(new Error(kBrowserOrContextClosedError))); this._setEventToSubscriptionMapping(new Map([ [Events.Page.Request, 'request'], @@ -693,10 +691,7 @@ export class Page extends ChannelOwner implements api.Page async pause() { if (require('inspector').url()) return; - await Promise.race([ - this.context()._channel.pause(), - this._closedOrCrashedPromise - ]); + await this._closedOrCrashedRace.safeRace(this.context()._channel.pause()); } async pdf(options: PDFOptions = {}): Promise { diff --git a/packages/playwright-core/src/client/video.ts b/packages/playwright-core/src/client/video.ts index 51f0d4a1a3..2f8da237db 100644 --- a/packages/playwright-core/src/client/video.ts +++ b/packages/playwright-core/src/client/video.ts @@ -18,22 +18,20 @@ import type { Page } from './page'; import type * as api from '../../types/types'; import type { Artifact } from './artifact'; import type { Connection } from './connection'; +import { ManualPromise } from '../utils'; export class Video implements api.Video { private _artifact: Promise | null = null; - private _artifactCallback = (artifact: Artifact) => {}; + private _artifactReadyPromise = new ManualPromise(); private _isRemote = false; constructor(page: Page, connection: Connection) { this._isRemote = connection.isRemote(); - this._artifact = Promise.race([ - new Promise(f => this._artifactCallback = f), - page._closedOrCrashedPromise.then(() => null), - ]); + this._artifact = page._closedOrCrashedRace.safeRace(this._artifactReadyPromise); } _artifactReady(artifact: Artifact) { - this._artifactCallback(artifact); + this._artifactReadyPromise.resolve(artifact); } async path(): Promise { diff --git a/packages/playwright-core/src/client/worker.ts b/packages/playwright-core/src/client/worker.ts index fe86d18f06..94f161dcbf 100644 --- a/packages/playwright-core/src/client/worker.ts +++ b/packages/playwright-core/src/client/worker.ts @@ -22,11 +22,13 @@ import type { Page } from './page'; import type { BrowserContext } from './browserContext'; import type * as api from '../../types/types'; import type * as structs from '../../types/structs'; +import { ScopedRace } from '../utils'; +import { kBrowserOrContextClosedError } from '../common/errors'; export class Worker extends ChannelOwner implements api.Worker { _page: Page | undefined; // Set for web workers. _context: BrowserContext | undefined; // Set for service workers. - _closedPromise: Promise; + readonly _closedRace = new ScopedRace(); static from(worker: channels.WorkerChannel): Worker { return (worker as any)._object; @@ -41,7 +43,7 @@ export class Worker extends ChannelOwner implements api. this._context._serviceWorkers.delete(this); this.emit(Events.Worker.Close, this); }); - this._closedPromise = new Promise(f => this.once(Events.Worker.Close, f)); + this.once(Events.Worker.Close, () => this._closedRace.scopeClosed(new Error(kBrowserOrContextClosedError))); } url(): string { diff --git a/packages/playwright-core/src/server/javascript.ts b/packages/playwright-core/src/server/javascript.ts index f67a3f6772..db2c64e84e 100644 --- a/packages/playwright-core/src/server/javascript.ts +++ b/packages/playwright-core/src/server/javascript.ts @@ -19,7 +19,7 @@ import * as utilityScriptSource from '../generated/utilityScriptSource'; import { serializeAsCallArgument } from './isomorphic/utilityScriptSerializers'; import { type UtilityScript } from './injected/utilityScript'; import { SdkObject } from './instrumentation'; -import { ManualPromise } from '../utils/manualPromise'; +import { ScopedRace } from '../utils/manualPromise'; export type ObjectId = string; export type RemoteObject = { @@ -62,7 +62,7 @@ export interface ExecutionContextDelegate { export class ExecutionContext extends SdkObject { private _delegate: ExecutionContextDelegate; private _utilityScriptPromise: Promise | undefined; - private _destroyedPromise = new ManualPromise(); + private _contextDestroyedRace = new ScopedRace(); constructor(parent: SdkObject, delegate: ExecutionContextDelegate) { super(parent, 'execution-context'); @@ -70,14 +70,11 @@ export class ExecutionContext extends SdkObject { } contextDestroyed(error: Error) { - this._destroyedPromise.resolve(error); + this._contextDestroyedRace.scopeClosed(error); } - _raceAgainstContextDestroyed(promise: Promise): Promise { - return Promise.race([ - this._destroyedPromise.then(e => { throw e; }), - promise, - ]); + async _raceAgainstContextDestroyed(promise: Promise): Promise { + return this._contextDestroyedRace.race(promise); } rawEvaluateJSON(expression: string): Promise { diff --git a/packages/playwright-core/src/utils/manualPromise.ts b/packages/playwright-core/src/utils/manualPromise.ts index 21ce630ca6..d5eae1c274 100644 --- a/packages/playwright-core/src/utils/manualPromise.ts +++ b/packages/playwright-core/src/utils/manualPromise.ts @@ -53,3 +53,37 @@ export class ManualPromise extends Promise { return 'ManualPromise'; } } + +export class ScopedRace { + private _terminateError: Error | undefined; + private _terminatePromises = new Set>(); + + scopeClosed(error: Error) { + this._terminateError = error; + for (const p of this._terminatePromises) + p.resolve(error); + } + + async race(promise: Promise): Promise { + return this._race([promise], false) as Promise; + } + + async safeRace(promise: Promise, defaultValue?: T): Promise { + return this._race([promise], true, defaultValue); + } + + private async _race(promises: Promise[], safe: boolean, defaultValue?: any): Promise { + const terminatePromise = new ManualPromise(); + if (this._terminateError) + terminatePromise.resolve(this._terminateError); + this._terminatePromises.add(terminatePromise); + try { + return await Promise.race([ + terminatePromise.then(e => safe ? defaultValue : Promise.reject(e)), + ...promises + ]); + } finally { + this._terminatePromises.delete(terminatePromise); + } + } +}