From e8ec7e51185c65fbb9c3a9683bae6f0e3a1b3d72 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Tue, 10 Dec 2019 15:34:36 -0700 Subject: [PATCH] feat(webkit): pause and configure provisional pages on creation (#200) --- src/page.ts | 2 +- src/webkit/Browser.ts | 13 +++- src/webkit/Connection.ts | 110 ++++++++++++++++++++------ src/webkit/FrameManager.ts | 146 +++++++++++++++-------------------- src/webkit/NetworkManager.ts | 11 ++- src/webkit/Screenshotter.ts | 2 +- src/webkit/Target.ts | 27 ++++--- 7 files changed, 189 insertions(+), 122 deletions(-) diff --git a/src/page.ts b/src/page.ts index 8bc7b78d6d..8d57336867 100644 --- a/src/page.ts +++ b/src/page.ts @@ -78,7 +78,7 @@ export class Page; private _disconnected = false; private _disconnectedCallback: (e: Error) => void; - private _disconnectedPromise: Promise; + readonly _disconnectedPromise: Promise; private _browserContext: BrowserContext; readonly keyboard: input.Keyboard; readonly mouse: input.Mouse; diff --git a/src/webkit/Browser.ts b/src/webkit/Browser.ts index 85d9a9ea46..bbd7a12472 100644 --- a/src/webkit/Browser.ts +++ b/src/webkit/Browser.ts @@ -59,6 +59,12 @@ export class Browser extends EventEmitter { helper.addEventListener(this._connection, 'Target.targetDestroyed', this._onTargetDestroyed.bind(this)), helper.addEventListener(this._connection, 'Target.didCommitProvisionalTarget', this._onProvisionalTargetCommitted.bind(this)), ]; + + // Intercept provisional targets during cross-process navigation. + this._connection.send('Target.setPauseOnStart', { pauseOnStart: true }).catch(e => { + debugError(e); + throw e; + }); } async userAgent(): Promise { @@ -157,6 +163,11 @@ export class Browser extends EventEmitter { context = this._defaultContext; const target = new Target(session, targetInfo, context); this._targets.set(targetInfo.targetId, target); + if (targetInfo.isProvisional) { + const oldTarget = this._targets.get(targetInfo.oldTargetId); + if (oldTarget) + oldTarget._initializeSession(session); + } this._privateEvents.emit(BrowserEvents.TargetCreated, target); } @@ -185,7 +196,7 @@ export class Browser extends EventEmitter { async _onProvisionalTargetCommitted({oldTargetId, newTargetId}) { const oldTarget = this._targets.get(oldTargetId); const newTarget = this._targets.get(newTargetId); - newTarget._swappedIn(oldTarget); + newTarget._swapWith(oldTarget); } disconnect() { diff --git a/src/webkit/Connection.ts b/src/webkit/Connection.ts index 3d986ba686..bb635d66f6 100644 --- a/src/webkit/Connection.ts +++ b/src/webkit/Connection.ts @@ -15,26 +15,29 @@ * limitations under the License. */ -import {assert} from '../helper'; +import {assert, debugError} from '../helper'; import * as debug from 'debug'; import {EventEmitter} from 'events'; import { ConnectionTransport } from '../types'; import { Protocol } from './protocol'; +import { throws } from 'assert'; const debugProtocol = debug('playwright:protocol'); const debugWrappedMessage = require('debug')('wrapped'); export const ConnectionEvents = { - Disconnected: Symbol('ConnectionEvents.Disconnected'), TargetCreated: Symbol('ConnectionEvents.TargetCreated') }; export class Connection extends EventEmitter { _lastId = 0; - private _callbacks = new Map void, reject: (e: Error) => void, error: Error, method: string}>(); - private _delay: number; - private _transport: ConnectionTransport; - private _sessions = new Map(); + private readonly _callbacks = new Map void, reject: (e: Error) => void, error: Error, method: string}>(); + private readonly _delay: number; + private readonly _transport: ConnectionTransport; + private readonly _sessions = new Map(); + private _incomingMessageQueue: string[] = []; + private _dispatchTimerId?: NodeJS.Timer; + _closed = false; constructor(transport: ConnectionTransport, delay: number | undefined = 0) { @@ -68,12 +71,48 @@ export class Connection extends EventEmitter { return id; } - async _onMessage(message: string) { - if (this._delay) - await new Promise(f => setTimeout(f, this._delay)); + private _onMessage(message: string) { + if (this._incomingMessageQueue.length || this._delay) + this._enqueueMessage(message); + else + this._dispatchMessage(message); + } + + private _enqueueMessage(message: string) { + this._incomingMessageQueue.push(message); + this._scheduleQueueDispatch(); + } + + private _enqueueMessages(messages: string[]) { + this._incomingMessageQueue = this._incomingMessageQueue.concat(messages); + this._scheduleQueueDispatch(); + } + + private _scheduleQueueDispatch() { + if (this._dispatchTimerId) + return; + if (!this._incomingMessageQueue.length) + return; + const delay = this._delay || 0; + this._dispatchTimerId = setTimeout(() => { + this._dispatchTimerId = undefined; + this._dispatchOneMessageFromQueue() + }, delay); + } + + private _dispatchOneMessageFromQueue() { + const message = this._incomingMessageQueue.shift(); + try { + this._dispatchMessage(message); + } finally { + this._scheduleQueueDispatch(); + } + } + + private _dispatchMessage(message: string) { debugProtocol('◀ RECV ' + message); const object = JSON.parse(message); - this._dispatchTargetMessageToSession(object); + this._dispatchTargetMessageToSession(object, message); if (object.id) { const callback = this._callbacks.get(object.id); // Callbacks could be all rejected if someone has called `.dispose()`. @@ -91,12 +130,14 @@ export class Connection extends EventEmitter { } } - _dispatchTargetMessageToSession(object: {method: string, params: any}) { + _dispatchTargetMessageToSession(object: {method: string, params: any}, wrappedMessage: string) { if (object.method === 'Target.targetCreated') { - const {targetId, type} = object.params.targetInfo; - const session = new TargetSession(this, type, targetId); - this._sessions.set(targetId, session); + const targetInfo = object.params.targetInfo as Protocol.Target.TargetInfo; + const session = new TargetSession(this, targetInfo); + this._sessions.set(session._sessionId, session); this.emit(ConnectionEvents.TargetCreated, session, object.params.targetInfo); + if (targetInfo.isPaused) + this.send('Target.resume', { targetId: targetInfo.targetId }).catch(debugError); } else if (object.method === 'Target.targetDestroyed') { const session = this._sessions.get(object.params.targetId); if (session) { @@ -104,12 +145,16 @@ export class Connection extends EventEmitter { this._sessions.delete(object.params.targetId); } } else if (object.method === 'Target.dispatchMessageFromTarget') { - const session = this._sessions.get(object.params.targetId); + const {targetId, message} = object.params as Protocol.Target.dispatchMessageFromTargetPayload; + const session = this._sessions.get(targetId); if (!session) - throw new Error('Unknown target: ' + object.params.targetId); - session._dispatchMessageFromTarget(object.params.message); + throw new Error('Unknown target: ' + targetId); + if (session.isProvisional()) + session._addProvisionalMessage(wrappedMessage); + else + session._dispatchMessageFromTarget(message); } else if (object.method === 'Target.didCommitProvisionalTarget') { - const {oldTargetId, newTargetId} = object.params; + const {oldTargetId, newTargetId} = object.params as Protocol.Target.didCommitProvisionalTargetPayload; const newSession = this._sessions.get(newTargetId); if (!newSession) throw new Error('Unknown new target: ' + newTargetId); @@ -117,6 +162,7 @@ export class Connection extends EventEmitter { if (!oldSession) throw new Error('Unknown old target: ' + oldTargetId); oldSession._swappedOut = true; + this._enqueueMessages(newSession._takeProvisionalMessagesAndCommit()); } } @@ -132,7 +178,6 @@ export class Connection extends EventEmitter { for (const session of this._sessions.values()) session._onClosed(); this._sessions.clear(); - this.emit(ConnectionEvents.Disconnected); } dispose() { @@ -149,19 +194,27 @@ export class TargetSession extends EventEmitter { _connection: Connection; private _callbacks = new Map void, reject: (e: Error) => void, error: Error, method: string}>(); private _targetType: string; - private _sessionId: string; + _sessionId: string; _swappedOut = false; + private _provisionalMessages?: string[]; on: (event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this; addListener: (event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this; off: (event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this; removeListener: (event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this; once: (event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this; - constructor(connection: Connection, targetType: string, sessionId: string) { + constructor(connection: Connection, targetInfo: Protocol.Target.TargetInfo) { super(); + const {targetId, type, isProvisional} = targetInfo; this._connection = connection; - this._targetType = targetType; - this._sessionId = sessionId; + this._targetType = type; + this._sessionId = targetId; + if (isProvisional) + this._provisionalMessages = []; + } + + isProvisional() : boolean { + return !!this._provisionalMessages; } send( @@ -194,7 +247,18 @@ export class TargetSession extends EventEmitter { return result; } + _addProvisionalMessage(message: string) { + this._provisionalMessages.push(message); + } + + _takeProvisionalMessagesAndCommit() : string[] { + const messages = this._provisionalMessages; + this._provisionalMessages = undefined; + return messages; + } + _dispatchMessageFromTarget(message: string) { + console.assert(!this.isProvisional()); const object = JSON.parse(message); debugWrappedMessage('◀ RECV ' + JSON.stringify(object, null, 2)); if (object.id && this._callbacks.has(object.id)) { diff --git a/src/webkit/FrameManager.ts b/src/webkit/FrameManager.ts index df5c277454..e7e238f3b9 100644 --- a/src/webkit/FrameManager.ts +++ b/src/webkit/FrameManager.ts @@ -40,7 +40,6 @@ const UTILITY_WORLD_NAME = '__playwright_utility_world__'; export const FrameManagerEvents = { FrameNavigatedWithinDocument: Symbol('FrameNavigatedWithinDocument'), - TargetSwappedOnNavigation: Symbol('TargetSwappedOnNavigation'), FrameAttached: Symbol('FrameAttached'), FrameDetached: Symbol('FrameDetached'), FrameNavigated: Symbol('FrameNavigated'), @@ -58,14 +57,14 @@ export class FrameManager extends EventEmitter implements frames.FrameDelegate, readonly rawKeyboard: RawKeyboardImpl; readonly screenshotterDelegate: WKScreenshotDelegate; _session: TargetSession; - _page: Page; - _networkManager: NetworkManager; - _frames: Map; - _contextIdToContext: Map; - _isolatedWorlds: Set; - _sessionListeners: RegisteredListener[] = []; - _mainFrame: frames.Frame; - private _bootstrapScripts: string[] = []; + readonly _page: Page; + private readonly _networkManager: NetworkManager; + private readonly _frames: Map; + private readonly _contextIdToContext: Map; + private _isolatedWorlds: Set; + private _sessionListeners: RegisteredListener[] = []; + private _mainFrame: frames.Frame; + private readonly _bootstrapScripts: string[] = []; constructor(browserContext: BrowserContext) { super(); @@ -83,34 +82,43 @@ export class FrameManager extends EventEmitter implements frames.FrameDelegate, this._networkManager.on(NetworkManagerEvents.RequestFinished, event => this._page.emit(Events.Page.RequestFinished, event)); } - async initialize(session: TargetSession) { + setSession(session: TargetSession) { helper.removeEventListeners(this._sessionListeners); this.disconnectFromTarget(); this._session = session; this.rawKeyboard.setSession(session); this.rawMouse.setSession(session); - this.screenshotterDelegate.initialize(session); + this.screenshotterDelegate.setSession(session); this._addSessionListeners(); - this.emit(FrameManagerEvents.TargetSwappedOnNavigation); - const [,{frameTree}] = await Promise.all([ + this._networkManager.setSession(session); + this._isolatedWorlds = new Set(); + } + + // This method is called for provisional targets as well. The session passed as the parameter + // may be different from the current session and may be destroyed without becoming current. + async _initializeSession(session: TargetSession) { + const promises : Promise[] = [ // Page agent must be enabled before Runtime. - this._session.send('Page.enable'), - this._session.send('Page.getResourceTree'), - ]); - this._handleFrameTree(frameTree); - await Promise.all([ - this._session.send('Runtime.enable').then(() => this._ensureIsolatedWorld(UTILITY_WORLD_NAME)), - this._session.send('Console.enable'), - this._session.send('Dialog.enable'), - this._session.send('Page.setInterceptFileChooserDialog', { enabled: true }), - this._networkManager.initialize(session), - ]); + session.send('Page.enable'), + session.send('Page.getResourceTree').then(({frameTree}) => this._handleFrameTree(frameTree)), + // Resource tree should be received before first execution context. + session.send('Runtime.enable').then(() => this._ensureIsolatedWorld(UTILITY_WORLD_NAME)), + session.send('Console.enable'), + session.send('Page.setInterceptFileChooserDialog', { enabled: true }), + this._networkManager.initializeSession(session), + ]; + if (!session.isProvisional()) { + // FIXME: move dialog agent to web process. + // Dialog agent resides in the UI process and should not be re-enabled on navigation. + promises.push(session.send('Dialog.enable')); + } if (this._page._state.userAgent !== null) - await this._session.send('Page.overrideUserAgent', { value: this._page._state.userAgent }); + promises.push(session.send('Page.overrideUserAgent', { value: this._page._state.userAgent })); if (this._page._state.mediaType !== null) - await this._session.send('Page.setEmulatedMedia', { media: this._page._state.mediaType || '' }); + promises.push(session.send('Page.setEmulatedMedia', { media: this._page._state.mediaType || '' })); if (this._page._state.javascriptEnabled !== null) - await this._session.send('Emulation.setJavaScriptEnabled', { enabled: this._page._state.javascriptEnabled }); + promises.push(session.send('Emulation.setJavaScriptEnabled', { enabled: this._page._state.javascriptEnabled })); + await Promise.all(promises); } didClose() { @@ -511,24 +519,24 @@ export class FrameManager extends EventEmitter implements frames.FrameDelegate, * @internal */ class NextNavigationWatchdog { - _frameManager: FrameManager; - _frame: frames.Frame; - _newDocumentNavigationPromise: Promise; - _newDocumentNavigationCallback: (value?: unknown) => void; - _sameDocumentNavigationPromise: Promise; - _sameDocumentNavigationCallback: (value?: unknown) => void; - private _lifecyclePromise: Promise; + private readonly _frameManager: FrameManager; + private readonly _frame: frames.Frame; + private readonly _newDocumentNavigationPromise: Promise; + private _newDocumentNavigationCallback: (value?: unknown) => void; + private readonly _sameDocumentNavigationPromise: Promise; + private _sameDocumentNavigationCallback: (value?: unknown) => void; + private readonly _lifecyclePromise: Promise; private _lifecycleCallback: () => void; - private _terminationPromise: Promise; - private _terminationCallback: (err: Error | null) => void; - _navigationRequest: any; - _eventListeners: RegisteredListener[]; - _timeoutPromise: Promise; - _timeoutId: NodeJS.Timer; - _hasSameDocumentNavigation = false; - _expectedLifecycle: frames.LifecycleEvent[]; - _initialLoaderId: string; - _disconnectedListener: RegisteredListener; + private readonly _frameDetachPromise: Promise; + private _frameDetachCallback: (err: Error | null) => void; + private readonly _initialSession: TargetSession; + private _navigationRequest?: network.Request = null; + private readonly _eventListeners: RegisteredListener[]; + private readonly _timeoutPromise: Promise; + private readonly _timeoutId: NodeJS.Timer; + private _hasSameDocumentNavigation = false; + private readonly _expectedLifecycle: frames.LifecycleEvent[]; + private readonly _initialLoaderId: string; constructor(frameManager: FrameManager, frame: frames.Frame, waitUntil: frames.LifecycleEvent | frames.LifecycleEvent[], timeout) { if (Array.isArray(waitUntil)) @@ -539,6 +547,7 @@ class NextNavigationWatchdog { this._frameManager = frameManager; this._frame = frame; this._initialLoaderId = frameManager._frameData(frame).loaderId; + this._initialSession = frameManager._session; this._newDocumentNavigationPromise = new Promise(fulfill => { this._newDocumentNavigationCallback = fulfill; }); @@ -548,23 +557,19 @@ class NextNavigationWatchdog { this._lifecyclePromise = new Promise(fulfill => { this._lifecycleCallback = fulfill; }); - /** @type {?Request} */ - this._navigationRequest = null; this._eventListeners = [ helper.addEventListener(frameManager, FrameManagerEvents.LifecycleEvent, frame => this._onLifecycleEvent(frame)), helper.addEventListener(frameManager, FrameManagerEvents.FrameNavigated, frame => this._onLifecycleEvent(frame)), helper.addEventListener(frameManager, FrameManagerEvents.FrameNavigatedWithinDocument, frame => this._onSameDocumentNavigation(frame)), - helper.addEventListener(frameManager, FrameManagerEvents.TargetSwappedOnNavigation, event => this._onTargetReconnected()), helper.addEventListener(frameManager, FrameManagerEvents.FrameDetached, frame => this._onFrameDetached(frame)), helper.addEventListener(frameManager.networkManager(), NetworkManagerEvents.Request, this._onRequest.bind(this)), ]; - this._registerDisconnectedListener(); const timeoutError = new TimeoutError('Navigation Timeout Exceeded: ' + timeout + 'ms'); let timeoutCallback; this._timeoutPromise = new Promise(resolve => timeoutCallback = resolve.bind(null, timeoutError)); this._timeoutId = timeout ? setTimeout(timeoutCallback, timeout) : null; - this._terminationPromise = new Promise(fulfill => { - this._terminationCallback = fulfill; + this._frameDetachPromise = new Promise(fulfill => { + this._frameDetachCallback = fulfill; }); } @@ -581,37 +586,11 @@ class NextNavigationWatchdog { } timeoutOrTerminationPromise(): Promise { - return Promise.race([this._timeoutPromise, this._terminationPromise]); - } - - _registerDisconnectedListener() { - if (this._disconnectedListener) - helper.removeEventListeners([this._disconnectedListener]); - const session = this._frameManager._session; - this._disconnectedListener = helper.addEventListener(this._frameManager._session, TargetSessionEvents.Disconnected, () => { - // Session may change on swap out, check that it's current. - if (session === this._frameManager._session) - this._terminationCallback(new Error('Navigation failed because browser has disconnected!')); - }); - } - - async _onTargetReconnected() { - this._registerDisconnectedListener(); - // In case web process change we migh have missed load event. Check current ready - // state to mitigate that. - try { - const context = await this._frame.executionContext(); - const readyState = await context.evaluate(() => document.readyState); - switch (readyState) { - case 'loading': - case 'interactive': - case 'complete': - this._newDocumentNavigationCallback(); - break; - } - } catch (e) { - debugError('_onTargetReconnected ' + e); - } + return Promise.race([ + this._timeoutPromise, + this._frameDetachPromise, + this._frameManager._page._disconnectedPromise + ]); } _onLifecycleEvent(frame: frames.Frame) { @@ -648,13 +627,14 @@ class NextNavigationWatchdog { this._lifecycleCallback(); if (this._hasSameDocumentNavigation) this._sameDocumentNavigationCallback(); - if (this._frameManager._frameData(this._frame).loaderId !== this._initialLoaderId) + if (this._frameManager._frameData(this._frame).loaderId !== this._initialLoaderId || + this._initialSession !== this._frameManager._session) this._newDocumentNavigationCallback(); } _onFrameDetached(frame: frames.Frame) { if (this._frame === frame) { - this._terminationCallback.call(null, new Error('Navigating frame was detached')); + this._frameDetachCallback.call(null, new Error('Navigating frame was detached')); return; } this._checkLifecycle(); diff --git a/src/webkit/NetworkManager.ts b/src/webkit/NetworkManager.ts index 240b356a37..2cebc327b6 100644 --- a/src/webkit/NetworkManager.ts +++ b/src/webkit/NetworkManager.ts @@ -44,7 +44,7 @@ export class NetworkManager extends EventEmitter { this._frameManager = frameManager; } - async initialize(session: TargetSession) { + setSession(session: TargetSession) { helper.removeEventListeners(this._sessionListeners); this._session = session; this._sessionListeners = [ @@ -53,8 +53,13 @@ export class NetworkManager extends EventEmitter { helper.addEventListener(this._session, 'Network.loadingFinished', this._onLoadingFinished.bind(this)), helper.addEventListener(this._session, 'Network.loadingFailed', this._onLoadingFailed.bind(this)), ]; - await this._session.send('Network.enable'); - await this._session.send('Network.setExtraHTTPHeaders', { headers: this._extraHTTPHeaders }); + } + + async initializeSession(session: TargetSession) { + await Promise.all([ + session.send('Network.enable'), + session.send('Network.setExtraHTTPHeaders', { headers: this._extraHTTPHeaders }), + ]); } async setExtraHTTPHeaders(extraHTTPHeaders: { [s: string]: string; }) { diff --git a/src/webkit/Screenshotter.ts b/src/webkit/Screenshotter.ts index 45ead0e207..238c3f4c51 100644 --- a/src/webkit/Screenshotter.ts +++ b/src/webkit/Screenshotter.ts @@ -11,7 +11,7 @@ import { TargetSession } from './Connection'; export class WKScreenshotDelegate implements ScreenshotterDelegate { private _session: TargetSession; - initialize(session: TargetSession) { + setSession(session: TargetSession) { this._session = session; } diff --git a/src/webkit/Target.ts b/src/webkit/Target.ts index d52b315c60..b097b507ae 100644 --- a/src/webkit/Target.ts +++ b/src/webkit/Target.ts @@ -50,7 +50,18 @@ export class Target { this._page._didClose(); } - async _swappedIn(oldTarget: Target) { + async _initializeSession(session: TargetSession) { + if (!this._page) + return; + await (this._page._delegate as FrameManager)._initializeSession(session).catch(e => { + // Swallow initialization errors due to newer target swap in, + // since we will reinitialize again. + if (!isSwappedOutError(e)) + throw e; + }); + } + + async _swapWith(oldTarget: Target) { if (!oldTarget._pagePromise) return; this._pagePromise = oldTarget._pagePromise; @@ -59,22 +70,17 @@ export class Target { // old target does not close the page on connection reset. oldTarget._pagePromise = null; oldTarget._page = null; - await this._adoptPage(); + this._adoptPage(); } - private async _adoptPage() { + private _adoptPage() { (this._page as any)[targetSymbol] = this; this._session.once(TargetSessionEvents.Disconnected, () => { // Once swapped out, we reset _page and won't call _didDisconnect for old session. if (this._page) this._page._didDisconnect(); }); - await (this._page._delegate as FrameManager).initialize(this._session).catch(e => { - // Swallow initialization errors due to newer target swap in, - // since we will reinitialize again. - if (!isSwappedOutError(e)) - throw e; - }); + (this._page._delegate as FrameManager).setSession(this._session); } async page(): Promise> { @@ -86,7 +92,8 @@ export class Target { const page = frameManager._page; this._page = page; this._pagePromise = new Promise(async f => { - await this._adoptPage(); + this._adoptPage(); + await this._initializeSession(this._session); if (browser._defaultViewport) await page.setViewport(browser._defaultViewport); f(page);