From f8e01739b8699dbf29acbb9280610a935bfbdb34 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Mon, 27 Jul 2026 11:48:41 -0700 Subject: [PATCH] fix(runner): do not force-kill worker while its teardown is in progress Worker sends heartbeats while gracefully closing, so that a slow fixture teardown with "timeout: 0" is not force-killed. A hung worker does not send heartbeats and is still force-killed after the timeout. Fixes: https://github.com/microsoft/playwright/issues/42007 --- packages/playwright/src/common/process.ts | 6 +++++ packages/playwright/src/runner/processHost.ts | 24 +++++++++++++++---- tests/playwright-test/exit-code.spec.ts | 21 ++++++++++++++++ 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/packages/playwright/src/common/process.ts b/packages/playwright/src/common/process.ts index 57c1e99c5d106..1d65cf1146d0b 100644 --- a/packages/playwright/src/common/process.ts +++ b/packages/playwright/src/common/process.ts @@ -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 diff --git a/packages/playwright/src/runner/processHost.ts b/packages/playwright/src/runner/processHost.ts index 5c0257933f8a8..b812b700240df 100644 --- a/packages/playwright/src/runner/processHost.ts +++ b/packages/playwright/src/runner/processHost.ts @@ -170,11 +170,25 @@ export class ProcessHost extends EventEmitter { return; const exitPromise = new Promise(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); } } diff --git a/tests/playwright-test/exit-code.spec.ts b/tests/playwright-test/exit-code.spec.ts index 82b4f0d7db60b..44d6238930aec 100644 --- a/tests/playwright-test/exit-code.spec.ts +++ b/tests/playwright-test/exit-code.spec.ts @@ -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'); +});