fix(reuse): make sure all dispose and close sequences are executed (#19572)

- When disposing recursively, only the root dispatcher received
`_dispose()` call, while some dispatchers need `_onDispose()` to clean
things up.
- When reusing the context, pages should be notified with `_onClose()`
so that all client-side waiting promises could reject.

Fixes #19216.
This commit is contained in:
Dmitry Gozman 2022-12-19 15:54:53 -08:00 committed by GitHub
parent 600d6bc635
commit 412c11db20
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 61 additions and 18 deletions

View file

@ -65,6 +65,8 @@ export class Browser extends ChannelOwner<channels.BrowserChannel> implements ap
async _newContextForReuse(options: BrowserContextOptions = {}): Promise<BrowserContext> { async _newContextForReuse(options: BrowserContextOptions = {}): Promise<BrowserContext> {
for (const context of this._contexts) { for (const context of this._contexts) {
await this._browserType._onWillCloseContext?.(context); await this._browserType._onWillCloseContext?.(context);
for (const page of context.pages())
page._onClose();
context._onClose(); context._onClose();
} }
this._contexts.clear(); this._contexts.clear();

View file

@ -270,8 +270,7 @@ export class BrowserContextDispatcher extends Dispatcher<BrowserContext, channel
this._subscriptions.delete(params.event); this._subscriptions.delete(params.event);
} }
override _dispose() { override _onDispose() {
super._dispose();
// Avoid protocol calls for the closed context. // Avoid protocol calls for the closed context.
if (!this._context.isClosingOrClosed()) if (!this._context.isClosingOrClosed())
this._context.setRequestInterceptor(undefined).catch(() => {}); this._context.setRequestInterceptor(undefined).catch(() => {});

View file

@ -79,8 +79,7 @@ export class DebugControllerDispatcher extends Dispatcher<DebugController, chann
await this._object.closeAllBrowsers(); await this._object.closeAllBrowsers();
} }
override _dispose() { override _onDispose() {
super._dispose();
this._object.dispose(); this._object.dispose();
} }
} }

View file

@ -102,8 +102,12 @@ export class Dispatcher<Type extends { guid: string }, ChannelType, ParentScopeT
this._connection.sendDispose(this); this._connection.sendDispose(this);
} }
protected _onDispose() {
}
private _disposeRecursively() { private _disposeRecursively() {
assert(!this._disposed, `${this._guid} is disposed more than once`); assert(!this._disposed, `${this._guid} is disposed more than once`);
this._onDispose();
this._disposed = true; this._disposed = true;
eventsHelper.removeEventListeners(this._eventListeners); eventsHelper.removeEventListeners(this._eventListeners);

View file

@ -299,9 +299,10 @@ export class PageDispatcher extends Dispatcher<Page, channels.PageChannel, Brows
this._dispatchEvent('frameDetached', { frame: FrameDispatcher.from(this, frame) }); this._dispatchEvent('frameDetached', { frame: FrameDispatcher.from(this, frame) });
} }
override _dispose() { override _onDispose() {
super._dispose(); // Avoid protocol calls for the closed page.
this._page.setClientRequestInterceptor(undefined).catch(() => {}); if (!this._page.isClosedOrClosingOrCrashed())
this._page.setClientRequestInterceptor(undefined).catch(() => {});
} }
} }

View file

@ -612,6 +612,10 @@ export class Page extends SdkObject {
return this._closedState === 'closed'; return this._closedState === 'closed';
} }
isClosedOrClosingOrCrashed() {
return this._closedState !== 'open' || this._crashedPromise.isDone();
}
_addWorker(workerId: string, worker: Worker) { _addWorker(workerId: string, worker: Worker) {
this._workers.set(workerId, worker); this._workers.set(workerId, worker);
this.emit(Page.Events.Worker, worker); this.emit(Page.Events.Worker, worker);

View file

@ -20,10 +20,12 @@ import { createGuid } from '../../packages/playwright-core/lib/utils';
import { Backend } from '../config/debugControllerBackend'; import { Backend } from '../config/debugControllerBackend';
import type { Browser, BrowserContext } from '@playwright/test'; import type { Browser, BrowserContext } from '@playwright/test';
type BrowserWithReuse = Browser & { _newContextForReuse: () => Promise<BrowserContext> };
type Fixtures = { type Fixtures = {
wsEndpoint: string; wsEndpoint: string;
backend: Backend; backend: Backend;
connectedBrowser: Browser & { _newContextForReuse: () => Promise<BrowserContext> }; connectedBrowserFactory: () => Promise<BrowserWithReuse>;
connectedBrowser: BrowserWithReuse;
}; };
const test = baseTest.extend<Fixtures>({ const test = baseTest.extend<Fixtures>({
@ -41,16 +43,24 @@ const test = baseTest.extend<Fixtures>({
await use(backend); await use(backend);
await backend.close(); await backend.close();
}, },
connectedBrowser: async ({ wsEndpoint, browserType }, use) => { connectedBrowserFactory: async ({ wsEndpoint, browserType }, use) => {
const oldValue = (browserType as any)._defaultConnectOptions; const browsers: BrowserWithReuse [] = [];
(browserType as any)._defaultConnectOptions = { await use(async () => {
wsEndpoint, const oldValue = (browserType as any)._defaultConnectOptions;
headers: { 'x-playwright-reuse-context': '1', }, (browserType as any)._defaultConnectOptions = {
}; wsEndpoint,
const browser = await browserType.launch(); headers: { 'x-playwright-reuse-context': '1', },
(browserType as any)._defaultConnectOptions = oldValue; };
await use(browser as any); const browser = await browserType.launch() as BrowserWithReuse;
await browser.close(); (browserType as any)._defaultConnectOptions = oldValue;
browsers.push(browser);
return browser;
});
for (const browser of browsers)
await browser.close();
},
connectedBrowser: async ({ connectedBrowserFactory }, use) => {
await use(await connectedBrowserFactory());
}, },
}); });
@ -231,3 +241,27 @@ test('should pause and resume', async ({ backend, connectedBrowser }) => {
await backend.resume(); await backend.resume();
await pausePromise; await pausePromise;
}); });
test('should reset routes before reuse', async ({ server, connectedBrowserFactory }) => {
const browser1 = await connectedBrowserFactory();
const context1 = await browser1._newContextForReuse();
await context1.route(server.PREFIX + '/title.html', route => route.fulfill({ body: '<title>Hello</title>', contentType: 'text/html' }));
const page1 = await context1.newPage();
await page1.route(server.PREFIX + '/consolelog.html', route => route.fulfill({ body: '<title>World</title>', contentType: 'text/html' }));
await page1.goto(server.PREFIX + '/title.html');
await expect(page1).toHaveTitle('Hello');
await page1.goto(server.PREFIX + '/consolelog.html');
await expect(page1).toHaveTitle('World');
await browser1.close();
const browser2 = await connectedBrowserFactory();
const context2 = await browser2._newContextForReuse();
const page2 = await context2.newPage();
await page2.goto(server.PREFIX + '/title.html');
await expect(page2).toHaveTitle('Woof-Woof');
await page2.goto(server.PREFIX + '/consolelog.html');
await expect(page2).toHaveTitle('console.log test');
await browser2.close();
});