feat(rpc): replace implicit scopes with explicit dispose (#3173)

This adds one more protocol message __dispose__
to dispose a scope and all child objects.

Now, client side does not need a notion of scope anymore -
it just disposes the whole object subtree upon __dispose__.
Server, on the other hand, marks some objects as scopes
and disposes them manually, also asserting that all parents
are proper scopes.
This commit is contained in:
Dmitry Gozman 2020-07-27 10:21:39 -07:00 committed by GitHub
parent 9b502af4e3
commit b2179193c6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 54 additions and 77 deletions

View file

@ -1575,7 +1575,6 @@ export type StreamReadResult = {
export type CDPSessionInitializer = {}; export type CDPSessionInitializer = {};
export interface CDPSessionChannel extends Channel { export interface CDPSessionChannel extends Channel {
on(event: 'event', callback: (params: CDPSessionEventEvent) => void): this; on(event: 'event', callback: (params: CDPSessionEventEvent) => void): this;
on(event: 'disconnected', callback: (params: CDPSessionDisconnectedEvent) => void): this;
send(params: CDPSessionSendParams): Promise<CDPSessionSendResult>; send(params: CDPSessionSendParams): Promise<CDPSessionSendResult>;
detach(params?: CDPSessionDetachParams): Promise<CDPSessionDetachResult>; detach(params?: CDPSessionDetachParams): Promise<CDPSessionDetachResult>;
} }
@ -1583,7 +1582,6 @@ export type CDPSessionEventEvent = {
method: string, method: string,
params?: SerializedValue, params?: SerializedValue,
}; };
export type CDPSessionDisconnectedEvent = {};
export type CDPSessionSendParams = { export type CDPSessionSendParams = {
method: string, method: string,
params?: SerializedValue, params?: SerializedValue,

View file

@ -40,13 +40,12 @@ export class Browser extends ChannelOwner<BrowserChannel, BrowserInitializer> {
} }
constructor(parent: ChannelOwner, type: string, guid: string, initializer: BrowserInitializer) { constructor(parent: ChannelOwner, type: string, guid: string, initializer: BrowserInitializer) {
super(parent, type, guid, initializer, true); super(parent, type, guid, initializer);
this._browserType = parent as BrowserType; this._browserType = parent as BrowserType;
this._channel.on('close', () => { this._channel.on('close', () => {
this._isConnected = false; this._isConnected = false;
this.emit(Events.Browser.Disconnected); this.emit(Events.Browser.Disconnected);
this._isClosedOrClosing = true; this._isClosedOrClosing = true;
this._dispose();
}); });
this._closedPromise = new Promise(f => this.once(Events.Browser.Disconnected, f)); this._closedPromise = new Promise(f => this.once(Events.Browser.Disconnected, f));
} }

View file

@ -48,7 +48,7 @@ export class BrowserContext extends ChannelOwner<BrowserContextChannel, BrowserC
} }
constructor(parent: ChannelOwner, type: string, guid: string, initializer: BrowserContextInitializer, browserName: string) { constructor(parent: ChannelOwner, type: string, guid: string, initializer: BrowserContextInitializer, browserName: string) {
super(parent, type, guid, initializer, true); super(parent, type, guid, initializer);
if (parent instanceof Browser) if (parent instanceof Browser)
this._browser = parent; this._browser = parent;
this._browserName = browserName; this._browserName = browserName;
@ -219,7 +219,6 @@ export class BrowserContext extends ChannelOwner<BrowserContextChannel, BrowserC
if (this._browser) if (this._browser)
this._browser._contexts.delete(this); this._browser._contexts.delete(this);
this.emit(Events.BrowserContext.Close); this.emit(Events.BrowserContext.Close);
this._dispose();
} }
async close(): Promise<void> { async close(): Promise<void> {

View file

@ -25,10 +25,9 @@ export class BrowserServer extends ChannelOwner<BrowserServerChannel, BrowserSer
} }
constructor(parent: ChannelOwner, type: string, guid: string, initializer: BrowserServerInitializer) { constructor(parent: ChannelOwner, type: string, guid: string, initializer: BrowserServerInitializer) {
super(parent, type, guid, initializer, true); super(parent, type, guid, initializer);
this._channel.on('close', ({ exitCode, signal }) => { this._channel.on('close', ({ exitCode, signal }) => {
this.emit(Events.BrowserServer.Close, exitCode === undefined ? null : exitCode, signal === undefined ? null : signal); this.emit(Events.BrowserServer.Close, exitCode === undefined ? null : exitCode, signal === undefined ? null : signal);
this._dispose();
}); });
} }

