Skip to content

Commit 91e1a25

Browse files
authored
feat(rpc): gracefully close browsers in server process on disconnect (#3005)
1 parent 9d98011 commit 91e1a25

File tree

7 files changed

+52
-36
lines changed

7 files changed

+52
-36
lines changed

src/rpc/server.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,19 @@ import { Playwright } from '../server/playwright';
2020
import { PlaywrightDispatcher } from './server/playwrightDispatcher';
2121
import { Electron } from '../server/electron';
2222
import { setUseApiName } from '../progress';
23+
import { gracefullyCloseAll } from '../server/processLauncher';
2324

2425
setUseApiName(false);
2526

2627
const dispatcherConnection = new DispatcherConnection();
2728
const transport = new Transport(process.stdout, process.stdin);
28-
transport.onclose = () => process.exit(0);
29+
transport.onclose = async () => {
30+
// Force exit after 30 seconds.
31+
setTimeout(() => process.exit(0), 30000);
32+
// Meanwhile, try to gracefully close all browsers.
33+
await gracefullyCloseAll();
34+
process.exit(0);
35+
};
2936
transport.onmessage = message => dispatcherConnection.dispatch(JSON.parse(message));
3037
dispatcherConnection.onmessage = message => transport.send(JSON.stringify(message));
3138

src/server/processLauncher.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@ type LaunchResult = {
4949
kill: () => Promise<void>,
5050
};
5151

52+
const gracefullyCloseSet = new Set<() => Promise<void>>();
53+
54+
export async function gracefullyCloseAll() {
55+
await Promise.all(Array.from(gracefullyCloseSet).map(gracefullyClose => gracefullyClose().catch(e => {})));
56+
}
57+
5258
export async function launchProcess(options: LaunchProcessOptions): Promise<LaunchResult> {
5359
const cleanup = () => helper.removeFolders(options.tempDirectories);
5460

@@ -97,6 +103,7 @@ export async function launchProcess(options: LaunchProcessOptions): Promise<Laun
97103
progress.logger.info(`<process did exit: exitCode=${exitCode}, signal=${signal}>`);
98104
processClosed = true;
99105
helper.removeEventListeners(listeners);
106+
gracefullyCloseSet.delete(gracefullyClose);
100107
options.onExit(exitCode, signal);
101108
fulfillClose();
102109
// Cleanup as process exits.
@@ -113,9 +120,11 @@ export async function launchProcess(options: LaunchProcessOptions): Promise<Laun
113120
listeners.push(helper.addEventListener(process, 'SIGTERM', gracefullyClose));
114121
if (options.handleSIGHUP)
115122
listeners.push(helper.addEventListener(process, 'SIGHUP', gracefullyClose));
123+
gracefullyCloseSet.add(gracefullyClose);
116124

117125
let gracefullyClosing = false;
118126
async function gracefullyClose(): Promise<void> {
127+
gracefullyCloseSet.delete(gracefullyClose);
119128
// We keep listeners until we are done, to handle 'exit' and 'SIGINT' while
120129
// asynchronously closing to prevent zombie processes. This might introduce
121130
// reentrancy to this function, for example user sends SIGINT second time.

test/chromium/launcher.jest.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ describe.skip(!CHROMIUM)('launcher', function() {
3232
const browser = await browserType.launchServer(options);
3333
await browser.close();
3434
});
35-
it.skip(USES_HOOKS).fail(CHROMIUM && WIN)('should open devtools when "devtools: true" option is given', async({browserType, defaultBrowserOptions}) => {
35+
it.fail(USES_HOOKS || WIN)('should open devtools when "devtools: true" option is given', async({browserType, defaultBrowserOptions}) => {
3636
let devtoolsCallback;
3737
const devtoolsPromise = new Promise(f => devtoolsCallback = f);
3838
const __testHookForDevTools = devtools => devtools.__testHookOnBinding = parsed => {

test/environments.js

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ class PlaywrightEnvironment {
161161
constructor(playwright) {
162162
this._playwright = playwright;
163163
this.spawnedProcess = undefined;
164-
this.expectExit = false;
164+
this.onExit = undefined;
165165
}
166166

167167
name() { return 'Playwright'; };
@@ -173,13 +173,13 @@ class PlaywrightEnvironment {
173173
if (process.env.PWCHANNEL === 'wire') {
174174
this.spawnedProcess = childProcess.fork(path.join(__dirname, '..', 'lib', 'rpc', 'server'), [], {
175175
stdio: 'pipe',
176-
detached: process.platform !== 'win32',
177-
});
178-
this.spawnedProcess.once('exit', (exitCode, signal) => {
179-
this.spawnedProcess = undefined;
180-
if (!this.expectExit)
181-
throw new Error(`Server closed with exitCode=${exitCode} signal=${signal}`);
176+
detached: true,
182177
});
178+
this.spawnedProcess.unref();
179+
this.onExit = (exitCode, signal) => {
180+
throw new Error(`Server closed with exitCode=${exitCode} signal=${signal}`);
181+
};
182+
this.spawnedProcess.once('exit', this.onExit);
183183
const transport = new Transport(this.spawnedProcess.stdin, this.spawnedProcess.stdout);
184184
connection.onmessage = message => transport.send(JSON.stringify(message));
185185
transport.onmessage = message => connection.dispatch(JSON.parse(message));
@@ -205,10 +205,10 @@ class PlaywrightEnvironment {
205205

206206
async afterAll(state) {
207207
if (this.spawnedProcess) {
208-
const exited = new Promise(f => this.spawnedProcess.once('exit', f));
209-
this.expectExit = true;
210-
this.spawnedProcess.kill();
211-
await exited;
208+
this.spawnedProcess.removeListener('exit', this.onExit);
209+
this.spawnedProcess.stdin.destroy();
210+
this.spawnedProcess.stdout.destroy();
211+
this.spawnedProcess.stderr.destroy();
212212
}
213213
delete state.playwright;
214214
}

test/jest/fixtures.js

Lines changed: 20 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -40,21 +40,21 @@ module.exports = function registerFixtures(global) {
4040
server.PREFIX = `http://localhost:${port}`;
4141
server.CROSS_PROCESS_PREFIX = `http://127.0.0.1:${port}`;
4242
server.EMPTY_PAGE = `http://localhost:${port}/empty.html`;
43-
43+
4444
const httpsPort = port + 1;
4545
const httpsServer = await TestServer.createHTTPS(assetsPath, httpsPort);
4646
httpsServer.enableHTTPCache(cachedPath);
4747
httpsServer.PORT = httpsPort;
4848
httpsServer.PREFIX = `https://localhost:${httpsPort}`;
4949
httpsServer.CROSS_PROCESS_PREFIX = `https://127.0.0.1:${httpsPort}`;
5050
httpsServer.EMPTY_PAGE = `https://localhost:${httpsPort}/empty.html`;
51-
51+
5252
await test({server, httpsServer});
5353

5454
await Promise.all([
5555
server.stop(),
5656
httpsServer.stop(),
57-
]);
57+
]);
5858
});
5959

6060
global.registerWorkerFixture('defaultBrowserOptions', async({}, test) => {
@@ -72,17 +72,17 @@ module.exports = function registerFixtures(global) {
7272
const connection = new Connection();
7373
let toImpl;
7474
let spawnedProcess;
75-
let expectExit;
75+
let onExit;
7676
if (process.env.PWCHANNEL === 'wire') {
7777
spawnedProcess = childProcess.fork(path.join(__dirname, '..', '..', 'lib', 'rpc', 'server'), [], {
7878
stdio: 'pipe',
79-
detached: process.platform !== 'win32',
80-
});
81-
spawnedProcess.once('exit', (exitCode, signal) => {
82-
spawnedProcess = undefined;
83-
if (!expectExit)
84-
throw new Error(`Server closed with exitCode=${exitCode} signal=${signal}`);
79+
detached: true,
8580
});
81+
spawnedProcess.unref();
82+
onExit = (exitCode, signal) => {
83+
throw new Error(`Server closed with exitCode=${exitCode} signal=${signal}`);
84+
};
85+
spawnedProcess.on('exit', onExit);
8686
const transport = new Transport(spawnedProcess.stdin, spawnedProcess.stdout);
8787
connection.onmessage = message => transport.send(JSON.stringify(message));
8888
transport.onmessage = message => connection.dispatch(JSON.parse(message));
@@ -103,17 +103,16 @@ module.exports = function registerFixtures(global) {
103103
const playwrightObject = await connection.waitForObjectWithKnownName('playwright');
104104
playwrightObject.toImpl = toImpl;
105105
await test(playwrightObject);
106-
107106
if (spawnedProcess) {
108-
const exited = new Promise(f => spawnedProcess.once('exit', f));
109-
expectExit = true;
110-
spawnedProcess.kill();
111-
await exited;
107+
spawnedProcess.removeListener('exit', onExit);
108+
spawnedProcess.stdin.destroy();
109+
spawnedProcess.stdout.destroy();
110+
spawnedProcess.stderr.destroy();
112111
}
113-
return;
112+
} else {
113+
playwright.toImpl = x => x;
114+
await test(playwright);
114115
}
115-
playwright.toImpl = x => x;
116-
await test(playwright);
117116
});
118117

119118
global.registerFixture('toImpl', async ({playwright}, test) => {
@@ -123,7 +122,7 @@ module.exports = function registerFixtures(global) {
123122
global.registerWorkerFixture('browserType', async ({playwright}, test) => {
124123
await test(playwright[process.env.BROWSER || 'chromium']);
125124
});
126-
125+
127126
global.registerWorkerFixture('browser', async ({browserType, defaultBrowserOptions}, test) => {
128127
const browser = await browserType.launch(defaultBrowserOptions);
129128
try {
@@ -136,7 +135,7 @@ module.exports = function registerFixtures(global) {
136135
await browser.close();
137136
}
138137
});
139-
138+
140139
global.registerFixture('context', async ({browser}, test) => {
141140
const context = await browser.newContext();
142141
try {
@@ -145,7 +144,7 @@ module.exports = function registerFixtures(global) {
145144
await context.close();
146145
}
147146
});
148-
147+
149148
global.registerFixture('page', async ({context}, test) => {
150149
const page = await context.newPage();
151150
await test(page);

test/jest/playwrightEnvironment.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ class PlaywrightEnvironment extends NodeEnvironment {
8181
this.global.describe.fail = this.global.describe.skip;
8282

8383
const itSkip = this.global.it.skip;
84+
itSkip.slow = () => itSkip;
8485
this.global.it.skip = (...args) => {
8586
if (args.length = 1)
8687
return args[0] ? itSkip : this.global.it;
@@ -199,7 +200,7 @@ class FixturePool {
199200
await this.setupFixture(name);
200201
const params = {};
201202
for (const n of names)
202-
params[n] = this.instances.get(n).value;
203+
params[n] = this.instances.get(n).value;
203204
return fn(params);
204205
}
205206
}

test/launcher.jest.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,7 @@ describe('browserType.connect', function() {
301301
await browserServer._checkLeaks();
302302
await browserServer.close();
303303
});
304-
it.skip(USES_HOOKS).slow().fail(CHROMIUM && WIN)('should handle exceptions during connect', async({browserType, defaultBrowserOptions, server}) => {
304+
it.fail(USES_HOOKS || (CHROMIUM && WIN)).slow()('should handle exceptions during connect', async({browserType, defaultBrowserOptions, server}) => {
305305
const browserServer = await browserType.launchServer(defaultBrowserOptions);
306306
const __testHookBeforeCreateBrowser = () => { throw new Error('Dummy') };
307307
const error = await browserType.connect({ wsEndpoint: browserServer.wsEndpoint(), __testHookBeforeCreateBrowser }).catch(e => e);

0 commit comments

Comments
 (0)