feat(rpc): gracefully close browsers in server process on disconnect (#3005)

This commit is contained in:
Dmitry Gozman 2020-07-17 16:14:23 -07:00 committed by GitHub
parent 9d9801192e
commit 91e1a25f34
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 52 additions and 36 deletions

View file

@ -20,12 +20,19 @@ import { Playwright } from '../server/playwright';
import { PlaywrightDispatcher } from './server/playwrightDispatcher'; import { PlaywrightDispatcher } from './server/playwrightDispatcher';
import { Electron } from '../server/electron'; import { Electron } from '../server/electron';
import { setUseApiName } from '../progress'; import { setUseApiName } from '../progress';
import { gracefullyCloseAll } from '../server/processLauncher';
setUseApiName(false); setUseApiName(false);
const dispatcherConnection = new DispatcherConnection(); const dispatcherConnection = new DispatcherConnection();
const transport = new Transport(process.stdout, process.stdin); const transport = new Transport(process.stdout, process.stdin);
transport.onclose = () => process.exit(0); transport.onclose = async () => {
// Force exit after 30 seconds.
setTimeout(() => process.exit(0), 30000);
// Meanwhile, try to gracefully close all browsers.
await gracefullyCloseAll();
process.exit(0);
};
transport.onmessage = message => dispatcherConnection.dispatch(JSON.parse(message)); transport.onmessage = message => dispatcherConnection.dispatch(JSON.parse(message));
dispatcherConnection.onmessage = message => transport.send(JSON.stringify(message)); dispatcherConnection.onmessage = message => transport.send(JSON.stringify(message));

View file

@ -49,6 +49,12 @@ type LaunchResult = {
kill: () => Promise<void>, kill: () => Promise<void>,
}; };
const gracefullyCloseSet = new Set<() => Promise<void>>();
export async function gracefullyCloseAll() {
await Promise.all(Array.from(gracefullyCloseSet).map(gracefullyClose => gracefullyClose().catch(e => {})));
}
export async function launchProcess(options: LaunchProcessOptions): Promise<LaunchResult> { export async function launchProcess(options: LaunchProcessOptions): Promise<LaunchResult> {
const cleanup = () => helper.removeFolders(options.tempDirectories); const cleanup = () => helper.removeFolders(options.tempDirectories);
@ -97,6 +103,7 @@ export async function launchProcess(options: LaunchProcessOptions): Promise<Laun
progress.logger.info(`<process did exit: exitCode=${exitCode}, signal=${signal}>`); progress.logger.info(`<process did exit: exitCode=${exitCode}, signal=${signal}>`);
processClosed = true; processClosed = true;
helper.removeEventListeners(listeners); helper.removeEventListeners(listeners);
gracefullyCloseSet.delete(gracefullyClose);
options.onExit(exitCode, signal); options.onExit(exitCode, signal);
fulfillClose(); fulfillClose();
// Cleanup as process exits. // Cleanup as process exits.
@ -113,9 +120,11 @@ export async function launchProcess(options: LaunchProcessOptions): Promise<Laun
listeners.push(helper.addEventListener(process, 'SIGTERM', gracefullyClose)); listeners.push(helper.addEventListener(process, 'SIGTERM', gracefullyClose));
if (options.handleSIGHUP) if (options.handleSIGHUP)
listeners.push(helper.addEventListener(process, 'SIGHUP', gracefullyClose)); listeners.push(helper.addEventListener(process, 'SIGHUP', gracefullyClose));
gracefullyCloseSet.add(gracefullyClose);
let gracefullyClosing = false; let gracefullyClosing = false;
async function gracefullyClose(): Promise<void> { async function gracefullyClose(): Promise<void> {
gracefullyCloseSet.delete(gracefullyClose);
// We keep listeners until we are done, to handle 'exit' and 'SIGINT' while // We keep listeners until we are done, to handle 'exit' and 'SIGINT' while
// asynchronously closing to prevent zombie processes. This might introduce // asynchronously closing to prevent zombie processes. This might introduce
// reentrancy to this function, for example user sends SIGINT second time. // reentrancy to this function, for example user sends SIGINT second time.

View file

@ -32,7 +32,7 @@ describe.skip(!CHROMIUM)('launcher', function() {
const browser = await browserType.launchServer(options); const browser = await browserType.launchServer(options);
await browser.close(); await browser.close();
}); });
it.skip(USES_HOOKS).fail(CHROMIUM && WIN)('should open devtools when "devtools: true" option is given', async({browserType, defaultBrowserOptions}) => { it.fail(USES_HOOKS || WIN)('should open devtools when "devtools: true" option is given', async({browserType, defaultBrowserOptions}) => {
let devtoolsCallback; let devtoolsCallback;
const devtoolsPromise = new Promise(f => devtoolsCallback = f); const devtoolsPromise = new Promise(f => devtoolsCallback = f);
const __testHookForDevTools = devtools => devtools.__testHookOnBinding = parsed => { const __testHookForDevTools = devtools => devtools.__testHookOnBinding = parsed => {

View file

@ -161,7 +161,7 @@ class PlaywrightEnvironment {
constructor(playwright) { constructor(playwright) {
this._playwright = playwright; this._playwright = playwright;
this.spawnedProcess = undefined; this.spawnedProcess = undefined;
this.expectExit = false; this.onExit = undefined;
} }
name() { return 'Playwright'; }; name() { return 'Playwright'; };
@ -173,13 +173,13 @@ class PlaywrightEnvironment {
if (process.env.PWCHANNEL === 'wire') { if (process.env.PWCHANNEL === 'wire') {
this.spawnedProcess = childProcess.fork(path.join(__dirname, '..', 'lib', 'rpc', 'server'), [], { this.spawnedProcess = childProcess.fork(path.join(__dirname, '..', 'lib', 'rpc', 'server'), [], {
stdio: 'pipe', stdio: 'pipe',
detached: process.platform !== 'win32', detached: true,
});
this.spawnedProcess.once('exit', (exitCode, signal) => {
this.spawnedProcess = undefined;
if (!this.expectExit)
throw new Error(`Server closed with exitCode=${exitCode} signal=${signal}`);
}); });
this.spawnedProcess.unref();
this.onExit = (exitCode, signal) => {
throw new Error(`Server closed with exitCode=${exitCode} signal=${signal}`);
};
this.spawnedProcess.once('exit', this.onExit);
const transport = new Transport(this.spawnedProcess.stdin, this.spawnedProcess.stdout); const transport = new Transport(this.spawnedProcess.stdin, this.spawnedProcess.stdout);
connection.onmessage = message => transport.send(JSON.stringify(message)); connection.onmessage = message => transport.send(JSON.stringify(message));
transport.onmessage = message => connection.dispatch(JSON.parse(message)); transport.onmessage = message => connection.dispatch(JSON.parse(message));
@ -205,10 +205,10 @@ class PlaywrightEnvironment {
async afterAll(state) { async afterAll(state) {
if (this.spawnedProcess) { if (this.spawnedProcess) {
const exited = new Promise(f => this.spawnedProcess.once('exit', f)); this.spawnedProcess.removeListener('exit', this.onExit);
this.expectExit = true; this.spawnedProcess.stdin.destroy();
this.spawnedProcess.kill(); this.spawnedProcess.stdout.destroy();
await exited; this.spawnedProcess.stderr.destroy();
} }
delete state.playwright; delete state.playwright;
} }

View file

@ -40,7 +40,7 @@ module.exports = function registerFixtures(global) {
server.PREFIX = `http://localhost:${port}`; server.PREFIX = `http://localhost:${port}`;
server.CROSS_PROCESS_PREFIX = `http://127.0.0.1:${port}`; server.CROSS_PROCESS_PREFIX = `http://127.0.0.1:${port}`;
server.EMPTY_PAGE = `http://localhost:${port}/empty.html`; server.EMPTY_PAGE = `http://localhost:${port}/empty.html`;
const httpsPort = port + 1; const httpsPort = port + 1;
const httpsServer = await TestServer.createHTTPS(assetsPath, httpsPort); const httpsServer = await TestServer.createHTTPS(assetsPath, httpsPort);
httpsServer.enableHTTPCache(cachedPath); httpsServer.enableHTTPCache(cachedPath);
@ -48,13 +48,13 @@ module.exports = function registerFixtures(global) {
httpsServer.PREFIX = `https://localhost:${httpsPort}`; httpsServer.PREFIX = `https://localhost:${httpsPort}`;
httpsServer.CROSS_PROCESS_PREFIX = `https://127.0.0.1:${httpsPort}`; httpsServer.CROSS_PROCESS_PREFIX = `https://127.0.0.1:${httpsPort}`;
httpsServer.EMPTY_PAGE = `https://localhost:${httpsPort}/empty.html`; httpsServer.EMPTY_PAGE = `https://localhost:${httpsPort}/empty.html`;
await test({server, httpsServer}); await test({server, httpsServer});
await Promise.all([ await Promise.all([
server.stop(), server.stop(),
httpsServer.stop(), httpsServer.stop(),
]); ]);
}); });
global.registerWorkerFixture('defaultBrowserOptions', async({}, test) => { global.registerWorkerFixture('defaultBrowserOptions', async({}, test) => {
@ -72,17 +72,17 @@ module.exports = function registerFixtures(global) {
const connection = new Connection(); const connection = new Connection();
let toImpl; let toImpl;
let spawnedProcess; let spawnedProcess;
let expectExit; let onExit;
if (process.env.PWCHANNEL === 'wire') { if (process.env.PWCHANNEL === 'wire') {
spawnedProcess = childProcess.fork(path.join(__dirname, '..', '..', 'lib', 'rpc', 'server'), [], { spawnedProcess = childProcess.fork(path.join(__dirname, '..', '..', 'lib', 'rpc', 'server'), [], {
stdio: 'pipe', stdio: 'pipe',
detached: process.platform !== 'win32', detached: true,
});
spawnedProcess.once('exit', (exitCode, signal) => {
spawnedProcess = undefined;
if (!expectExit)
throw new Error(`Server closed with exitCode=${exitCode} signal=${signal}`);
}); });
spawnedProcess.unref();
onExit = (exitCode, signal) => {
throw new Error(`Server closed with exitCode=${exitCode} signal=${signal}`);
};
spawnedProcess.on('exit', onExit);
const transport = new Transport(spawnedProcess.stdin, spawnedProcess.stdout); const transport = new Transport(spawnedProcess.stdin, spawnedProcess.stdout);
connection.onmessage = message => transport.send(JSON.stringify(message)); connection.onmessage = message => transport.send(JSON.stringify(message));
transport.onmessage = message => connection.dispatch(JSON.parse(message)); transport.onmessage = message => connection.dispatch(JSON.parse(message));
@ -103,17 +103,16 @@ module.exports = function registerFixtures(global) {
const playwrightObject = await connection.waitForObjectWithKnownName('playwright'); const playwrightObject = await connection.waitForObjectWithKnownName('playwright');
playwrightObject.toImpl = toImpl; playwrightObject.toImpl = toImpl;
await test(playwrightObject); await test(playwrightObject);
if (spawnedProcess) { if (spawnedProcess) {
const exited = new Promise(f => spawnedProcess.once('exit', f)); spawnedProcess.removeListener('exit', onExit);
expectExit = true; spawnedProcess.stdin.destroy();
spawnedProcess.kill(); spawnedProcess.stdout.destroy();
await exited; spawnedProcess.stderr.destroy();
} }
return; } else {
playwright.toImpl = x => x;
await test(playwright);
} }
playwright.toImpl = x => x;
await test(playwright);
}); });
global.registerFixture('toImpl', async ({playwright}, test) => { global.registerFixture('toImpl', async ({playwright}, test) => {
@ -123,7 +122,7 @@ module.exports = function registerFixtures(global) {
global.registerWorkerFixture('browserType', async ({playwright}, test) => { global.registerWorkerFixture('browserType', async ({playwright}, test) => {
await test(playwright[process.env.BROWSER || 'chromium']); await test(playwright[process.env.BROWSER || 'chromium']);
}); });
global.registerWorkerFixture('browser', async ({browserType, defaultBrowserOptions}, test) => { global.registerWorkerFixture('browser', async ({browserType, defaultBrowserOptions}, test) => {
const browser = await browserType.launch(defaultBrowserOptions); const browser = await browserType.launch(defaultBrowserOptions);
try { try {
@ -136,7 +135,7 @@ module.exports = function registerFixtures(global) {
await browser.close(); await browser.close();
} }
}); });
global.registerFixture('context', async ({browser}, test) => { global.registerFixture('context', async ({browser}, test) => {
const context = await browser.newContext(); const context = await browser.newContext();
try { try {
@ -145,7 +144,7 @@ module.exports = function registerFixtures(global) {
await context.close(); await context.close();
} }
}); });
global.registerFixture('page', async ({context}, test) => { global.registerFixture('page', async ({context}, test) => {
const page = await context.newPage(); const page = await context.newPage();
await test(page); await test(page);

View file

@ -81,6 +81,7 @@ class PlaywrightEnvironment extends NodeEnvironment {
this.global.describe.fail = this.global.describe.skip; this.global.describe.fail = this.global.describe.skip;
const itSkip = this.global.it.skip; const itSkip = this.global.it.skip;
itSkip.slow = () => itSkip;
this.global.it.skip = (...args) => { this.global.it.skip = (...args) => {
if (args.length = 1) if (args.length = 1)
return args[0] ? itSkip : this.global.it; return args[0] ? itSkip : this.global.it;
@ -199,7 +200,7 @@ class FixturePool {
await this.setupFixture(name); await this.setupFixture(name);
const params = {}; const params = {};
for (const n of names) for (const n of names)
params[n] = this.instances.get(n).value; params[n] = this.instances.get(n).value;
return fn(params); return fn(params);
} }
} }

View file

@ -301,7 +301,7 @@ describe('browserType.connect', function() {
await browserServer._checkLeaks(); await browserServer._checkLeaks();
await browserServer.close(); await browserServer.close();
}); });
it.skip(USES_HOOKS).slow().fail(CHROMIUM && WIN)('should handle exceptions during connect', async({browserType, defaultBrowserOptions, server}) => { it.fail(USES_HOOKS || (CHROMIUM && WIN)).slow()('should handle exceptions during connect', async({browserType, defaultBrowserOptions, server}) => {
const browserServer = await browserType.launchServer(defaultBrowserOptions); const browserServer = await browserType.launchServer(defaultBrowserOptions);
const __testHookBeforeCreateBrowser = () => { throw new Error('Dummy') }; const __testHookBeforeCreateBrowser = () => { throw new Error('Dummy') };
const error = await browserType.connect({ wsEndpoint: browserServer.wsEndpoint(), __testHookBeforeCreateBrowser }).catch(e => e); const error = await browserType.connect({ wsEndpoint: browserServer.wsEndpoint(), __testHookBeforeCreateBrowser }).catch(e => e);