fix(runner): do not force-kill worker while its teardown is in progress (#42010)

This commit is contained in:
Pavel Feldman
2026-07-28 08:44:43 -07:00
committed by GitHub
parent 205633dc53
commit 63ff5dc1b3
3 changed files with 46 additions and 5 deletions
@@ -105,6 +105,7 @@ export function startProcessRunner(create: (params: any) => ProcessRunner) {
}
const kForceExitTimeout = +(process.env.PWTEST_FORCE_EXIT_TIMEOUT || 30000);
const kHeartbeatInterval = 1000;
async function gracefullyCloseAndExit(forceExit: boolean) {
if (forceExit && !forceExitInitiated) {
@@ -115,8 +116,13 @@ async function gracefullyCloseAndExit(forceExit: boolean) {
}
if (!gracefullyCloseCalled) {
gracefullyCloseCalled = true;
// Heartbeats tell the parent that graceful close is still running, e.g. a fixture
// teardown with "timeout: 0", as opposed to a hung process that must be force-killed.
const heartbeat = setInterval(() => sendMessageToParent({ method: '__heartbeat__' }), kHeartbeatInterval);
heartbeat.unref();
// Meanwhile, try to gracefully shutdown.
await processRunner?.gracefullyClose().catch(() => {});
clearInterval(heartbeat);
if (processName)
await stopProfiling(processName).catch(() => {});
// eslint-disable-next-line no-restricted-properties
+19 -5
View File
@@ -170,11 +170,25 @@ export class ProcessHost extends EventEmitter {
return;
const exitPromise = new Promise<void>(f => this.once('exit', () => f()));
const timeout = +(process.env.PWTEST_CHILD_PROCESS_TIMEOUT || 5 * 60 * 1000);
const result = await raceAgainstDeadline(() => exitPromise, monotonicTime() + timeout);
if (result.timedOut) {
this.emit('processError', { message: `Error: ${this._processName} process did not exit within ${timeout}ms after stop, force-killed it` });
this._forceKill();
await exitPromise;
// Child sends heartbeats while gracefully closing, e.g. running a slow fixture
// teardown with "timeout: 0". Only force-kill when heartbeats stop coming.
let lastHeartbeat = monotonicTime();
const onHeartbeat = () => lastHeartbeat = monotonicTime();
this.on('__heartbeat__', onHeartbeat);
try {
while (true) {
const result = await raceAgainstDeadline(() => exitPromise, lastHeartbeat + timeout);
if (!result.timedOut)
return;
if (monotonicTime() < lastHeartbeat + timeout)
continue;
this.emit('processError', { message: `Error: ${this._processName} process did not exit within ${timeout}ms after stop, force-killed it` });
this._forceKill();
await exitPromise;
return;
}
} finally {
this.off('__heartbeat__', onHeartbeat);
}
}
+21
View File
@@ -230,3 +230,24 @@ test('should force-kill a worker that does not exit on stop', async ({ runInline
// Should complete well within a minute thanks to the watchdog.
expect(monotonicTime() - now).toBeLessThan(60000);
});
test('should not force-kill a worker that is running a slow fixture teardown', async ({ runInlineTest }) => {
test.info().annotations.push({ type: 'issue', description: 'https://github.com/microsoft/playwright/issues/42007' });
const result = await runInlineTest({
'a.spec.ts': `
import { test as base, expect } from '@playwright/test';
const test = base.extend<{}, { slowTeardown: void }>({
slowTeardown: [async ({}, use) => {
await use();
await new Promise(f => setTimeout(f, 4000));
console.log('slow teardown finished');
}, { scope: 'worker', timeout: 0 }],
});
test('passes', async ({ slowTeardown }) => {});
`,
}, undefined, { PWTEST_CHILD_PROCESS_TIMEOUT: '2000' });
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
expect(result.output).toContain('slow teardown finished');
expect(result.output).not.toContain('force-killed');
});