chore: handle disposal of core/bidi resources (#11730)

This commit is contained in:
jrandolf 2024-01-24 10:10:42 +01:00 committed by GitHub
parent bc7bd01d85
commit 69e44fc808
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 612 additions and 340 deletions

View File

@ -126,11 +126,11 @@ export class BidiBrowser extends Browser {
} }
#initialize() { #initialize() {
this.#browserCore.once('disconnect', () => { this.#browserCore.once('disconnected', () => {
this.emit(BrowserEvent.Disconnected, undefined); this.emit(BrowserEvent.Disconnected, undefined);
}); });
this.#process?.once('close', () => { this.#process?.once('close', async () => {
this.#browserCore.dispose('Browser process closed.', true); this.#browserCore.dispose('Browser process exited.', true);
this.connection.dispose(); this.connection.dispose();
}); });

View File

@ -7,7 +7,8 @@
import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
import {EventEmitter} from '../../common/EventEmitter.js'; import {EventEmitter} from '../../common/EventEmitter.js';
import {throwIfDisposed} from '../../util/decorators.js'; import {inertIfDisposed, throwIfDisposed} from '../../util/decorators.js';
import {DisposableStack, disposeSymbol} from '../../util/disposable.js';
import type {BrowsingContext} from './BrowsingContext.js'; import type {BrowsingContext} from './BrowsingContext.js';
import type {SharedWorkerRealm} from './Realm.js'; import type {SharedWorkerRealm} from './Realm.js';
@ -28,13 +29,13 @@ export type AddPreloadScriptOptions = Omit<
* @internal * @internal
*/ */
export class Browser extends EventEmitter<{ export class Browser extends EventEmitter<{
/** Emitted after the browser closes. */ /** Emitted before the browser closes. */
closed: { closed: {
/** The reason for closing the browser. */ /** The reason for closing the browser. */
reason: string; reason: string;
}; };
/** Emitted after the browser disconnects. */ /** Emitted after the browser disconnects. */
disconnect: { disconnected: {
/** The reason for disconnecting the browser. */ /** The reason for disconnecting the browser. */
reason: string; reason: string;
}; };
@ -51,14 +52,15 @@ export class Browser extends EventEmitter<{
} }
// keep-sorted start // keep-sorted start
#closed = false;
#reason: string | undefined; #reason: string | undefined;
readonly #disposables = new DisposableStack();
readonly #userContexts = new Map(); readonly #userContexts = new Map();
readonly session: Session; readonly session: Session;
// keep-sorted end // keep-sorted end
private constructor(session: Session) { private constructor(session: Session) {
super(); super();
// keep-sorted start // keep-sorted start
this.session = session; this.session = session;
// keep-sorted end // keep-sorted end
@ -67,49 +69,44 @@ export class Browser extends EventEmitter<{
} }
async #initialize() { async #initialize() {
// /////////////////////// const sessionEmitter = this.#disposables.use(
// Session listeners // new EventEmitter(this.session)
// /////////////////////// );
const session = this.#session; sessionEmitter.once('ended', ({reason}) => {
session.on('script.realmCreated', info => { this.dispose(reason);
});
sessionEmitter.on('script.realmCreated', info => {
if (info.type === 'shared-worker') { if (info.type === 'shared-worker') {
// TODO: Create a SharedWorkerRealm. // TODO: Create a SharedWorkerRealm.
} }
}); });
// /////////////////// await this.#syncBrowsingContexts();
// Parent listeners // }
// ///////////////////
this.session.once('ended', ({reason}) => {
this.dispose(reason);
});
// ////////////////////////////// async #syncBrowsingContexts() {
// Asynchronous initialization //
// //////////////////////////////
// In case contexts are created or destroyed during `getTree`, we use this // In case contexts are created or destroyed during `getTree`, we use this
// set to detect them. // set to detect them.
const contextIds = new Set<string>(); const contextIds = new Set<string>();
const created = (info: {context: string}) => { let contexts: Bidi.BrowsingContext.Info[];
contextIds.add(info.context);
};
const destroyed = (info: {context: string}) => {
contextIds.delete(info.context);
};
session.on('browsingContext.contextCreated', created);
session.on('browsingContext.contextDestroyed', destroyed);
const { {
result: {contexts}, using sessionEmitter = new EventEmitter(this.session);
} = await session.send('browsingContext.getTree', {}); sessionEmitter.on('browsingContext.contextCreated', info => {
contextIds.add(info.context);
session.off('browsingContext.contextDestroyed', destroyed); });
session.off('browsingContext.contextCreated', created); sessionEmitter.on('browsingContext.contextDestroyed', info => {
contextIds.delete(info.context);
});
const {result} = await this.session.send('browsingContext.getTree', {});
contexts = result.contexts;
}
// Simulating events so contexts are created naturally. // Simulating events so contexts are created naturally.
for (const info of contexts) { for (const info of contexts) {
if (contextIds.has(info.context)) { if (contextIds.has(info.context)) {
session.emit('browsingContext.contextCreated', info); this.session.emit('browsingContext.contextCreated', info);
} }
if (info.children) { if (info.children) {
contexts.push(...info.children); contexts.push(...info.children);
@ -117,48 +114,45 @@ export class Browser extends EventEmitter<{
} }
} }
get #session() { // keep-sorted start block=yes
return this.session; get closed(): boolean {
return this.#closed;
} }
get disposed(): boolean {
return this.#reason !== undefined;
}
get defaultUserContext(): UserContext { get defaultUserContext(): UserContext {
// SAFETY: A UserContext is always created for the default context. // SAFETY: A UserContext is always created for the default context.
return this.#userContexts.get('')!; return this.#userContexts.get('')!;
} }
get disconnected(): boolean {
return this.#reason !== undefined;
}
get disposed(): boolean {
return this.disconnected;
}
get userContexts(): Iterable<UserContext> { get userContexts(): Iterable<UserContext> {
return this.#userContexts.values(); return this.#userContexts.values();
} }
// keep-sorted end
dispose(reason?: string, close?: boolean): void { @inertIfDisposed
if (this.disposed) { dispose(reason?: string, closed = false): void {
return; this.#closed = closed;
} this.#reason = reason;
this.#reason = reason ?? `Browser was disposed.`; this[disposeSymbol]();
if (close) {
this.emit('closed', {reason: this.#reason});
}
this.emit('disconnect', {reason: this.#reason});
this.removeAllListeners();
} }
@throwIfDisposed((browser: Browser) => { @throwIfDisposed<Browser>(browser => {
// SAFETY: By definition of `disposed`, `#reason` is defined. // SAFETY: By definition of `disposed`, `#reason` is defined.
return browser.#reason!; return browser.#reason!;
}) })
async close(): Promise<void> { async close(): Promise<void> {
try { try {
await this.#session.send('browser.close', {}); await this.session.send('browser.close', {});
} finally { } finally {
this.dispose(`Browser was closed.`, true); this.dispose('Browser already closed.', true);
} }
} }
@throwIfDisposed((browser: Browser) => { @throwIfDisposed<Browser>(browser => {
// SAFETY: By definition of `disposed`, `#reason` is defined. // SAFETY: By definition of `disposed`, `#reason` is defined.
return browser.#reason!; return browser.#reason!;
}) })
@ -168,7 +162,7 @@ export class Browser extends EventEmitter<{
): Promise<string> { ): Promise<string> {
const { const {
result: {script}, result: {script},
} = await this.#session.send('script.addPreloadScript', { } = await this.session.send('script.addPreloadScript', {
functionDeclaration, functionDeclaration,
...options, ...options,
contexts: options.contexts?.map(context => { contexts: options.contexts?.map(context => {
@ -178,13 +172,25 @@ export class Browser extends EventEmitter<{
return script; return script;
} }
@throwIfDisposed((browser: Browser) => { @throwIfDisposed<Browser>(browser => {
// SAFETY: By definition of `disposed`, `#reason` is defined. // SAFETY: By definition of `disposed`, `#reason` is defined.
return browser.#reason!; return browser.#reason!;
}) })
async removePreloadScript(script: string): Promise<void> { async removePreloadScript(script: string): Promise<void> {
await this.#session.send('script.removePreloadScript', { await this.session.send('script.removePreloadScript', {
script, script,
}); });
} }
[disposeSymbol](): void {
this.#reason ??=
'Browser was disconnected, probably because the session ended.';
if (this.closed) {
this.emit('closed', {reason: this.#reason});
}
this.emit('disconnected', {reason: this.#reason});
this.#disposables.dispose();
super[disposeSymbol]();
}
} }

View File

@ -7,7 +7,8 @@
import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
import {EventEmitter} from '../../common/EventEmitter.js'; import {EventEmitter} from '../../common/EventEmitter.js';
import {throwIfDisposed} from '../../util/decorators.js'; import {inertIfDisposed, throwIfDisposed} from '../../util/decorators.js';
import {DisposableStack, disposeSymbol} from '../../util/disposable.js';
import type {AddPreloadScriptOptions} from './Browser.js'; import type {AddPreloadScriptOptions} from './Browser.js';
import {Navigation} from './Navigation.js'; import {Navigation} from './Navigation.js';
@ -60,8 +61,11 @@ export type SetViewportOptions = Omit<
* @internal * @internal
*/ */
export class BrowsingContext extends EventEmitter<{ export class BrowsingContext extends EventEmitter<{
/** Emitted when this context is destroyed. */ /** Emitted when this context is closed. */
destroyed: void; closed: {
/** The reason the browsing context was closed */
reason: string;
};
/** Emitted when a child browsing context is created. */ /** Emitted when a child browsing context is created. */
browsingcontext: { browsingcontext: {
/** The newly created child browsing context. */ /** The newly created child browsing context. */
@ -105,15 +109,16 @@ export class BrowsingContext extends EventEmitter<{
// keep-sorted start // keep-sorted start
#navigation: Navigation | undefined; #navigation: Navigation | undefined;
#reason?: string;
#url: string; #url: string;
readonly #children = new Map<string, BrowsingContext>(); readonly #children = new Map<string, BrowsingContext>();
readonly #disposables = new DisposableStack();
readonly #realms = new Map<string, WindowRealm>(); readonly #realms = new Map<string, WindowRealm>();
readonly #requests = new Map<string, Request>(); readonly #requests = new Map<string, Request>();
readonly defaultRealm: WindowRealm; readonly defaultRealm: WindowRealm;
readonly id: string; readonly id: string;
readonly parent: BrowsingContext | undefined; readonly parent: BrowsingContext | undefined;
readonly userContext: UserContext; readonly userContext: UserContext;
disposed = false;
// keep-sorted end // keep-sorted end
private constructor( private constructor(
@ -123,7 +128,6 @@ export class BrowsingContext extends EventEmitter<{
url: string url: string
) { ) {
super(); super();
// keep-sorted start // keep-sorted start
this.#url = url; this.#url = url;
this.id = id; this.id = id;
@ -135,11 +139,17 @@ export class BrowsingContext extends EventEmitter<{
} }
#initialize() { #initialize() {
// /////////////////////// const userContextEmitter = this.#disposables.use(
// Session listeners // new EventEmitter(this.userContext)
// /////////////////////// );
const session = this.#session; userContextEmitter.once('closed', ({reason}) => {
session.on('browsingContext.contextCreated', info => { this.dispose(`Browsing context already closed: ${reason}`);
});
const sessionEmitter = this.#disposables.use(
new EventEmitter(this.#session)
);
sessionEmitter.on('browsingContext.contextCreated', info => {
if (info.parent !== this.id) { if (info.parent !== this.id) {
return; return;
} }
@ -150,24 +160,27 @@ export class BrowsingContext extends EventEmitter<{
info.context, info.context,
info.url info.url
); );
browsingContext.on('destroyed', () => { this.#children.set(info.context, browsingContext);
const browsingContextEmitter = this.#disposables.use(
new EventEmitter(browsingContext)
);
browsingContextEmitter.once('closed', () => {
browsingContextEmitter.removeAllListeners();
this.#children.delete(browsingContext.id); this.#children.delete(browsingContext.id);
}); });
this.#children.set(info.context, browsingContext);
this.emit('browsingcontext', {browsingContext}); this.emit('browsingcontext', {browsingContext});
}); });
session.on('browsingContext.contextDestroyed', info => { sessionEmitter.on('browsingContext.contextDestroyed', info => {
if (info.context !== this.id) { if (info.context !== this.id) {
return; return;
} }
this.disposed = true; this.dispose('Browsing context already closed.');
this.emit('destroyed', undefined);
this.removeAllListeners();
}); });
session.on('browsingContext.domContentLoaded', info => { sessionEmitter.on('browsingContext.domContentLoaded', info => {
if (info.context !== this.id) { if (info.context !== this.id) {
return; return;
} }
@ -175,7 +188,7 @@ export class BrowsingContext extends EventEmitter<{
this.emit('DOMContentLoaded', undefined); this.emit('DOMContentLoaded', undefined);
}); });
session.on('browsingContext.load', info => { sessionEmitter.on('browsingContext.load', info => {
if (info.context !== this.id) { if (info.context !== this.id) {
return; return;
} }
@ -183,22 +196,31 @@ export class BrowsingContext extends EventEmitter<{
this.emit('load', undefined); this.emit('load', undefined);
}); });
session.on('browsingContext.navigationStarted', info => { sessionEmitter.on('browsingContext.navigationStarted', info => {
if (info.context !== this.id) { if (info.context !== this.id) {
return; return;
} }
this.#url = info.url;
this.#requests.clear(); this.#requests.clear();
// Note the navigation ID is null for this event. // Note the navigation ID is null for this event.
this.#navigation = Navigation.from(this, info.url); this.#navigation = Navigation.from(this);
this.#navigation.on('fragment', ({url}) => {
this.#url = url; const navigationEmitter = this.#disposables.use(
}); new EventEmitter(this.#navigation)
);
for (const eventName of ['fragment', 'failed', 'aborted'] as const) {
navigationEmitter.once(eventName, ({url}) => {
navigationEmitter[disposeSymbol]();
this.#url = url;
});
}
this.emit('navigation', {navigation: this.#navigation}); this.emit('navigation', {navigation: this.#navigation});
}); });
session.on('network.beforeRequestSent', event => { sessionEmitter.on('network.beforeRequestSent', event => {
if (event.context !== this.id) { if (event.context !== this.id) {
return; return;
} }
@ -206,12 +228,12 @@ export class BrowsingContext extends EventEmitter<{
return; return;
} }
const request = new Request(this, event); const request = Request.from(this, event);
this.#requests.set(request.id, request); this.#requests.set(request.id, request);
this.emit('request', {request}); this.emit('request', {request});
}); });
session.on('log.entryAdded', entry => { sessionEmitter.on('log.entryAdded', entry => {
if (entry.source.context !== this.id) { if (entry.source.context !== this.id) {
return; return;
} }
@ -219,7 +241,7 @@ export class BrowsingContext extends EventEmitter<{
this.emit('log', {entry}); this.emit('log', {entry});
}); });
session.on('browsingContext.userPromptOpened', info => { sessionEmitter.on('browsingContext.userPromptOpened', info => {
if (info.context !== this.id) { if (info.context !== this.id) {
return; return;
} }
@ -236,6 +258,12 @@ export class BrowsingContext extends EventEmitter<{
get children(): Iterable<BrowsingContext> { get children(): Iterable<BrowsingContext> {
return this.#children.values(); return this.#children.values();
} }
get closed(): boolean {
return this.#reason !== undefined;
}
get disposed(): boolean {
return this.closed;
}
get realms(): Iterable<WindowRealm> { get realms(): Iterable<WindowRealm> {
return this.#realms.values(); return this.#realms.values();
} }
@ -251,14 +279,26 @@ export class BrowsingContext extends EventEmitter<{
} }
// keep-sorted end // keep-sorted end
@throwIfDisposed() @inertIfDisposed
private dispose(reason?: string): void {
this.#reason = reason;
this[disposeSymbol]();
}
@throwIfDisposed<BrowsingContext>(context => {
// SAFETY: Disposal implies this exists.
return context.#reason!;
})
async activate(): Promise<void> { async activate(): Promise<void> {
await this.#session.send('browsingContext.activate', { await this.#session.send('browsingContext.activate', {
context: this.id, context: this.id,
}); });
} }
@throwIfDisposed() @throwIfDisposed<BrowsingContext>(context => {
// SAFETY: Disposal implies this exists.
return context.#reason!;
})
async captureScreenshot( async captureScreenshot(
options: CaptureScreenshotOptions = {} options: CaptureScreenshotOptions = {}
): Promise<string> { ): Promise<string> {
@ -271,7 +311,10 @@ export class BrowsingContext extends EventEmitter<{
return data; return data;
} }
@throwIfDisposed() @throwIfDisposed<BrowsingContext>(context => {
// SAFETY: Disposal implies this exists.
return context.#reason!;
})
async close(promptUnload?: boolean): Promise<void> { async close(promptUnload?: boolean): Promise<void> {
await Promise.all( await Promise.all(
[...this.#children.values()].map(async child => { [...this.#children.values()].map(async child => {
@ -284,7 +327,10 @@ export class BrowsingContext extends EventEmitter<{
}); });
} }
@throwIfDisposed() @throwIfDisposed<BrowsingContext>(context => {
// SAFETY: Disposal implies this exists.
return context.#reason!;
})
async traverseHistory(delta: number): Promise<void> { async traverseHistory(delta: number): Promise<void> {
await this.#session.send('browsingContext.traverseHistory', { await this.#session.send('browsingContext.traverseHistory', {
context: this.id, context: this.id,
@ -292,7 +338,10 @@ export class BrowsingContext extends EventEmitter<{
}); });
} }
@throwIfDisposed() @throwIfDisposed<BrowsingContext>(context => {
// SAFETY: Disposal implies this exists.
return context.#reason!;
})
async navigate( async navigate(
url: string, url: string,
wait?: Bidi.BrowsingContext.ReadinessState wait?: Bidi.BrowsingContext.ReadinessState
@ -309,7 +358,10 @@ export class BrowsingContext extends EventEmitter<{
}); });
} }
@throwIfDisposed() @throwIfDisposed<BrowsingContext>(context => {
// SAFETY: Disposal implies this exists.
return context.#reason!;
})
async reload(options: ReloadOptions = {}): Promise<Navigation> { async reload(options: ReloadOptions = {}): Promise<Navigation> {
await this.#session.send('browsingContext.reload', { await this.#session.send('browsingContext.reload', {
context: this.id, context: this.id,
@ -322,7 +374,10 @@ export class BrowsingContext extends EventEmitter<{
}); });
} }
@throwIfDisposed() @throwIfDisposed<BrowsingContext>(context => {
// SAFETY: Disposal implies this exists.
return context.#reason!;
})
async print(options: PrintOptions = {}): Promise<string> { async print(options: PrintOptions = {}): Promise<string> {
const { const {
result: {data}, result: {data},
@ -333,7 +388,10 @@ export class BrowsingContext extends EventEmitter<{
return data; return data;
} }
@throwIfDisposed() @throwIfDisposed<BrowsingContext>(context => {
// SAFETY: Disposal implies this exists.
return context.#reason!;
})
async handleUserPrompt(options: HandleUserPromptOptions = {}): Promise<void> { async handleUserPrompt(options: HandleUserPromptOptions = {}): Promise<void> {
await this.#session.send('browsingContext.handleUserPrompt', { await this.#session.send('browsingContext.handleUserPrompt', {
context: this.id, context: this.id,
@ -341,7 +399,10 @@ export class BrowsingContext extends EventEmitter<{
}); });
} }
@throwIfDisposed() @throwIfDisposed<BrowsingContext>(context => {
// SAFETY: Disposal implies this exists.
return context.#reason!;
})
async setViewport(options: SetViewportOptions = {}): Promise<void> { async setViewport(options: SetViewportOptions = {}): Promise<void> {
await this.#session.send('browsingContext.setViewport', { await this.#session.send('browsingContext.setViewport', {
context: this.id, context: this.id,
@ -349,7 +410,10 @@ export class BrowsingContext extends EventEmitter<{
}); });
} }
@throwIfDisposed() @throwIfDisposed<BrowsingContext>(context => {
// SAFETY: Disposal implies this exists.
return context.#reason!;
})
async performActions(actions: Bidi.Input.SourceActions[]): Promise<void> { async performActions(actions: Bidi.Input.SourceActions[]): Promise<void> {
await this.#session.send('input.performActions', { await this.#session.send('input.performActions', {
context: this.id, context: this.id,
@ -357,19 +421,28 @@ export class BrowsingContext extends EventEmitter<{
}); });
} }
@throwIfDisposed() @throwIfDisposed<BrowsingContext>(context => {
// SAFETY: Disposal implies this exists.
return context.#reason!;
})
async releaseActions(): Promise<void> { async releaseActions(): Promise<void> {
await this.#session.send('input.releaseActions', { await this.#session.send('input.releaseActions', {
context: this.id, context: this.id,
}); });
} }
@throwIfDisposed() @throwIfDisposed<BrowsingContext>(context => {
// SAFETY: Disposal implies this exists.
return context.#reason!;
})
createWindowRealm(sandbox: string): WindowRealm { createWindowRealm(sandbox: string): WindowRealm {
return WindowRealm.from(this, sandbox); return WindowRealm.from(this, sandbox);
} }
@throwIfDisposed() @throwIfDisposed<BrowsingContext>(context => {
// SAFETY: Disposal implies this exists.
return context.#reason!;
})
async addPreloadScript( async addPreloadScript(
functionDeclaration: string, functionDeclaration: string,
options: AddPreloadScriptOptions = {} options: AddPreloadScriptOptions = {}
@ -383,8 +456,20 @@ export class BrowsingContext extends EventEmitter<{
); );
} }
@throwIfDisposed() @throwIfDisposed<BrowsingContext>(context => {
// SAFETY: Disposal implies this exists.
return context.#reason!;
})
async removePreloadScript(script: string): Promise<void> { async removePreloadScript(script: string): Promise<void> {
await this.userContext.browser.removePreloadScript(script); await this.userContext.browser.removePreloadScript(script);
} }
[disposeSymbol](): void {
this.#reason ??=
'Browsing context already closed, probably because the user context closed.';
this.emit('closed', {reason: this.#reason});
this.#disposables.dispose();
super[disposeSymbol]();
}
} }

