Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(webserver): customize shutdown with new kill option #34130

Open
wants to merge 28 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/src/test-api/class-testconfig.md
Original file line number Diff line number Diff line change
Expand Up @@ -629,6 +629,9 @@ export default defineConfig({
- `stdout` ?<["pipe"|"ignore"]> If `"pipe"`, it will pipe the stdout of the command to the process stdout. If `"ignore"`, it will ignore the stdout of the command. Default to `"ignore"`.
- `stderr` ?<["pipe"|"ignore"]> Whether to pipe the stderr of the command to the process stderr or ignore it. Defaults to `"pipe"`.
- `timeout` ?<[int]> How long to wait for the process to start up and be available in milliseconds. Defaults to 60000.
- `kill` ?<[Object]> How to shut down the process gracefully. If unspecified, the process group is forcefully `SIGKILL`ed. If set to `{ SIGINT: 500 }`, the process group is sent a `SIGINT` signal, followed by `SIGKILL` if it doesn't exit within 500ms. You can also use `SIGTERM` instead. A `0` timeout means no `SIGKILL` will be sent. Windows doesn't support `SIGINT` and `SIGTERM` signals, so this option is ignored.
- `SIGINT` ?<[int]>
- `SIGTERM` ?<[int]>
- `url` ?<[string]> The url on your http server that is expected to return a 2xx, 3xx, 400, 401, 402, or 403 status code when the server is ready to accept connections. Redirects (3xx status codes) are being followed and the new location is checked. Either `port` or `url` should be specified.

Launch a development web server (or multiple) during the tests.
Expand Down
1 change: 1 addition & 0 deletions docs/src/test-webserver-js.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export default defineConfig({
| `stdout` | If `"pipe"`, it will pipe the stdout of the command to the process stdout. If `"ignore"`, it will ignore the stdout of the command. Default to `"ignore"`. |
| `stderr` | Whether to pipe the stderr of the command to the process stderr or ignore it. Defaults to `"pipe"`. |
| `timeout` | How long to wait for the process to start up and be available in milliseconds. Defaults to 60000. |
| `kill` | How to shut down the process gracefully. If unspecified, the process group is forcefully `SIGKILL`ed. If set to `{ SIGINT: 500 }`, the process group is sent a `SIGINT` signal, followed by `SIGKILL` if it doesn't exit within 500ms. You can also use `SIGTERM` instead. A `0` timeout means no `SIGKILL` will be sent. Windows doesn't support `SIGINT` and `SIGTERM` signals, so this option is ignored. |
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd split it into 2 fields{ signal: SIGINT', timeout 5000 } to make it clear that only one signal can be specified and what the value semantics is.


## Adding a server timeout

Expand Down
4 changes: 2 additions & 2 deletions packages/playwright-core/src/utils/processLauncher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ export async function launchProcess(options: LaunchProcessOptions): Promise<Laun
let processClosed = false;
let fulfillCleanup = () => {};
const waitForCleanup = new Promise<void>(f => fulfillCleanup = f);
spawnedProcess.once('exit', (exitCode, signal) => {
spawnedProcess.once('close', (exitCode, signal) => {
options.log(`[pid=${spawnedProcess.pid}] <process did exit: exitCode=${exitCode}, signal=${signal}>`);
processClosed = true;
gracefullyCloseSet.delete(gracefullyClose);
Expand Down Expand Up @@ -226,7 +226,7 @@ export async function launchProcess(options: LaunchProcessOptions): Promise<Laun
killSet.delete(killProcessAndCleanup);
removeProcessHandlersIfNeeded();
options.log(`[pid=${spawnedProcess.pid}] <kill>`);
if (spawnedProcess.pid && !spawnedProcess.killed && !processClosed) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previously we wouldn't send the signal again if it has already been received, I believe it shouldn't matter for SIGKILL handling, but would be nice to double check.

if (spawnedProcess.pid && !processClosed) {
options.log(`[pid=${spawnedProcess.pid}] <will force kill>`);
// Force kill the browser.
try {
Expand Down
43 changes: 38 additions & 5 deletions packages/playwright/src/plugins/webServerPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export type WebServerPluginOptions = {
url?: string;
ignoreHTTPSErrors?: boolean;
timeout?: number;
kill?: { SIGINT?: number, SIGTERM?: number };
reuseExistingServer?: boolean;
cwd?: string;
env?: { [key: string]: string; };
Expand Down Expand Up @@ -91,8 +92,23 @@ export class WebServerPlugin implements TestRunnerPlugin {
throw new Error(`${this._options.url ?? `http://localhost${port ? ':' + port : ''}`} is already used, make sure that nothing is running on the port/url or set reuseExistingServer:true in config.webServer.`);
}

let signal: 'SIGINT' | 'SIGTERM' | undefined = undefined;
let timeout = 0;
if (this._options.kill) {
if (typeof this._options.kill.SIGINT === 'number') {
signal = 'SIGINT';
timeout = this._options.kill.SIGINT;
}
if (typeof this._options.kill.SIGTERM === 'number') {
if (signal)
throw new Error('Only one of SIGINT or SIGTERM can be specified in config.webServer.kill');
signal = 'SIGTERM';
timeout = this._options.kill.SIGTERM;
}
}

debugWebServer(`Starting WebServer process ${this._options.command}...`);
const { launchedProcess, kill } = await launchProcess({
const { launchedProcess, gracefullyClose } = await launchProcess({
command: this._options.command,
env: {
...DEFAULT_ENVIRONMENT_VARIABLES,
Expand All @@ -102,14 +118,31 @@ export class WebServerPlugin implements TestRunnerPlugin {
cwd: this._options.cwd,
stdio: 'stdin',
shell: true,
// Reject to indicate that we cannot close the web server gracefully
// and should fallback to non-graceful shutdown.
attemptToGracefullyClose: () => Promise.reject(),
attemptToGracefullyClose: async () => {
if (process.platform === 'win32')
throw new Error('Graceful shutdown is not supported on Windows');
if (!signal)
throw new Error('skip graceful shutdown');

// proper usage of SIGINT is to send it to the entire process group, see https://www.cons.org/cracauer/sigint.html
// there's no such convention for SIGTERM, so we decide what we want. signaling the process group for consistency.
process.kill(-launchedProcess.pid!, signal);

return new Promise<void>((resolve, reject) => {
const timer = timeout !== 0
? setTimeout(() => reject(new Error(`process didn't close gracefully within timeout, falling back to SIGKILL`)), timeout)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Drop 'falling back to SIGKILL' as it makes an assumption about the caller and how it handles errors. We already print <will force kill> before sending sigkill, so it should be clear.

: undefined;
launchedProcess.once('close', (...args) => {
clearTimeout(timer);
resolve();
});
});
},
log: () => {},
onExit: code => processExitedReject(new Error(code ? `Process from config.webServer was not able to start. Exit code: ${code}` : 'Process from config.webServer exited early.')),
tempDirectories: [],
});
this._killProcess = kill;
this._killProcess = gracefullyClose;

debugWebServer(`Process started`);

Expand Down
12 changes: 12 additions & 0 deletions packages/playwright/types/test.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9629,6 +9629,18 @@ interface TestConfigWebServer {
*/
timeout?: number;

/**
* How to shut down the process gracefully. If unspecified, the process group is forcefully `SIGKILL`ed. If set to `{
* SIGINT: 500 }`, the process group is sent a `SIGINT` signal, followed by `SIGKILL` if it doesn't exit within 500ms.
* You can also use `SIGTERM` instead. A `0` timeout means no `SIGKILL` will be sent. Windows doesn't support `SIGINT`
* and `SIGTERM` signals, so this option is ignored.
*/
kill?: {
SIGINT?: number;

SIGTERM?: number;
};

/**
* The url on your http server that is expected to return a 2xx, 3xx, 400, 401, 402, or 403 status code when the
* server is ready to accept connections. Redirects (3xx status codes) are being followed and the new location is
Expand Down
70 changes: 70 additions & 0 deletions tests/playwright-test/web-server.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import type http from 'http';
import path from 'path';
import { test, expect, parseTestRunnerOutput } from './playwright-test-fixtures';
import type { RunResult } from './playwright-test-fixtures';
import { createHttpServer } from '../../packages/playwright-core/lib/utils/network';

const SIMPLE_SERVER_PATH = path.join(__dirname, 'assets', 'simple-server.js');
Expand Down Expand Up @@ -744,3 +745,72 @@ test('should forward stdout when set to "pipe" before server is ready', async ({
expect(result.output).toContain('[WebServer] output from server');
expect(result.output).not.toContain('Timed out waiting 3000ms');
});

test.describe('kill option', () => {
test.skip(process.platform === 'win32', 'No sending SIGINT on Windows');

const files = (additionalOptions = {}) => {
const port = test.info().workerIndex * 2 + 10510;
return {
'child.js': `
process.on('SIGINT', () => { console.log('%%childprocess received SIGINT'); setTimeout(() => process.exit(), 10) })
process.on('SIGTERM', () => { console.log('%%childprocess received SIGTERM'); setTimeout(() => process.exit(), 10) })
setTimeout(() => {}, 100000) // prevent child from exiting
`,
'web-server.js': `
require("node:child_process").fork('./child.js', { silent: false })

process.on('SIGINT', () => {
console.log('%%webserver received SIGINT but stubbornly refuses to wind down')
})
process.on('SIGTERM', () => {
console.log('%%webserver received SIGTERM but stubbornly refuses to wind down')
})

const server = require("node:http").createServer((req, res) => { res.end("ok"); })
server.listen(process.argv[2]);
`,
'test.spec.ts': `
import { test, expect } from '@playwright/test';
test('pass', async ({}) => {});
`,
'playwright.config.ts': `
module.exports = {
webServer: {
command: 'echo some-precondition && node web-server.js ${port}',
port: ${port},
stdout: 'pipe',
timeout: 3000,
...${JSON.stringify(additionalOptions)}
},
};
`,
};
};

function parseOutputLines(result: RunResult): string[] {
const prefix = '[WebServer] %%';
return result.output.split('\n').filter(line => line.startsWith(prefix)).map(line => line.substring(prefix.length));
}

test('sends SIGKILL by default', async ({ runInlineTest }) => {
const result = await runInlineTest(files(), { workers: 1 });
expect(parseOutputLines(result)).toEqual([]);
});

test('can be configured to send SIGTERM', async ({ runInlineTest }) => {
const result = await runInlineTest(files({ kill: { SIGTERM: 500 } }), { workers: 1 });
expect(parseOutputLines(result).sort()).toEqual(['childprocess received SIGTERM', 'webserver received SIGTERM but stubbornly refuses to wind down']);
});

test('can be configured to send SIGINT', async ({ runInlineTest }) => {
const result = await runInlineTest(files({ kill: { SIGINT: 500 } }), { workers: 1 });
expect(parseOutputLines(result).sort()).toEqual(['childprocess received SIGINT', 'webserver received SIGINT but stubbornly refuses to wind down']);
});

test('throws when mixed', async ({ runInlineTest }) => {
const result = await runInlineTest(files({ kill: { SIGINT: 500, SIGTERM: 500 } }), { workers: 1 });
expect(result.exitCode).toBe(1);
expect(result.output).toContain('Only one of SIGINT or SIGTERM can be specified in config.webServer.kill');
});
});
Loading