fix(webkit): improve target swap handling (#175)

- Fix "page closed twice" race.
- Do not fire 'disconnected' on swapped out sessions.
- Use a different error for commands sent to swapped out targets.
  This allows callers to detect this situation and retry/throw/catch.
- Restore more state on swap: extra http headers, user agent, emulated media.
This commit is contained in:
Dmitry Gozman 2019-12-07 16:58:23 -08:00 committed by Pavel Feldman
parent 3fe20ba516
commit 0d0f6b7d03
7 changed files with 125 additions and 118 deletions

View File

@ -184,17 +184,8 @@ export class Browser extends EventEmitter {
async _onProvisionalTargetCommitted({oldTargetId, newTargetId}) { async _onProvisionalTargetCommitted({oldTargetId, newTargetId}) {
const oldTarget = this._targets.get(oldTargetId); const oldTarget = this._targets.get(oldTargetId);
if (!oldTarget._pagePromise)
return;
const page = await oldTarget._pagePromise;
const newTarget = this._targets.get(newTargetId); const newTarget = this._targets.get(newTargetId);
const newSession = this._connection.session(newTargetId); newTarget._swappedIn(oldTarget, this._connection.session(newTargetId));
page._swapSessionOnNavigation(newSession);
newTarget._pagePromise = oldTarget._pagePromise;
newTarget._adoptPage(page);
// Old target should not be accessed by anyone. Reset page promise so that
// old target does not close the page on connection reset.
oldTarget._pagePromise = null;
} }
disconnect() { disconnect() {

View File

@ -104,7 +104,6 @@ export class Connection extends EventEmitter {
} else if (object.method === 'Target.targetDestroyed') { } else if (object.method === 'Target.targetDestroyed') {
const session = this._sessions.get(object.params.targetId); const session = this._sessions.get(object.params.targetId);
if (session) { if (session) {
// FIXME: this is a workaround for cross-origin navigation in WebKit.
session._onClosed(); session._onClosed();
this._sessions.delete(object.params.targetId); this._sessions.delete(object.params.targetId);
} }
@ -121,6 +120,7 @@ export class Connection extends EventEmitter {
const oldSession = this._sessions.get(oldTargetId); const oldSession = this._sessions.get(oldTargetId);
if (!oldSession) if (!oldSession)
throw new Error('Unknown old target: ' + oldTargetId); throw new Error('Unknown old target: ' + oldTargetId);
oldSession._swappedOut = true;
} }
} }
@ -158,6 +158,7 @@ export class TargetSession extends EventEmitter {
private _callbacks = new Map<number, {resolve:(o: any) => void, reject: (e: Error) => void, error: Error, method: string}>(); private _callbacks = new Map<number, {resolve:(o: any) => void, reject: (e: Error) => void, error: Error, method: string}>();
private _targetType: string; private _targetType: string;
private _sessionId: string; private _sessionId: string;
_swappedOut = false;
on: <T extends keyof Protocol.Events | symbol>(event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this; on: <T extends keyof Protocol.Events | symbol>(event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this;
addListener: <T extends keyof Protocol.Events | symbol>(event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this; addListener: <T extends keyof Protocol.Events | symbol>(event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this;
off: <T extends keyof Protocol.Events | symbol>(event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this; off: <T extends keyof Protocol.Events | symbol>(event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this;
@ -194,7 +195,7 @@ export class TargetSession extends EventEmitter {
}).catch(e => { }).catch(e => {
// There is a possible race of the connection closure. We may have received // There is a possible race of the connection closure. We may have received
// targetDestroyed notification before response for the command, in that // targetDestroyed notification before response for the command, in that
// case it's safe to swallow the exception.g // case it's safe to swallow the exception.
const callback = this._callbacks.get(innerId); const callback = this._callbacks.get(innerId);
assert(!callback, 'Callback was not rejected when target was destroyed.'); assert(!callback, 'Callback was not rejected when target was destroyed.');
}); });
@ -218,11 +219,17 @@ export class TargetSession extends EventEmitter {
} }
_onClosed() { _onClosed() {
for (const callback of this._callbacks.values()) for (const callback of this._callbacks.values()) {
callback.reject(rewriteError(callback.error, `Protocol error (${callback.method}): Target closed.`)); // TODO: make some calls like screenshot catch swapped out error and retry.
if (this._swappedOut)
callback.reject(rewriteError(callback.error, `Protocol error (${callback.method}): Target was swapped out.`));
else
callback.reject(rewriteError(callback.error, `Protocol error (${callback.method}): Target closed.`));
}
this._callbacks.clear(); this._callbacks.clear();
this._connection = null; this._connection = null;
this.emit(TargetSessionEvents.Disconnected); if (!this._swappedOut)
this.emit(TargetSessionEvents.Disconnected);
} }
} }
@ -237,3 +244,7 @@ function rewriteError(error: Error, message: string): Error {
error.message = message; error.message = message;
return error; return error;
} }
export function isSwappedOutError(e: Error) {
return e.message.includes('Target was swapped out.');
}

View File

@ -15,7 +15,7 @@
* limitations under the License. * limitations under the License.
*/ */
import { TargetSession } from './Connection'; import { TargetSession, isSwappedOutError } from './Connection';
import { helper } from '../helper'; import { helper } from '../helper';
import { valueFromRemoteObject, releaseObject } from './protocolHelper'; import { valueFromRemoteObject, releaseObject } from './protocolHelper';
import { Protocol } from './protocol'; import { Protocol } from './protocol';
@ -25,7 +25,7 @@ export const EVALUATION_SCRIPT_URL = '__playwright_evaluation_script__';
const SOURCE_URL_REGEX = /^[\040\t]*\/\/[@#] sourceURL=\s*(\S*?)\s*$/m; const SOURCE_URL_REGEX = /^[\040\t]*\/\/[@#] sourceURL=\s*(\S*?)\s*$/m;
export class ExecutionContextDelegate implements js.ExecutionContextDelegate { export class ExecutionContextDelegate implements js.ExecutionContextDelegate {
private _globalObjectId?: string; private _globalObjectId?: Promise<string>;
_session: TargetSession; _session: TargetSession;
_contextId: number; _contextId: number;
private _contextDestroyedCallback: () => void; private _contextDestroyedCallback: () => void;
@ -45,8 +45,6 @@ export class ExecutionContextDelegate implements js.ExecutionContextDelegate {
} }
async evaluate(context: js.ExecutionContext, returnByValue: boolean, pageFunction: Function | string, ...args: any[]): Promise<any> { async evaluate(context: js.ExecutionContext, returnByValue: boolean, pageFunction: Function | string, ...args: any[]): Promise<any> {
const suffix = `//# sourceURL=${EVALUATION_SCRIPT_URL}`;
if (helper.isString(pageFunction)) { if (helper.isString(pageFunction)) {
const contextId = this._contextId; const contextId = this._contextId;
const expression: string = pageFunction as string; const expression: string = pageFunction as string;
@ -58,18 +56,9 @@ export class ExecutionContextDelegate implements js.ExecutionContextDelegate {
emulateUserGesture: true emulateUserGesture: true
}).then(response => { }).then(response => {
if (response.result.type === 'object' && response.result.className === 'Promise') { if (response.result.type === 'object' && response.result.className === 'Promise') {
const contextDiscarded = this._executionContextDestroyedPromise.then(() => ({
wasThrown: true,
result: {
description: 'Protocol error: Execution context was destroyed, most likely because of a navigation.'
} as Protocol.Runtime.RemoteObject
}));
return Promise.race([ return Promise.race([
contextDiscarded, this._executionContextDestroyedPromise.then(() => contextDestroyedResult),
this._session.send('Runtime.awaitPromise', { this._awaitPromise(response.result.objectId),
promiseObjectId: response.result.objectId,
returnByValue: false
})
]); ]);
} }
return response; return response;
@ -78,32 +67,8 @@ export class ExecutionContextDelegate implements js.ExecutionContextDelegate {
throw new Error('Evaluation failed: ' + response.result.description); throw new Error('Evaluation failed: ' + response.result.description);
if (!returnByValue) if (!returnByValue)
return context._createHandle(response.result); return context._createHandle(response.result);
if (response.result.objectId) { if (response.result.objectId)
const serializeFunction = function() { return this._returnObjectByValue(response.result.objectId);
try {
return JSON.stringify(this);
} catch (e) {
if (e instanceof TypeError)
return void 0;
throw e;
}
};
return this._session.send('Runtime.callFunctionOn', {
// Serialize object using standard JSON implementation to correctly pass 'undefined'.
functionDeclaration: serializeFunction + '\n' + suffix + '\n',
objectId: response.result.objectId,
returnByValue
}).then(serializeResponse => {
if (serializeResponse.wasThrown)
throw new Error('Serialization failed: ' + serializeResponse.result.description);
// This is the case of too long property chain, not serializable to json string.
if (serializeResponse.result.type === 'undefined')
return undefined;
if (serializeResponse.result.type !== 'string')
throw new Error('Unexpected result of JSON.stringify: ' + JSON.stringify(serializeResponse, null, 2));
return JSON.parse(serializeResponse.result.value);
});
}
return valueFromRemoteObject(response.result); return valueFromRemoteObject(response.result);
}).catch(rewriteError); }).catch(rewriteError);
} }
@ -164,18 +129,9 @@ export class ExecutionContextDelegate implements js.ExecutionContextDelegate {
} }
return callFunctionOnPromise.then(response => { return callFunctionOnPromise.then(response => {
if (response.result.type === 'object' && response.result.className === 'Promise') { if (response.result.type === 'object' && response.result.className === 'Promise') {
const contextDiscarded = this._executionContextDestroyedPromise.then(() => ({
wasThrown: true,
result: {
description: 'Protocol error: Execution context was destroyed, most likely because of a navigation.'
} as Protocol.Runtime.RemoteObject
}));
return Promise.race([ return Promise.race([
contextDiscarded, this._executionContextDestroyedPromise.then(() => contextDestroyedResult),
this._session.send('Runtime.awaitPromise', { this._awaitPromise(response.result.objectId),
promiseObjectId: response.result.objectId,
returnByValue: false
})
]); ]);
} }
return response; return response;
@ -184,32 +140,8 @@ export class ExecutionContextDelegate implements js.ExecutionContextDelegate {
throw new Error('Evaluation failed: ' + response.result.description); throw new Error('Evaluation failed: ' + response.result.description);
if (!returnByValue) if (!returnByValue)
return context._createHandle(response.result); return context._createHandle(response.result);
if (response.result.objectId) { if (response.result.objectId)
const serializeFunction = function() { return this._returnObjectByValue(response.result.objectId);
try {
return JSON.stringify(this);
} catch (e) {
if (e instanceof TypeError)
return void 0;
throw e;
}
};
return this._session.send('Runtime.callFunctionOn', {
// Serialize object using standard JSON implementation to correctly pass 'undefined'.
functionDeclaration: serializeFunction + '\n' + suffix + '\n',
objectId: response.result.objectId,
returnByValue
}).then(serializeResponse => {
if (serializeResponse.wasThrown)
throw new Error('Serialization failed: ' + serializeResponse.result.description);
// This is the case of too long property chain, not serializable to json string.
if (serializeResponse.result.type === 'undefined')
return undefined;
if (serializeResponse.result.type !== 'string')
throw new Error('Unexpected result of JSON.stringify: ' + JSON.stringify(serializeResponse, null, 2));
return JSON.parse(serializeResponse.result.value);
});
}
return valueFromRemoteObject(response.result); return valueFromRemoteObject(response.result);
}).catch(rewriteError); }).catch(rewriteError);
@ -263,14 +195,64 @@ export class ExecutionContextDelegate implements js.ExecutionContextDelegate {
} }
} }
async _contextGlobalObjectId() { private _contextGlobalObjectId() {
if (!this._globalObjectId) { if (!this._globalObjectId) {
const globalObject = await this._session.send('Runtime.evaluate', { expression: 'this', contextId: this._contextId }); this._globalObjectId = this._session.send('Runtime.evaluate', {
this._globalObjectId = globalObject.result.objectId; expression: 'this',
contextId: this._contextId
}).catch(e => {
if (isSwappedOutError(e))
throw new Error('Execution context was destroyed, most likely because of a navigation.');
throw e;
}).then(response => {
return response.result.objectId;
});
} }
return this._globalObjectId; return this._globalObjectId;
} }
private _awaitPromise(objectId: Protocol.Runtime.RemoteObjectId) {
return this._session.send('Runtime.awaitPromise', {
promiseObjectId: objectId,
returnByValue: false
}).catch(e => {
if (isSwappedOutError(e))
return contextDestroyedResult;
throw e;
});
}
private _returnObjectByValue(objectId: Protocol.Runtime.RemoteObjectId) {
const serializeFunction = function() {
try {
return JSON.stringify(this);
} catch (e) {
if (e instanceof TypeError)
return void 0;
throw e;
}
};
return this._session.send('Runtime.callFunctionOn', {
// Serialize object using standard JSON implementation to correctly pass 'undefined'.
functionDeclaration: serializeFunction + '\n' + suffix + '\n',
objectId: objectId,
returnByValue: true
}).catch(e => {
if (isSwappedOutError(e))
return contextDestroyedResult;
throw e;
}).then(serializeResponse => {
if (serializeResponse.wasThrown)
throw new Error('Serialization failed: ' + serializeResponse.result.description);
// This is the case of too long property chain, not serializable to json string.
if (serializeResponse.result.type === 'undefined')
return undefined;
if (serializeResponse.result.type !== 'string')
throw new Error('Unexpected result of JSON.stringify: ' + JSON.stringify(serializeResponse, null, 2));
return JSON.parse(serializeResponse.result.value);
});
}
async getProperties(handle: js.JSHandle): Promise<Map<string, js.JSHandle>> { async getProperties(handle: js.JSHandle): Promise<Map<string, js.JSHandle>> {
const response = await this._session.send('Runtime.getProperties', { const response = await this._session.send('Runtime.getProperties', {
objectId: toRemoteObject(handle).objectId, objectId: toRemoteObject(handle).objectId,
@ -328,9 +310,16 @@ export class ExecutionContextDelegate implements js.ExecutionContextDelegate {
} }
return { value: arg }; return { value: arg };
} }
} }
const suffix = `//# sourceURL=${EVALUATION_SCRIPT_URL}`;
const contextDestroyedResult = {
wasThrown: true,
result: {
description: 'Protocol error: Execution context was destroyed, most likely because of a navigation.'
} as Protocol.Runtime.RemoteObject
};
function toRemoteObject(handle: js.JSHandle): Protocol.Runtime.RemoteObject { function toRemoteObject(handle: js.JSHandle): Protocol.Runtime.RemoteObject {
return handle._remoteObject as Protocol.Runtime.RemoteObject; return handle._remoteObject as Protocol.Runtime.RemoteObject;
} }

View File

@ -91,7 +91,7 @@ export class FrameManager extends EventEmitter implements frames.FrameDelegate {
]; ];
} }
async _swapSessionOnNavigation(newSession) { _swapSessionOnNavigation(newSession) {
helper.removeEventListeners(this._sessionListeners); helper.removeEventListeners(this._sessionListeners);
this.disconnectFromTarget(); this.disconnectFromTarget();
this._session = newSession; this._session = newSession;

View File

@ -63,6 +63,7 @@ export class NetworkManager extends EventEmitter {
async initialize() { async initialize() {
await this._sesssion.send('Network.enable'); await this._sesssion.send('Network.enable');
await this._sesssion.send('Network.setExtraHTTPHeaders', { headers: this._extraHTTPHeaders });
} }
async setExtraHTTPHeaders(extraHTTPHeaders: { [s: string]: string; }) { async setExtraHTTPHeaders(extraHTTPHeaders: { [s: string]: string; }) {

View File

@ -20,7 +20,7 @@ import * as console from '../console';
import * as dialog from '../dialog'; import * as dialog from '../dialog';
import * as dom from '../dom'; import * as dom from '../dom';
import * as frames from '../frames'; import * as frames from '../frames';
import { assert, debugError, helper, RegisteredListener } from '../helper'; import { assert, helper, RegisteredListener } from '../helper';
import * as input from '../input'; import * as input from '../input';
import { ClickOptions, mediaColorSchemes, mediaTypes, MultiClickOptions } from '../input'; import { ClickOptions, mediaColorSchemes, mediaTypes, MultiClickOptions } from '../input';
import * as js from '../javascript'; import * as js from '../javascript';
@ -49,6 +49,7 @@ export class Page extends EventEmitter {
private _frameManager: FrameManager; private _frameManager: FrameManager;
private _bootstrapScripts: string[] = []; private _bootstrapScripts: string[] = [];
_javascriptEnabled = true; _javascriptEnabled = true;
private _userAgent: string | null = null;
private _viewport: types.Viewport | null = null; private _viewport: types.Viewport | null = null;
_screenshotter: Screenshotter; _screenshotter: Screenshotter;
private _workers = new Map<string, Worker>(); private _workers = new Map<string, Worker>();
@ -57,14 +58,6 @@ export class Page extends EventEmitter {
private _emulatedMediaType: string | undefined; private _emulatedMediaType: string | undefined;
private _fileChooserInterceptors = new Set<(chooser: FileChooser) => void>(); private _fileChooserInterceptors = new Set<(chooser: FileChooser) => void>();
static async create(session: TargetSession, browserContext: BrowserContext, defaultViewport: types.Viewport | null): Promise<Page> {
const page = new Page(session, browserContext);
await page._initialize();
if (defaultViewport)
await page.setViewport(defaultViewport);
return page;
}
constructor(session: TargetSession, browserContext: BrowserContext) { constructor(session: TargetSession, browserContext: BrowserContext) {
super(); super();
this._closedPromise = new Promise(f => this._closedCallback = f); this._closedPromise = new Promise(f => this._closedCallback = f);
@ -97,12 +90,16 @@ export class Page extends EventEmitter {
} }
async _initialize() { async _initialize() {
return Promise.all([ await Promise.all([
this._frameManager.initialize(), this._frameManager.initialize(),
this._session.send('Console.enable'), this._session.send('Console.enable'),
this._session.send('Dialog.enable'), this._session.send('Dialog.enable'),
this._session.send('Page.setInterceptFileChooserDialog', { enabled: true }), this._session.send('Page.setInterceptFileChooserDialog', { enabled: true }),
]); ]);
if (this._userAgent !== null)
await this._session.send('Page.overrideUserAgent', { value: this._userAgent });
if (this._emulatedMediaType !== undefined)
await this._session.send('Page.setEmulatedMedia', { media: this._emulatedMediaType || '' });
} }
_setSession(newSession: TargetSession) { _setSession(newSession: TargetSession) {
@ -130,8 +127,8 @@ export class Page extends EventEmitter {
async _swapSessionOnNavigation(newSession: TargetSession) { async _swapSessionOnNavigation(newSession: TargetSession) {
this._setSession(newSession); this._setSession(newSession);
await this._frameManager._swapSessionOnNavigation(newSession); this._frameManager._swapSessionOnNavigation(newSession);
await this._initialize().catch(e => debugError('failed to enable agents after swap: ' + e)); await this._initialize();
} }
browser(): Browser { browser(): Browser {
@ -230,6 +227,7 @@ export class Page extends EventEmitter {
} }
async setUserAgent(userAgent: string) { async setUserAgent(userAgent: string) {
this._userAgent = userAgent;
await this._session.send('Page.overrideUserAgent', { value: userAgent }); await this._session.send('Page.overrideUserAgent', { value: userAgent });
} }
@ -326,9 +324,8 @@ export class Page extends EventEmitter {
assert(!options.type || mediaTypes.has(options.type), 'Unsupported media type: ' + options.type); assert(!options.type || mediaTypes.has(options.type), 'Unsupported media type: ' + options.type);
assert(!options.colorScheme || mediaColorSchemes.has(options.colorScheme), 'Unsupported color scheme: ' + options.colorScheme); assert(!options.colorScheme || mediaColorSchemes.has(options.colorScheme), 'Unsupported color scheme: ' + options.colorScheme);
assert(!options.colorScheme, 'Media feature emulation is not supported'); assert(!options.colorScheme, 'Media feature emulation is not supported');
const media = typeof options.type === 'undefined' ? this._emulatedMediaType : options.type; this._emulatedMediaType = typeof options.type === 'undefined' ? this._emulatedMediaType : options.type;
await this._session.send('Page.setEmulatedMedia', { media: media || '' }); await this._session.send('Page.setEmulatedMedia', { media: this._emulatedMediaType || '' });
this._emulatedMediaType = options.type;
} }
async setViewport(viewport: types.Viewport) { async setViewport(viewport: types.Viewport) {

View File

@ -19,6 +19,7 @@ import { RegisteredListener } from '../helper';
import { Browser, BrowserContext } from './Browser'; import { Browser, BrowserContext } from './Browser';
import { Page } from './Page'; import { Page } from './Page';
import { Protocol } from './protocol'; import { Protocol } from './protocol';
import { isSwappedOutError, TargetSession } from './Connection';
const targetSymbol = Symbol('target'); const targetSymbol = Symbol('target');
@ -26,7 +27,7 @@ export class Target {
private _browserContext: BrowserContext; private _browserContext: BrowserContext;
_targetId: string; _targetId: string;
private _type: 'page' | 'service-worker' | 'worker'; private _type: 'page' | 'service-worker' | 'worker';
_pagePromise: Promise<Page> | null = null; private _pagePromise: Promise<Page> | null = null;
private _url: string; private _url: string;
_initializedPromise: Promise<boolean>; _initializedPromise: Promise<boolean>;
_initializedCallback: (value?: unknown) => void; _initializedCallback: (value?: unknown) => void;
@ -52,16 +53,28 @@ export class Target {
this._pagePromise.then(page => page._didClose()); this._pagePromise.then(page => page._didClose());
} }
_adoptPage(page: Page) { async _swappedIn(oldTarget: Target, session: TargetSession) {
this._pagePromise = oldTarget._pagePromise;
// Swapped out target should not be accessed by anyone. Reset page promise so that
// old target does not close the page on connection reset.
oldTarget._pagePromise = null;
if (!this._pagePromise)
return;
const page = await this._pagePromise;
(page as any)[targetSymbol] = this; (page as any)[targetSymbol] = this;
page._swapSessionOnNavigation(session).catch(rethrowIfNotSwapped);
} }
async page(): Promise<Page | null> { async page(): Promise<Page | null> {
if (this._type === 'page' && !this._pagePromise) { if (this._type === 'page' && !this._pagePromise) {
const session = this.browser()._connection.session(this._targetId); const session = this.browser()._connection.session(this._targetId);
this._pagePromise = Page.create(session, this._browserContext, this.browser()._defaultViewport).then(page => { this._pagePromise = new Promise(async f => {
this._adoptPage(page); const page = new Page(session, this._browserContext);
return page; await page._initialize().catch(rethrowIfNotSwapped);
if (this.browser()._defaultViewport)
await page.setViewport(this.browser()._defaultViewport);
(page as any)[targetSymbol] = this;
f(page);
}); });
} }
return this._pagePromise; return this._pagePromise;
@ -83,3 +96,8 @@ export class Target {
return this._browserContext; return this._browserContext;
} }
} }
function rethrowIfNotSwapped(e: Error) {
if (!isSwappedOutError(e))
throw e;
}