View File

@ -4,10 +4,10 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
import {EventEmitter} from '../../common/EventEmitter.js'; import {EventEmitter} from '../../common/EventEmitter.js';
import {inertIfDisposed} from '../../util/decorators.js';
import {Deferred} from '../../util/Deferred.js'; import {Deferred} from '../../util/Deferred.js';
import {DisposableStack, disposeSymbol} from '../../util/disposable.js';
import type {BrowsingContext} from './BrowsingContext.js'; import type {BrowsingContext} from './BrowsingContext.js';
import type {Request} from './Request.js'; import type {Request} from './Request.js';
@ -24,44 +24,82 @@ export interface NavigationInfo {
* @internal * @internal
*/ */
export class Navigation extends EventEmitter<{ export class Navigation extends EventEmitter<{
/** Emitted when navigation has a request associated with it. */
request: Request;
/** Emitted when fragment navigation occurred. */
fragment: NavigationInfo; fragment: NavigationInfo;
/** Emitted when navigation failed. */
failed: NavigationInfo; failed: NavigationInfo;
/** Emitted when navigation was aborted. */
aborted: NavigationInfo; aborted: NavigationInfo;
}> { }> {
static from(context: BrowsingContext, url: string): Navigation { static from(context: BrowsingContext): Navigation {
const navigation = new Navigation(context, url); const navigation = new Navigation(context);
navigation.#initialize(); navigation.#initialize();
return navigation; return navigation;
} }
// keep-sorted start // keep-sorted start
#context: BrowsingContext;
#id = new Deferred<string>();
#request: Request | undefined; #request: Request | undefined;
#url: string; readonly #browsingContext: BrowsingContext;
readonly #disposables = new DisposableStack();
readonly #id = new Deferred<string>();
// keep-sorted end // keep-sorted end
private constructor(context: BrowsingContext, url: string) { private constructor(context: BrowsingContext) {
super(); super();
// keep-sorted start // keep-sorted start
this.#context = context; this.#browsingContext = context;
this.#url = url;
// keep-sorted end // keep-sorted end
} }
#initialize() { #initialize() {
// /////////////////////// const browsingContextEmitter = this.#disposables.use(
// Session listeners // new EventEmitter(this.#browsingContext)
// /////////////////////// );
const session = this.#session; browsingContextEmitter.once('closed', () => {
for (const [bidiEvent, event] of [ this.emit('failed', {
url: this.#browsingContext.url,
timestamp: new Date(),
});
this.dispose();
});
this.#browsingContext.on('request', ({request}) => {
if (request.navigation === this.#id.value()) {
this.#request = request;
this.emit('request', request);
}
});
const sessionEmitter = this.#disposables.use(
new EventEmitter(this.#session)
);
// To get the navigation ID if any.
for (const eventName of [
'browsingContext.domContentLoaded',
'browsingContext.load',
] as const) {
sessionEmitter.on(eventName, info => {
if (info.context !== this.#browsingContext.id) {
return;
}
if (!info.navigation) {
return;
}
if (!this.#id.resolved()) {
this.#id.resolve(info.navigation);
}
});
}
for (const [eventName, event] of [
['browsingContext.fragmentNavigated', 'fragment'], ['browsingContext.fragmentNavigated', 'fragment'],
['browsingContext.navigationFailed', 'failed'], ['browsingContext.navigationFailed', 'failed'],
['browsingContext.navigationAborted', 'aborted'], ['browsingContext.navigationAborted', 'aborted'],
] as const) { ] as const) {
session.on(bidiEvent, (info: Bidi.BrowsingContext.NavigationInfo) => { sessionEmitter.on(eventName, info => {
if (info.context !== this.#context.id) { if (info.context !== this.#browsingContext.id) {
return; return;
} }
if (!info.navigation) { if (!info.navigation) {
@ -73,33 +111,34 @@ export class Navigation extends EventEmitter<{
if (this.#id.value() !== info.navigation) { if (this.#id.value() !== info.navigation) {
return; return;
} }
this.#url = info.url;
this.emit(event, { this.emit(event, {
url: this.#url, url: info.url,
timestamp: new Date(info.timestamp), timestamp: new Date(info.timestamp),
}); });
this.dispose();
}); });
} }
// ///////////////////
// Parent listeners //
// ///////////////////
this.#context.on('request', ({request}) => {
if (request.navigation === this.#id.value()) {
this.#request = request;
}
});
} }
// keep-sorted start block=yes
get #session() { get #session() {
return this.#context.userContext.browser.session; return this.#browsingContext.userContext.browser.session;
} }
get disposed(): boolean {
get url(): string { return this.#disposables.disposed;
return this.#url;
} }
get request(): Request | undefined {
request(): Request | undefined {
return this.#request; return this.#request;
} }
// keep-sorted end
@inertIfDisposed
private dispose(): void {
this[disposeSymbol]();
}
[disposeSymbol](): void {
this.#disposables.dispose();
super[disposeSymbol]();
}
} }