View file

@ -34,7 +34,7 @@ export class BrowserType extends ChannelOwner<BrowserTypeChannel, BrowserTypeIni
} }
constructor(parent: ChannelOwner, type: string, guid: string, initializer: BrowserTypeInitializer) { constructor(parent: ChannelOwner, type: string, guid: string, initializer: BrowserTypeInitializer) {
super(parent, type, guid, initializer, true); super(parent, type, guid, initializer);
} }
executablePath(): string { executablePath(): string {

View file

@ -31,13 +31,12 @@ export class CDPSession extends ChannelOwner<CDPSessionChannel, CDPSessionInitia
once: <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; once: <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;
constructor(parent: ChannelOwner, type: string, guid: string, initializer: CDPSessionInitializer) { constructor(parent: ChannelOwner, type: string, guid: string, initializer: CDPSessionInitializer) {
super(parent, type, guid, initializer, true); super(parent, type, guid, initializer);
this._channel.on('event', ({ method, params }) => { this._channel.on('event', ({ method, params }) => {
const cdpParams = params ? parseResult(params) : undefined; const cdpParams = params ? parseResult(params) : undefined;
this.emit(method, cdpParams); this.emit(method, cdpParams);
}); });
this._channel.on('disconnected', () => this._dispose());
this.on = super.on; this.on = super.on;
this.addListener = super.addListener; this.addListener = super.addListener;

View file

@ -17,16 +17,12 @@
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import type { Channel } from '../channels'; import type { Channel } from '../channels';
import type { Connection } from './connection'; import type { Connection } from './connection';
import { assert } from '../../helper';
import type { LoggerSink } from '../../loggerSink'; import type { LoggerSink } from '../../loggerSink';
import { DebugLoggerSink } from '../../logger'; import { DebugLoggerSink } from '../../logger';
export abstract class ChannelOwner<T extends Channel = Channel, Initializer = {}> extends EventEmitter { export abstract class ChannelOwner<T extends Channel = Channel, Initializer = {}> extends EventEmitter {
private _connection: Connection; private _connection: Connection;
private _isScope: boolean;
// Parent is always "isScope".
private _parent: ChannelOwner | undefined; private _parent: ChannelOwner | undefined;
// Only "isScope" channel owners have registered objects inside.
private _objects = new Map<string, ChannelOwner>(); private _objects = new Map<string, ChannelOwner>();
readonly _type: string; readonly _type: string;
@ -35,12 +31,11 @@ export abstract class ChannelOwner<T extends Channel = Channel, Initializer = {}
readonly _initializer: Initializer; readonly _initializer: Initializer;
_logger: LoggerSink | undefined; _logger: LoggerSink | undefined;
constructor(parent: ChannelOwner | Connection, type: string, guid: string, initializer: Initializer, isScope?: boolean) { constructor(parent: ChannelOwner | Connection, type: string, guid: string, initializer: Initializer) {
super(); super();
this._connection = parent instanceof ChannelOwner ? parent._connection : parent; this._connection = parent instanceof ChannelOwner ? parent._connection : parent;
this._type = type; this._type = type;
this._guid = guid; this._guid = guid;
this._isScope = !!isScope;
this._parent = parent instanceof ChannelOwner ? parent : undefined; this._parent = parent instanceof ChannelOwner ? parent : undefined;
this._connection._objects.set(guid, this); this._connection._objects.set(guid, this);
@ -74,27 +69,21 @@ export abstract class ChannelOwner<T extends Channel = Channel, Initializer = {}
} }
_dispose() { _dispose() {
assert(this._isScope);
// Clean up from parent and connection. // Clean up from parent and connection.
if (this._parent) if (this._parent)
this._parent._objects.delete(this._guid); this._parent._objects.delete(this._guid);
this._connection._objects.delete(this._guid); this._connection._objects.delete(this._guid);
// Dispose all children. // Dispose all children.
for (const [guid, object] of [...this._objects]) { for (const object of [...this._objects.values()])
if (object._isScope) object._dispose();
object._dispose();
else
this._connection._objects.delete(guid);
}
this._objects.clear(); this._objects.clear();
} }
_debugScopeState(): any { _debugScopeState(): any {
return { return {
_guid: this._guid, _guid: this._guid,
objects: this._isScope ? Array.from(this._objects.values()).map(o => o._debugScopeState()) : undefined, objects: Array.from(this._objects.values()).map(o => o._debugScopeState()),
}; };
} }

View file

@ -44,7 +44,7 @@ import { FirefoxBrowser } from './firefoxBrowser';
class Root extends ChannelOwner<Channel, {}> { class Root extends ChannelOwner<Channel, {}> {
constructor(connection: Connection) { constructor(connection: Connection) {
super(connection, '', '', {}, true); super(connection, '', '', {});
} }
} }
@ -97,6 +97,10 @@ export class Connection {
this._createRemoteObject(guid, params.type, params.guid, params.initializer); this._createRemoteObject(guid, params.type, params.guid, params.initializer);
return; return;
} }
if (method === '__dispose__') {
this._objects.get(guid)!._dispose();
return;
}
const object = this._objects.get(guid)!; const object = this._objects.get(guid)!;
object._channel.emit(method, this._replaceGuidsWithChannels(params)); object._channel.emit(method, this._replaceGuidsWithChannels(params));
} }

View file

@ -33,7 +33,7 @@ export class Electron extends ChannelOwner<ElectronChannel, ElectronInitializer>
} }
constructor(parent: ChannelOwner, type: string, guid: string, initializer: ElectronInitializer) { constructor(parent: ChannelOwner, type: string, guid: string, initializer: ElectronInitializer) {
super(parent, type, guid, initializer, true); super(parent, type, guid, initializer);
} }
async launch(executablePath: string, options: ElectronLaunchOptionsBase & { logger?: LoggerSink } = {}): Promise<ElectronApplication> { async launch(executablePath: string, options: ElectronLaunchOptionsBase & { logger?: LoggerSink } = {}): Promise<ElectronApplication> {
@ -60,7 +60,7 @@ export class ElectronApplication extends ChannelOwner<ElectronApplicationChannel
} }
constructor(parent: ChannelOwner, type: string, guid: string, initializer: ElectronApplicationInitializer) { constructor(parent: ChannelOwner, type: string, guid: string, initializer: ElectronApplicationInitializer) {
super(parent, type, guid, initializer, true); super(parent, type, guid, initializer);
this._channel.on('context', ({ context }) => this._context = BrowserContext.from(context)); this._channel.on('context', ({ context }) => this._context = BrowserContext.from(context));
this._channel.on('window', ({ page, browserWindow }) => { this._channel.on('window', ({ page, browserWindow }) => {
const window = Page.from(page); const window = Page.from(page);
@ -69,10 +69,7 @@ export class ElectronApplication extends ChannelOwner<ElectronApplicationChannel
this.emit(ElectronEvents.ElectronApplication.Window, window); this.emit(ElectronEvents.ElectronApplication.Window, window);
window.once(Events.Page.Close, () => this._windows.delete(window)); window.once(Events.Page.Close, () => this._windows.delete(window));
}); });
this._channel.on('close', () => { this._channel.on('close', () => this.emit(ElectronEvents.ElectronApplication.Close));
this.emit(ElectronEvents.ElectronApplication.Close);
this._dispose();
});
} }
windows(): Page[] { windows(): Page[] {

View file

@ -1852,8 +1852,6 @@ CDPSession:
method: string method: string
params: SerializedValue? params: SerializedValue?
disconnected:
Electron: Electron:

View file

@ -26,10 +26,7 @@ export class CDPSessionDispatcher extends Dispatcher<CRSession, CDPSessionInitia
const params = cdpParams ? serializeResult(cdpParams) : undefined; const params = cdpParams ? serializeResult(cdpParams) : undefined;
this._dispatchEvent('event', { method, params }); this._dispatchEvent('event', { method, params });
}; };
crSession.on(CRSessionEvents.Disconnected, () => { crSession.on(CRSessionEvents.Disconnected, () => this._dispose());
this._dispatchEvent('disconnected');
this._dispose();
});
} }
async send(params: { method: string, params?: SerializedValue }): Promise<{ result: SerializedValue }> { async send(params: { method: string, params?: SerializedValue }): Promise<{ result: SerializedValue }> {

View file

@ -43,6 +43,7 @@ export class Dispatcher<Type, Initializer> extends EventEmitter implements Chann
private _parent: Dispatcher<any, any> | undefined; private _parent: Dispatcher<any, any> | undefined;
// Only "isScope" channel owners have registered dispatchers inside. // Only "isScope" channel owners have registered dispatchers inside.
private _dispatchers = new Map<string, Dispatcher<any, any>>(); private _dispatchers = new Map<string, Dispatcher<any, any>>();
private _disposed = false;
readonly _guid: string; readonly _guid: string;
readonly _type: string; readonly _type: string;
@ -70,7 +71,7 @@ export class Dispatcher<Type, Initializer> extends EventEmitter implements Chann
(object as any)[dispatcherSymbol] = this; (object as any)[dispatcherSymbol] = this;
if (this._parent) if (this._parent)
this._connection.sendMessageToClient(this._parent._guid, '__create__', { type, initializer, guid }); this._connection.sendMessageToClient(this._parent._guid, '__create__', { type, initializer, guid }, !!isScope);
} }
_dispatchEvent(method: string, params: Dispatcher<any, any> | any = {}) { _dispatchEvent(method: string, params: Dispatcher<any, any> | any = {}) {
@ -78,7 +79,7 @@ export class Dispatcher<Type, Initializer> extends EventEmitter implements Chann
} }
_dispose() { _dispose() {
assert(this._isScope); assert(!this._disposed);
// Clean up from parent and connection. // Clean up from parent and connection.
if (this._parent) if (this._parent)
@ -86,19 +87,18 @@ export class Dispatcher<Type, Initializer> extends EventEmitter implements Chann
this._connection._dispatchers.delete(this._guid); this._connection._dispatchers.delete(this._guid);
// Dispose all children. // Dispose all children.
for (const [guid, dispatcher] of [...this._dispatchers]) { for (const dispatcher of [...this._dispatchers.values()])
if (dispatcher._isScope) dispatcher._dispose();
dispatcher._dispose();
else
this._connection._dispatchers.delete(guid);
}
this._dispatchers.clear(); this._dispatchers.clear();
if (this._isScope)
this._connection.sendMessageToClient(this._guid, '__dispose__', {});
} }
_debugScopeState(): any { _debugScopeState(): any {
return { return {
_guid: this._guid, _guid: this._guid,
objects: this._isScope ? Array.from(this._dispatchers.values()).map(o => o._debugScopeState()) : undefined, objects: Array.from(this._dispatchers.values()).map(o => o._debugScopeState()),
}; };
} }
} }
@ -117,8 +117,9 @@ export class DispatcherConnection {
onmessage = (message: object) => {}; onmessage = (message: object) => {};
private _validateParams: (type: string, method: string, params: any) => any; private _validateParams: (type: string, method: string, params: any) => any;
async sendMessageToClient(guid: string, method: string, params: any): Promise<any> { async sendMessageToClient(guid: string, method: string, params: any, disallowDispatchers?: boolean): Promise<any> {
this.onmessage({ guid, method, params: this._replaceDispatchersWithGuids(params) }); const allowDispatchers = !disallowDispatchers;
this.onmessage({ guid, method, params: this._replaceDispatchersWithGuids(params, allowDispatchers) });
} }
constructor() { constructor() {
@ -165,23 +166,26 @@ export class DispatcherConnection {
try { try {
const validated = this._validateParams(dispatcher._type, method, params); const validated = this._validateParams(dispatcher._type, method, params);
const result = await (dispatcher as any)[method](validated); const result = await (dispatcher as any)[method](validated);
this.onmessage({ id, result: this._replaceDispatchersWithGuids(result) }); this.onmessage({ id, result: this._replaceDispatchersWithGuids(result, true) });
} catch (e) { } catch (e) {
this.onmessage({ id, error: serializeError(e) }); this.onmessage({ id, error: serializeError(e) });
} }
} }
private _replaceDispatchersWithGuids(payload: any): any { private _replaceDispatchersWithGuids(payload: any, allowDispatchers: boolean): any {
if (!payload) if (!payload)
return payload; return payload;
if (payload instanceof Dispatcher) if (payload instanceof Dispatcher) {
if (!allowDispatchers)
throw new Error(`Channels are not allowed in the scope's initialzier`);
return { guid: payload._guid }; return { guid: payload._guid };
}
if (Array.isArray(payload)) if (Array.isArray(payload))
return payload.map(p => this._replaceDispatchersWithGuids(p)); return payload.map(p => this._replaceDispatchersWithGuids(p, allowDispatchers));
if (typeof payload === 'object') { if (typeof payload === 'object') {
const result: any = {}; const result: any = {};
for (const key of Object.keys(payload)) for (const key of Object.keys(payload))
result[key] = this._replaceDispatchersWithGuids(payload[key]); result[key] = this._replaceDispatchersWithGuids(payload[key], allowDispatchers);
return result; return result;
} }
return payload; return payload;

View file

@ -31,8 +31,8 @@ describe.skip(!CHANNEL)('Channels', function() {
] }, ] },
{ _guid: 'BrowserType', objects: [] }, { _guid: 'BrowserType', objects: [] },
{ _guid: 'BrowserType', objects: [] }, { _guid: 'BrowserType', objects: [] },
{ _guid: 'Playwright' }, { _guid: 'Playwright', objects: [] },
{ _guid: 'Selectors' }, { _guid: 'Selectors', objects: [] },
{ _guid: 'Electron', objects: [] }, { _guid: 'Electron', objects: [] },
] ]
}; };
@ -49,15 +49,15 @@ describe.skip(!CHANNEL)('Channels', function() {
{ _guid: 'BrowserType', objects: [ { _guid: 'BrowserType', objects: [
{ _guid: 'Browser', objects: [ { _guid: 'Browser', objects: [
{ _guid: 'BrowserContext', objects: [ { _guid: 'BrowserContext', objects: [
{ _guid: 'Frame' }, { _guid: 'Frame', objects: [] },
{ _guid: 'Page' }, { _guid: 'Page', objects: [] },
{ _guid: 'Request' }, { _guid: 'Request', objects: [] },
{ _guid: 'Response' }, { _guid: 'Response', objects: [] },
]}, ]},
] }, ] },
] }, ] },
{ _guid: 'Playwright' }, { _guid: 'Playwright', objects: [] },
{ _guid: 'Selectors' }, { _guid: 'Selectors', objects: [] },
{ _guid: 'Electron', objects: [] }, { _guid: 'Electron', objects: [] },
] ]
}); });
@ -75,8 +75,8 @@ describe.skip(!CHANNEL)('Channels', function() {
] }, ] },
{ _guid: 'BrowserType', objects: [] }, { _guid: 'BrowserType', objects: [] },
{ _guid: 'BrowserType', objects: [] }, { _guid: 'BrowserType', objects: [] },
{ _guid: 'Playwright' }, { _guid: 'Playwright', objects: [] },
{ _guid: 'Selectors' }, { _guid: 'Selectors', objects: [] },
{ _guid: 'Electron', objects: [] }, { _guid: 'Electron', objects: [] },
] ]
}; };
@ -93,8 +93,8 @@ describe.skip(!CHANNEL)('Channels', function() {
] }, ] },
{ _guid: 'BrowserType', objects: [] }, { _guid: 'BrowserType', objects: [] },
{ _guid: 'BrowserType', objects: [] }, { _guid: 'BrowserType', objects: [] },
{ _guid: 'Playwright' }, { _guid: 'Playwright', objects: [] },
{ _guid: 'Selectors' }, { _guid: 'Selectors', objects: [] },
{ _guid: 'Electron', objects: [] }, { _guid: 'Electron', objects: [] },
] ]
}); });
@ -112,8 +112,8 @@ describe.skip(!CHANNEL)('Channels', function() {
] }, ] },
{ _guid: 'BrowserType', objects: [] }, { _guid: 'BrowserType', objects: [] },
{ _guid: 'BrowserType', objects: [] }, { _guid: 'BrowserType', objects: [] },
{ _guid: 'Playwright' }, { _guid: 'Playwright', objects: [] },
{ _guid: 'Selectors' }, { _guid: 'Selectors', objects: [] },
{ _guid: 'Electron', objects: [] }, { _guid: 'Electron', objects: [] },
] ]
}; };
@ -132,8 +132,8 @@ describe.skip(!CHANNEL)('Channels', function() {
] }, ] },
{ _guid: 'BrowserType', objects: [] }, { _guid: 'BrowserType', objects: [] },
{ _guid: 'BrowserType', objects: [] }, { _guid: 'BrowserType', objects: [] },
{ _guid: 'Playwright' }, { _guid: 'Playwright', objects: [] },
{ _guid: 'Selectors' }, { _guid: 'Selectors', objects: [] },
{ _guid: 'Electron', objects: [] }, { _guid: 'Electron', objects: [] },
] ]
}); });
@ -154,12 +154,6 @@ async function expectScopeState(object, golden) {
function compareObjects(a, b) { function compareObjects(a, b) {
if (a._guid !== b._guid) if (a._guid !== b._guid)
return a._guid.localeCompare(b._guid); return a._guid.localeCompare(b._guid);
if (a.objects && !b.objects)
return -1;
if (!a.objects && b.objects)
return 1;
if (!a.objects && !b.objects)
return 0;
return a.objects.length - b.objects.length; return a.objects.length - b.objects.length;
} }