View File

@ -7,6 +7,8 @@
import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
import {EventEmitter} from '../../common/EventEmitter.js'; import {EventEmitter} from '../../common/EventEmitter.js';
import {inertIfDisposed, throwIfDisposed} from '../../util/decorators.js';
import {DisposableStack, disposeSymbol} from '../../util/disposable.js';
import type {BrowsingContext} from './BrowsingContext.js'; import type {BrowsingContext} from './BrowsingContext.js';
import type {Session} from './Session.js'; import type {Session} from './Session.js';
@ -32,34 +34,57 @@ export type EvaluateOptions = Omit<
*/ */
export abstract class Realm extends EventEmitter<{ export abstract class Realm extends EventEmitter<{
/** Emitted when the realm is destroyed. */ /** Emitted when the realm is destroyed. */
destroyed: void; destroyed: {reason: string};
/** Emitted when a dedicated worker is created in the realm. */ /** Emitted when a dedicated worker is created in the realm. */
worker: DedicatedWorkerRealm; worker: DedicatedWorkerRealm;
/** Emitted when a shared worker is created in the realm. */ /** Emitted when a shared worker is created in the realm. */
sharedworker: SharedWorkerRealm; sharedworker: SharedWorkerRealm;
}> { }> {
// keep-sorted start
#reason?: string;
protected readonly disposables = new DisposableStack();
readonly id: string; readonly id: string;
readonly origin: string; readonly origin: string;
// keep-sorted end
protected constructor(id: string, origin: string) { protected constructor(id: string, origin: string) {
super(); super();
// keep-sorted start
this.id = id; this.id = id;
this.origin = origin; this.origin = origin;
// keep-sorted end
} }
protected initialize(): void { protected initialize(): void {
this.session.on('script.realmDestroyed', info => { const sessionEmitter = this.disposables.use(new EventEmitter(this.session));
if (info.realm === this.id) { sessionEmitter.on('script.realmDestroyed', info => {
this.emit('destroyed', undefined); if (info.realm !== this.id) {
return;
} }
this.dispose('Realm already destroyed.');
}); });
} }
// keep-sorted start block=yes
get disposed(): boolean {
return this.#reason !== undefined;
}
protected abstract get session(): Session; protected abstract get session(): Session;
protected get target(): Bidi.Script.Target { protected get target(): Bidi.Script.Target {
return {realm: this.id}; return {realm: this.id};
} }
// keep-sorted end
@inertIfDisposed
protected dispose(reason?: string): void {
this.#reason = reason;
this[disposeSymbol]();
}
@throwIfDisposed<Realm>(realm => {
// SAFETY: Disposal implies this exists.
return realm.#reason!;
})
async disown(handles: string[]): Promise<void> { async disown(handles: string[]): Promise<void> {
await this.session.send('script.disown', { await this.session.send('script.disown', {
target: this.target, target: this.target,
@ -67,6 +92,10 @@ export abstract class Realm extends EventEmitter<{
}); });
} }
@throwIfDisposed<Realm>(realm => {
// SAFETY: Disposal implies this exists.
return realm.#reason!;
})
async callFunction( async callFunction(
functionDeclaration: string, functionDeclaration: string,
awaitPromise: boolean, awaitPromise: boolean,
@ -81,6 +110,10 @@ export abstract class Realm extends EventEmitter<{
return result; return result;
} }
@throwIfDisposed<Realm>(realm => {
// SAFETY: Disposal implies this exists.
return realm.#reason!;
})
async evaluate( async evaluate(
expression: string, expression: string,
awaitPromise: boolean, awaitPromise: boolean,
@ -94,6 +127,15 @@ export abstract class Realm extends EventEmitter<{
}); });
return result; return result;
} }
[disposeSymbol](): void {
this.#reason ??=
'Realm already destroyed, probably because all associated browsing contexts closed.';
this.emit('destroyed', {reason: this.#reason});
this.disposables.dispose();
super[disposeSymbol]();
}
} }
/** /**
@ -106,8 +148,10 @@ export class WindowRealm extends Realm {
return realm; return realm;
} }
// keep-sorted start
readonly browsingContext: BrowsingContext; readonly browsingContext: BrowsingContext;
readonly sandbox?: string; readonly sandbox?: string;
// keep-sorted end
readonly #workers: { readonly #workers: {
dedicated: Map<string, DedicatedWorkerRealm>; dedicated: Map<string, DedicatedWorkerRealm>;
@ -117,53 +161,59 @@ export class WindowRealm extends Realm {
shared: new Map(), shared: new Map(),
}; };
constructor(context: BrowsingContext, sandbox?: string) { private constructor(context: BrowsingContext, sandbox?: string) {
super('', ''); super('', '');
// keep-sorted start
this.browsingContext = context; this.browsingContext = context;
this.sandbox = sandbox; this.sandbox = sandbox;
// keep-sorted end
} }
override initialize(): void { override initialize(): void {
super.initialize(); super.initialize();
// /////////////////////// const sessionEmitter = this.disposables.use(new EventEmitter(this.session));
// Session listeners // sessionEmitter.on('script.realmCreated', info => {
// /////////////////////// if (info.type !== 'window') {
this.session.on('script.realmCreated', info => {
if (info.type === 'window') {
// SAFETY: This is the only time we allow mutations.
(this as any).id = info.realm;
return; return;
} }
if (info.type === 'dedicated-worker') { (this as any).id = info.realm;
if (!info.owners.includes(this.id)) { (this as any).origin = info.origin;
return; });
} sessionEmitter.on('script.realmCreated', info => {
if (info.type !== 'dedicated-worker') {
const realm = DedicatedWorkerRealm.from(this, info.realm, info.origin); return;
realm.on('destroyed', () => {
this.#workers.dedicated.delete(realm.id);
});
this.#workers.dedicated.set(realm.id, realm);
this.emit('worker', realm);
} }
if (!info.owners.includes(this.id)) {
return;
}
const realm = DedicatedWorkerRealm.from(this, info.realm, info.origin);
this.#workers.dedicated.set(realm.id, realm);
const realmEmitter = this.disposables.use(new EventEmitter(realm));
realmEmitter.once('destroyed', () => {
realmEmitter.removeAllListeners();
this.#workers.dedicated.delete(realm.id);
});
this.emit('worker', realm);
}); });
// ///////////////////
// Parent listeners //
// ///////////////////
this.browsingContext.userContext.browser.on('sharedworker', ({realm}) => { this.browsingContext.userContext.browser.on('sharedworker', ({realm}) => {
if (realm.owners.has(this)) { if (!realm.owners.has(this)) {
realm.on('destroyed', () => { return;
this.#workers.shared.delete(realm.id);
});
this.#workers.shared.set(realm.id, realm);
this.emit('sharedworker', realm);
} }
this.#workers.shared.set(realm.id, realm);
const realmEmitter = this.disposables.use(new EventEmitter(realm));
realmEmitter.once('destroyed', () => {
realmEmitter.removeAllListeners();
this.#workers.shared.delete(realm.id);
});
this.emit('sharedworker', realm);
}); });
} }
@ -198,11 +248,16 @@ export class DedicatedWorkerRealm extends Realm {
return realm; return realm;
} }
readonly owners: Set<DedicatedWorkerOwnerRealm>; // keep-sorted start
readonly #workers = new Map<string, DedicatedWorkerRealm>(); readonly #workers = new Map<string, DedicatedWorkerRealm>();
readonly owners: Set<DedicatedWorkerOwnerRealm>;
// keep-sorted end
constructor(owner: DedicatedWorkerOwnerRealm, id: string, origin: string) { private constructor(
owner: DedicatedWorkerOwnerRealm,
id: string,
origin: string
) {
super(id, origin); super(id, origin);
this.owners = new Set([owner]); this.owners = new Set([owner]);
} }
@ -210,24 +265,24 @@ export class DedicatedWorkerRealm extends Realm {
override initialize(): void { override initialize(): void {
super.initialize(); super.initialize();
// /////////////////////// const sessionEmitter = this.disposables.use(new EventEmitter(this.session));
// Session listeners // sessionEmitter.on('script.realmCreated', info => {
// /////////////////////// if (info.type !== 'dedicated-worker') {
this.session.on('script.realmCreated', info => { return;
if (info.type === 'dedicated-worker') {
if (!info.owners.includes(this.id)) {
return;
}
const realm = DedicatedWorkerRealm.from(this, info.realm, info.origin);
realm.on('destroyed', () => {
this.#workers.delete(realm.id);
});
this.#workers.set(realm.id, realm);
this.emit('worker', realm);
} }
if (!info.owners.includes(this.id)) {
return;
}
const realm = DedicatedWorkerRealm.from(this, info.realm, info.origin);
this.#workers.set(realm.id, realm);
const realmEmitter = this.disposables.use(new EventEmitter(realm));
realmEmitter.once('destroyed', () => {
this.#workers.delete(realm.id);
});
this.emit('worker', realm);
}); });
} }
@ -251,11 +306,12 @@ export class SharedWorkerRealm extends Realm {
return realm; return realm;
} }
readonly owners: Set<WindowRealm>; // keep-sorted start
readonly #workers = new Map<string, DedicatedWorkerRealm>(); readonly #workers = new Map<string, DedicatedWorkerRealm>();
readonly owners: Set<WindowRealm>;
// keep-sorted end
constructor( private constructor(
owners: [WindowRealm, ...WindowRealm[]], owners: [WindowRealm, ...WindowRealm[]],
id: string, id: string,
origin: string origin: string
@ -267,24 +323,24 @@ export class SharedWorkerRealm extends Realm {
override initialize(): void { override initialize(): void {
super.initialize(); super.initialize();
// /////////////////////// const sessionEmitter = this.disposables.use(new EventEmitter(this.session));
// Session listeners // sessionEmitter.on('script.realmCreated', info => {
// /////////////////////// if (info.type !== 'dedicated-worker') {
this.session.on('script.realmCreated', info => { return;
if (info.type === 'dedicated-worker') {
if (!info.owners.includes(this.id)) {
return;
}
const realm = DedicatedWorkerRealm.from(this, info.realm, info.origin);
realm.on('destroyed', () => {
this.#workers.delete(realm.id);
});
this.#workers.set(realm.id, realm);
this.emit('worker', realm);
} }
if (!info.owners.includes(this.id)) {
return;
}
const realm = DedicatedWorkerRealm.from(this, info.realm, info.origin);
this.#workers.set(realm.id, realm);
const realmEmitter = this.disposables.use(new EventEmitter(realm));
realmEmitter.once('destroyed', () => {
this.#workers.delete(realm.id);
});
this.emit('worker', realm);
}); });
} }

View File

@ -7,6 +7,8 @@
import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
import {EventEmitter} from '../../common/EventEmitter.js'; import {EventEmitter} from '../../common/EventEmitter.js';
import {inertIfDisposed} from '../../util/decorators.js';
import {DisposableStack, disposeSymbol} from '../../util/disposable.js';
import type {BrowsingContext} from './BrowsingContext.js'; import type {BrowsingContext} from './BrowsingContext.js';
@ -14,44 +16,68 @@ import type {BrowsingContext} from './BrowsingContext.js';
* @internal * @internal
*/ */
export class Request extends EventEmitter<{ export class Request extends EventEmitter<{
// Emitted whenever a redirect is received. /** Emitted when the request is redirected. */
redirect: Request; redirect: Request;
// Emitted when when the request succeeds. /** Emitted when the request succeeds. */
success: Bidi.Network.ResponseData; success: Bidi.Network.ResponseData;
// Emitted when when the request errors. /** Emitted when the request fails. */
error: string; error: string;
}> { }> {
readonly #context: BrowsingContext; static from(
readonly #event: Bidi.Network.BeforeRequestSentParameters; browsingContext: BrowsingContext,
event: Bidi.Network.BeforeRequestSentParameters
): Request {
const request = new Request(browsingContext, event);
request.#initialize();
return request;
}
#response?: Bidi.Network.ResponseData; // keep-sorted start
#redirect?: Request;
#error?: string; #error?: string;
#redirect?: Request;
#response?: Bidi.Network.ResponseData;
readonly #browsingContext: BrowsingContext;
readonly #disposables = new DisposableStack();
readonly #event: Bidi.Network.BeforeRequestSentParameters;
// keep-sorted end
constructor( private constructor(
context: BrowsingContext, browsingContext: BrowsingContext,
event: Bidi.Network.BeforeRequestSentParameters event: Bidi.Network.BeforeRequestSentParameters
) { ) {
super(); super();
this.#context = context; // keep-sorted start
this.#browsingContext = browsingContext;
this.#event = event; this.#event = event;
// keep-sorted end
}
const session = this.#session; #initialize() {
session.on('network.beforeRequestSent', event => { const browsingContextEmitter = this.#disposables.use(
if (event.context !== this.id) { new EventEmitter(this.#browsingContext)
);
browsingContextEmitter.once('closed', ({reason}) => {
this.#error = reason;
this.emit('error', this.#error);
this.dispose();
});
const sessionEmitter = this.#disposables.use(
new EventEmitter(this.#session)
);
sessionEmitter.on('network.beforeRequestSent', event => {
if (event.context !== this.#browsingContext.id) {
return; return;
} }
if (event.request.request !== this.id) { if (event.request.request !== this.id) {
return; return;
} }
if (this.#redirect) { this.#redirect = Request.from(this.#browsingContext, event);
return;
}
this.#redirect = new Request(this.#context, event);
this.emit('redirect', this.#redirect); this.emit('redirect', this.#redirect);
this.dispose();
}); });
session.on('network.fetchError', event => { sessionEmitter.on('network.fetchError', event => {
if (event.context !== this.#context.id) { if (event.context !== this.#browsingContext.id) {
return; return;
} }
if (event.request.request !== this.id) { if (event.request.request !== this.id) {
@ -59,9 +85,10 @@ export class Request extends EventEmitter<{
} }
this.#error = event.errorText; this.#error = event.errorText;
this.emit('error', this.#error); this.emit('error', this.#error);
this.dispose();
}); });
session.on('network.responseCompleted', event => { sessionEmitter.on('network.responseCompleted', event => {
if (event.context !== this.#context.id) { if (event.context !== this.#browsingContext.id) {
return; return;
} }
if (event.request.request !== this.id) { if (event.request.request !== this.id) {
@ -69,46 +96,53 @@ export class Request extends EventEmitter<{
} }
this.#response = event.response; this.#response = event.response;
this.emit('success', this.#response); this.emit('success', this.#response);
this.dispose();
}); });
} }
// keep-sorted start block=yes
get #session() { get #session() {
return this.#context.userContext.browser.session; return this.#browsingContext.userContext.browser.session;
} }
get disposed(): boolean {
get id(): string { return this.#disposables.disposed;
return this.#event.request.request;
} }
get url(): string {
return this.#event.request.url;
}
get initiator(): Bidi.Network.Initiator {
return this.#event.initiator;
}
get method(): string {
return this.#event.request.method;
}
get headers(): Bidi.Network.Header[] {
return this.#event.request.headers;
}
get navigation(): string | undefined {
return this.#event.navigation ?? undefined;
}
get redirect(): Request | undefined {
return this.redirect;
}
get response(): Bidi.Network.ResponseData | undefined {
return this.#response;
}
get error(): string | undefined { get error(): string | undefined {
return this.#error; return this.#error;
} }
get headers(): Bidi.Network.Header[] {
return this.#event.request.headers;
}
get id(): string {
return this.#event.request.request;
}
get initiator(): Bidi.Network.Initiator {
return this.#event.initiator;
}
get method(): string {
return this.#event.request.method;
}
get navigation(): string | undefined {
return this.#event.navigation ?? undefined;
}
get redirect(): Request | undefined {
return this.redirect;
}
get response(): Bidi.Network.ResponseData | undefined {
return this.#response;
}
get url(): string {
return this.#event.request.url;
}
// keep-sorted end
@inertIfDisposed
private dispose(): void {
this[disposeSymbol]();
}
[disposeSymbol](): void {
this.#disposables.dispose();
super[disposeSymbol]();
}
} }

View File

@ -8,7 +8,8 @@ import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
import {EventEmitter} from '../../common/EventEmitter.js'; import {EventEmitter} from '../../common/EventEmitter.js';
import {debugError} from '../../common/util.js'; import {debugError} from '../../common/util.js';
import {throwIfDisposed} from '../../util/decorators.js'; import {inertIfDisposed, throwIfDisposed} from '../../util/decorators.js';
import {DisposableStack, disposeSymbol} from '../../util/disposable.js';
import {Browser} from './Browser.js'; import {Browser} from './Browser.js';
import type {BidiEvents, Commands, Connection} from './Connection.js'; import type {BidiEvents, Commands, Connection} from './Connection.js';
@ -75,58 +76,53 @@ export class Session
return session; return session;
} }
readonly connection: Connection; // keep-sorted start
#reason: string | undefined;
readonly #disposables = new DisposableStack();
readonly #info: Bidi.Session.NewResult; readonly #info: Bidi.Session.NewResult;
readonly browser!: Browser; readonly browser!: Browser;
readonly connection: Connection;
#reason: string | undefined; // keep-sorted end
private constructor(connection: Connection, info: Bidi.Session.NewResult) { private constructor(connection: Connection, info: Bidi.Session.NewResult) {
super(); super();
this.connection = connection; // keep-sorted start
this.#info = info; this.#info = info;
this.connection = connection;
// keep-sorted end
} }
async #initialize(): Promise<void> { async #initialize(): Promise<void> {
// ///////////////////////
// Connection listeners //
// ///////////////////////
this.connection.pipeTo(this); this.connection.pipeTo(this);
// //////////////////////////////
// Asynchronous initialization //
// //////////////////////////////
// SAFETY: We use `any` to allow assignment of the readonly property. // SAFETY: We use `any` to allow assignment of the readonly property.
(this as any).browser = await Browser.from(this); (this as any).browser = await Browser.from(this);
// ////////////////// const browserEmitter = this.#disposables.use(this.browser);
// Child listeners // browserEmitter.once('closed', ({reason}) => {
// //////////////////
this.browser.once('closed', ({reason}) => {
this.dispose(reason); this.dispose(reason);
}); });
} }
get disposed(): boolean { // keep-sorted start block=yes
return this.#reason !== undefined;
}
get id(): string {
return this.#info.sessionId;
}
get capabilities(): Bidi.Session.NewResult['capabilities'] { get capabilities(): Bidi.Session.NewResult['capabilities'] {
return this.#info.capabilities; return this.#info.capabilities;
} }
get disposed(): boolean {
return this.ended;
}
get ended(): boolean {
return this.#reason !== undefined;
}
get id(): string {
return this.#info.sessionId;
}
// keep-sorted end
dispose(reason?: string): void { @inertIfDisposed
if (this.disposed) { private dispose(reason?: string): void {
return; this.#reason = reason;
} this[disposeSymbol]();
this.#reason = reason ?? 'Session was disposed.';
this.emit('ended', {reason: this.#reason});
this.removeAllListeners();
} }
pipeTo<Events extends BidiEvents>(emitter: EventEmitter<Events>): void { pipeTo<Events extends BidiEvents>(emitter: EventEmitter<Events>): void {
@ -140,7 +136,7 @@ export class Session
* object is used, so we implement this method here, although it's not defined * object is used, so we implement this method here, although it's not defined
* in the spec. * in the spec.
*/ */
@throwIfDisposed((session: Session) => { @throwIfDisposed<Session>(session => {
// SAFETY: By definition of `disposed`, `#reason` is defined. // SAFETY: By definition of `disposed`, `#reason` is defined.
return session.#reason!; return session.#reason!;
}) })
@ -151,7 +147,7 @@ export class Session
return await this.connection.send(method, params); return await this.connection.send(method, params);
} }
@throwIfDisposed((session: Session) => { @throwIfDisposed<Session>(session => {
// SAFETY: By definition of `disposed`, `#reason` is defined. // SAFETY: By definition of `disposed`, `#reason` is defined.
return session.#reason!; return session.#reason!;
}) })
@ -161,7 +157,7 @@ export class Session
}); });
} }
@throwIfDisposed((session: Session) => { @throwIfDisposed<Session>(session => {
// SAFETY: By definition of `disposed`, `#reason` is defined. // SAFETY: By definition of `disposed`, `#reason` is defined.
return session.#reason!; return session.#reason!;
}) })
@ -169,7 +165,16 @@ export class Session
try { try {
await this.send('session.end', {}); await this.send('session.end', {});
} finally { } finally {
this.dispose(`Session (${this.id}) has already ended.`); this.dispose(`Session already ended.`);
} }
} }
[disposeSymbol](): void {
this.#reason ??=
'Session already destroyed, probably because the connection broke.';
this.emit('ended', {reason: this.#reason});
this.#disposables.dispose();
super[disposeSymbol]();
}
} }

View File

@ -8,7 +8,7 @@ import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
import {EventEmitter} from '../../common/EventEmitter.js'; import {EventEmitter} from '../../common/EventEmitter.js';
import {assert} from '../../util/assert.js'; import {assert} from '../../util/assert.js';
import {throwIfDisposed} from '../../util/decorators.js'; import {inertIfDisposed, throwIfDisposed} from '../../util/decorators.js';
import {DisposableStack, disposeSymbol} from '../../util/disposable.js'; import {DisposableStack, disposeSymbol} from '../../util/disposable.js';
import type {Browser} from './Browser.js'; import type {Browser} from './Browser.js';
@ -35,6 +35,13 @@ export class UserContext extends EventEmitter<{
/** The new browsing context. */ /** The new browsing context. */
browsingContext: BrowsingContext; browsingContext: BrowsingContext;
}; };
/**
* Emitted when the user context is closed.
*/
closed: {
/** The reason the user context was closed. */
reason: string;
};
}> { }> {
static create(browser: Browser, id: string): UserContext { static create(browser: Browser, id: string): UserContext {
const context = new UserContext(browser, id); const context = new UserContext(browser, id);
@ -46,16 +53,15 @@ export class UserContext extends EventEmitter<{
#reason?: string; #reason?: string;
// Note these are only top-level contexts. // Note these are only top-level contexts.
readonly #browsingContexts = new Map<string, BrowsingContext>(); readonly #browsingContexts = new Map<string, BrowsingContext>();
readonly #disposables = new DisposableStack();
// @ts-expect-error -- TODO: This will be used once the WebDriver BiDi // @ts-expect-error -- TODO: This will be used once the WebDriver BiDi
// protocol supports it. // protocol supports it.
readonly #id: string; readonly #id: string;
readonly #disposables = new DisposableStack();
readonly browser: Browser; readonly browser: Browser;
// keep-sorted end // keep-sorted end
private constructor(browser: Browser, id: string) { private constructor(browser: Browser, id: string) {
super(); super();
// keep-sorted start // keep-sorted start
this.#id = id; this.#id = id;
this.browser = browser; this.browser = browser;
@ -63,9 +69,13 @@ export class UserContext extends EventEmitter<{
} }
#initialize() { #initialize() {
// //////////////////// const browserEmitter = this.#disposables.use(
// Session listeners // new EventEmitter(this.browser)
// //////////////////// );
browserEmitter.once('closed', ({reason}) => {
this.dispose(`User context already closed: ${reason}`);
});
const sessionEmitter = this.#disposables.use( const sessionEmitter = this.#disposables.use(
new EventEmitter(this.#session) new EventEmitter(this.#session)
); );
@ -85,7 +95,9 @@ export class UserContext extends EventEmitter<{
const browsingContextEmitter = this.#disposables.use( const browsingContextEmitter = this.#disposables.use(
new EventEmitter(browsingContext) new EventEmitter(browsingContext)
); );
browsingContextEmitter.on('destroyed', () => { browsingContextEmitter.on('closed', () => {
browsingContextEmitter.removeAllListeners();
this.#browsingContexts.delete(browsingContext.id); this.#browsingContexts.delete(browsingContext.id);
}); });
@ -100,12 +112,16 @@ export class UserContext extends EventEmitter<{
get browsingContexts(): Iterable<BrowsingContext> { get browsingContexts(): Iterable<BrowsingContext> {
return this.#browsingContexts.values(); return this.#browsingContexts.values();
} }
get closed(): boolean {
return this.#reason !== undefined;
}
get disposed(): boolean { get disposed(): boolean {
return Boolean(this.#reason); return this.closed;
} }
// keep-sorted end // keep-sorted end
dispose(reason?: string): void { @inertIfDisposed
private dispose(reason?: string): void {
this.#reason = reason; this.#reason = reason;
this[disposeSymbol](); this[disposeSymbol]();
} }
@ -136,23 +152,28 @@ export class UserContext extends EventEmitter<{
return browsingContext; return browsingContext;
} }
@throwIfDisposed<UserContext>(context => {
// SAFETY: Disposal implies this exists.
return context.#reason!;
})
async close(): Promise<void> { async close(): Promise<void> {
const promises = []; try {
for (const browsingContext of this.#browsingContexts.values()) { const promises = [];
promises.push(browsingContext.close()); for (const browsingContext of this.#browsingContexts.values()) {
promises.push(browsingContext.close());
}
await Promise.all(promises);
} finally {
this.dispose('User context already closed.');
} }
await Promise.all(promises);
this.dispose('User context was closed.');
} }
[disposeSymbol](): void { [disposeSymbol](): void {
super[disposeSymbol](); this.#reason ??=
'User context already closed, probably because the browser disconnected/closed.';
if (this.#reason === undefined) { this.emit('closed', {reason: this.#reason});
this.#reason =
'User context was destroyed, probably because browser disconnected/closed.';
}
this.#disposables.dispose(); this.#disposables.dispose();
super[disposeSymbol]();
} }
} }

View File

@ -7,7 +7,7 @@
import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js'; import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';
import {EventEmitter} from '../../common/EventEmitter.js'; import {EventEmitter} from '../../common/EventEmitter.js';
import {throwIfDisposed} from '../../util/decorators.js'; import {inertIfDisposed, throwIfDisposed} from '../../util/decorators.js';
import {DisposableStack, disposeSymbol} from '../../util/disposable.js'; import {DisposableStack, disposeSymbol} from '../../util/disposable.js';
import type {BrowsingContext} from './BrowsingContext.js'; import type {BrowsingContext} from './BrowsingContext.js';
@ -32,7 +32,13 @@ export type UserPromptResult = Omit<
* @internal * @internal
*/ */
export class UserPrompt extends EventEmitter<{ export class UserPrompt extends EventEmitter<{
/** Emitted when the user prompt is handled. */
handled: UserPromptResult; handled: UserPromptResult;
/** Emitted when the user prompt is closed. */
closed: {
/** The reason the user prompt was closed. */
reason: string;
};
}> { }> {
static from( static from(
browsingContext: BrowsingContext, browsingContext: BrowsingContext,
@ -56,17 +62,20 @@ export class UserPrompt extends EventEmitter<{
info: Bidi.BrowsingContext.UserPromptOpenedParameters info: Bidi.BrowsingContext.UserPromptOpenedParameters
) { ) {
super(); super();
// keep-sorted start // keep-sorted start
this.info = info;
this.browsingContext = context; this.browsingContext = context;
this.info = info;
// keep-sorted end // keep-sorted end
} }
#initialize() { #initialize() {
// //////////////////// const browserContextEmitter = this.#disposables.use(
// Session listeners // new EventEmitter(this.browsingContext)
// //////////////////// );
browserContextEmitter.once('closed', ({reason}) => {
this.dispose(`User prompt already closed: ${reason}`);
});
const sessionEmitter = this.#disposables.use( const sessionEmitter = this.#disposables.use(
new EventEmitter(this.#session) new EventEmitter(this.#session)
); );
@ -76,7 +85,7 @@ export class UserPrompt extends EventEmitter<{
} }
this.#result = parameters; this.#result = parameters;
this.emit('handled', parameters); this.emit('handled', parameters);
this.dispose('User prompt was handled.'); this.dispose('User prompt already handled.');
}); });
} }
@ -84,20 +93,27 @@ export class UserPrompt extends EventEmitter<{
get #session() { get #session() {
return this.browsingContext.userContext.browser.session; return this.browsingContext.userContext.browser.session;
} }
get closed(): boolean {
return this.#reason !== undefined;
}
get disposed(): boolean { get disposed(): boolean {
return Boolean(this.#reason); return this.closed;
}
get handled(): boolean {
return this.#result !== undefined;
} }
get result(): UserPromptResult | undefined { get result(): UserPromptResult | undefined {
return this.#result; return this.#result;
} }
// keep-sorted end // keep-sorted end
dispose(reason?: string): void { @inertIfDisposed
private dispose(reason?: string): void {
this.#reason = reason; this.#reason = reason;
this[disposeSymbol](); this[disposeSymbol]();
} }
@throwIfDisposed((prompt: UserPrompt) => { @throwIfDisposed<UserPrompt>(prompt => {
// SAFETY: Disposal implies this exists. // SAFETY: Disposal implies this exists.
return prompt.#reason!; return prompt.#reason!;
}) })
@ -111,13 +127,11 @@ export class UserPrompt extends EventEmitter<{
} }
[disposeSymbol](): void { [disposeSymbol](): void {
super[disposeSymbol](); this.#reason ??=
'User prompt already closed, probably because the associated browsing context was destroyed.';
if (this.#reason === undefined) { this.emit('closed', {reason: this.#reason});
this.#reason =
'User prompt was destroyed, probably because the associated browsing context was destroyed.';
}
this.#disposables.dispose(); this.#disposables.dispose();
super[disposeSymbol]();
} }
} }

View File

@ -63,6 +63,18 @@ export function throwIfDisposed<This extends Disposed>(
}; };
} }
export function inertIfDisposed<This extends Disposed>(
target: (this: This, ...args: any[]) => any,
_: unknown
) {
return function (this: This, ...args: any[]): any {
if (this.disposed) {
return;
}
return target.call(this, ...args);
};
}
/** /**
* The decorator only invokes the target if the target has not been invoked with * The decorator only invokes the target if the target has not been invoked with
* the same arguments before. The decorated method throws an error if it's * the same arguments before. The decorated method throws an error if it's